From e5b4bb3ea3007b5995e28e4014be0f9fce9241a6 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 4 Nov 2026 13:06:11 +0300 Subject: [PATCH] refactor: normalize view models and share breadcrumbs and titles (WealthLedger/frontend#143) --- eslint-rules.mjs | 7 ++- .../routes/DocumentTitle/DocumentTitle.tsx | 5 +- .../RouteErrorBoundary/RouteErrorBoundary.tsx | 4 +- src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx | 24 +++++--- src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx | 25 -------- src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx | 2 +- src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx | 25 -------- .../ui/AppBreadcrumbs/EntityBreadcrumbs.tsx | 48 +++++++++++++++ .../ui/AppBreadcrumbs/StockBreadcrumbs.tsx | 25 -------- .../TbankConnectionSection.tsx | 12 ++-- .../TbankConnectionSection.vm.ts | 4 +- .../AccountEventsPage.filters.test.tsx | 8 +-- .../AccountEventsPage/AccountEventsPage.tsx | 12 ++-- .../AccountEventsPage/AccountEventsPage.vm.ts | 4 +- .../AccountIncomePage.test.tsx | 4 +- .../AccountIncomePage/AccountIncomePage.tsx | 12 ++-- .../AccountIncomePage/AccountIncomePage.vm.ts | 4 +- .../AccountOperationsPage.tsx | 10 ++-- .../account/ui/AccountPage/AccountPage.tsx | 60 +++++++++---------- .../ui/AccountPage/AccountPage.vm.test.tsx | 12 ++-- .../account/ui/AccountPage/AccountPage.vm.ts | 4 +- .../accounts/ui/AccountsPage/AccountsPage.tsx | 16 ++--- .../ui/AccountsPage/AccountsPage.vm.ts | 4 +- src/pages/login/ui/LoginPage.tsx | 4 +- src/pages/not-found/ui/NotFoundPage.tsx | 5 +- src/pages/register/ui/RegisterPage.tsx | 4 +- .../settings/ui/SettingsPage/SettingsPage.tsx | 17 +++++- .../ui/SettingsPage/SettingsPage.vm.ts | 21 ------- src/shared/ui/index.ts | 1 + src/shared/ui/useAppDocumentTitle.ts | 13 ++++ 30 files changed, 194 insertions(+), 202 deletions(-) delete mode 100644 src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx delete mode 100644 src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx create mode 100644 src/app/ui/AppBreadcrumbs/EntityBreadcrumbs.tsx delete mode 100644 src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx delete mode 100644 src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts create mode 100644 src/shared/ui/useAppDocumentTitle.ts diff --git a/eslint-rules.mjs b/eslint-rules.mjs index bbb543c..5eaa6f9 100644 --- a/eslint-rules.mjs +++ b/eslint-rules.mjs @@ -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", diff --git a/src/app/routes/DocumentTitle/DocumentTitle.tsx b/src/app/routes/DocumentTitle/DocumentTitle.tsx index 61d25f0..2db4f17 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.tsx @@ -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 = (props): undefined => { - const { t } = useTranslation(); - useDocumentTitle(`${props.title} — ${t("appName")}`); + useAppDocumentTitle(props.title); }; type AccountDocumentTitleProps = { diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx index cd06c6f..35b6e38 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -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 { const RouteErrorFallback: FC = (props) => { const { authenticated } = props; const { t } = useTranslation(); - useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`); + useAppDocumentTitle(t("routeError.title")); const to = authenticated ? "/accounts" : "/login"; return ( diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx index b21d084..9241a4f 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx @@ -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 ; - if (stock) - return ; + if (bond) { + return ; + } + + if (stock) { + return ( + + ); + } + if (account && (section || !account.params.section)) return ( @@ -43,8 +48,13 @@ const AppBreadcrumbs: FC = () => { /> ); - if (card) return ; + + if (card) { + return ; + } + if (cards) return ; + if (accounts) return ; diff --git a/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx deleted file mode 100644 index 08cb1d2..0000000 --- a/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx +++ /dev/null @@ -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 = (props) => { - const { data } = useBond(props.identifier); - const { t } = useTranslation(); - - return ( - - ); -}; - -export { BondBreadcrumbs }; diff --git a/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx b/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx index 52cf37c..30b60e0 100644 --- a/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx +++ b/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx @@ -39,4 +39,4 @@ const BreadcrumbTrail: FC = (props) => { ); }; -export { BreadcrumbTrail }; +export { BreadcrumbTrail, type Crumb }; diff --git a/src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx deleted file mode 100644 index bf4dfb6..0000000 --- a/src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx +++ /dev/null @@ -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 = (props) => { - const { data } = useBankCard(props.identifier); - const { t } = useTranslation(); - - return ( - - ); -}; - -export { CardBreadcrumbs }; diff --git a/src/app/ui/AppBreadcrumbs/EntityBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/EntityBreadcrumbs.tsx new file mode 100644 index 0000000..cfcef84 --- /dev/null +++ b/src/app/ui/AppBreadcrumbs/EntityBreadcrumbs.tsx @@ -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 = (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 ; +}; + +export { EntityBreadcrumbs }; diff --git a/src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx deleted file mode 100644 index afdb04d..0000000 --- a/src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx +++ /dev/null @@ -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 = (props) => { - const { data } = useStock(props.identifier); - const { t } = useTranslation(); - - return ( - - ); -}; - -export { StockBreadcrumbs }; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx index a0a7eac..3dab9ca 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx @@ -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 ; + const vm = useTbankConnectionSectionVm(); + if (vm.state === "loading") return ; - return viewModel.state === "error" - ? - : ; + return vm.state === "error" + ? + : ; }; export { TbankConnectionSection }; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts index 9507c0e..fbb860f 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts @@ -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 }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index ba652d0..71c8821 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -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 }) => ( @@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => { const queryClient = createTestQueryClient(); const { result } = renderHook( - () => useAccountEventsPageViewModel(), + () => useAccountEventsPageVm(), { wrapper: ({ children }) => ( @@ -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 }) => ( diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx index 682dd4a..a898a75 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx @@ -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 ; + const vm = useAccountEventsPageVm(); + if (vm.state === "loading") return ; - return viewModel.state === "error" - ? - : ; + return vm.state === "error" + ? + : ; }; export { AccountEventsPage }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 3190415..010c865 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -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 }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 2e6def7..586caa0 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -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 }) => ( diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx index f272f09..6133d53 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx @@ -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 ; + const vm = useAccountIncomePageVm(); + if (vm.state === "loading") return ; - return viewModel.state === "error" - ? - : ; + return vm.state === "error" + ? + : ; }; export { AccountIncomePage }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 069c915..96ad523 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -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, }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx index de98d16..3b12363 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx @@ -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 ; } - return viewModel.state === "error" - ? - : ; + return vm.state === "error" + ? + : ; }; export { AccountOperationsPage }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index db138bc..0b74da4 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -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 ; - if (viewModel.state === "error") return ; - if (viewModel.state === "empty") + const vm = useAccountPageVm(); + if (vm.state === "loading") return ; + if (vm.state === "error") return ; + if (vm.state === "empty") return ( ); return ( ); }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index 62d0b39..9915fae 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -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(), }); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 77814d0..8f0106e 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -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 }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index e141610..0ea59ec 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -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 ; - if (viewModel.state === "error") return ; - if (viewModel.state === "empty") return ; + const vm = useAccountsPageVm(); + if (vm.state === "loading") return ; + if (vm.state === "error") return ; + if (vm.state === "empty") return ; return ( ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index e9c1fef..838f22f 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -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 }; diff --git a/src/pages/login/ui/LoginPage.tsx b/src/pages/login/ui/LoginPage.tsx index a0b593f..9b40c5d 100644 --- a/src/pages/login/ui/LoginPage.tsx +++ b/src/pages/login/ui/LoginPage.tsx @@ -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 ( diff --git a/src/pages/not-found/ui/NotFoundPage.tsx b/src/pages/not-found/ui/NotFoundPage.tsx index 1a2e4e5..36f0046 100644 --- a/src/pages/not-found/ui/NotFoundPage.tsx +++ b/src/pages/not-found/ui/NotFoundPage.tsx @@ -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 ( diff --git a/src/pages/register/ui/RegisterPage.tsx b/src/pages/register/ui/RegisterPage.tsx index c29276b..1da17c0 100644 --- a/src/pages/register/ui/RegisterPage.tsx +++ b/src/pages/register/ui/RegisterPage.tsx @@ -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 ( diff --git a/src/pages/settings/ui/SettingsPage/SettingsPage.tsx b/src/pages/settings/ui/SettingsPage/SettingsPage.tsx index 30c3929..6baef1c 100644 --- a/src/pages/settings/ui/SettingsPage/SettingsPage.tsx +++ b/src/pages/settings/ui/SettingsPage/SettingsPage.tsx @@ -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 ( diff --git a/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts b/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts deleted file mode 100644 index c048de0..0000000 --- a/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts +++ /dev/null @@ -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 }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 598d3ed..99f0483 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -11,3 +11,4 @@ export { type ReadRetryProps, } from "./ReadRetry/ReadRetry"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; +export { useAppDocumentTitle } from "./useAppDocumentTitle"; diff --git a/src/shared/ui/useAppDocumentTitle.ts b/src/shared/ui/useAppDocumentTitle.ts new file mode 100644 index 0000000..944d636 --- /dev/null +++ b/src/shared/ui/useAppDocumentTitle.ts @@ -0,0 +1,13 @@ +import { useDocumentTitle } from "@mantine/hooks"; +import { useTranslation } from "react-i18next"; + +/** + * Sets `document.title` to ` — <appName>`. + * Owner of the "page title + app name suffix" formatting rule. + */ +function useAppDocumentTitle(title: string) { + const { t } = useTranslation(); + useDocumentTitle(`${title} — ${t("appName")}`); +} + +export { useAppDocumentTitle }; -- 2.54.0