From 2580134683cbe036bb07b635aab6262d3702f270 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 11 Sep 2026 11:26:11 +0300 Subject: [PATCH] feat: show page context in browser tab titles --- index.html | 2 +- src/app/routes/AppRoutes.test.tsx | 2 + src/app/routes/DocumentTitle.test.tsx | 111 +++++++++++++++++++++ src/app/routes/DocumentTitle.tsx | 88 ++++++++++++++++ src/app/routes/ProtectedLayout.tsx | 8 +- src/app/routes/RouteErrorBoundary.test.tsx | 1 + src/app/routes/RouteErrorBoundary.tsx | 2 + src/pages/login/ui/LoginPage.tsx | 2 + src/pages/not-found/ui/NotFoundPage.tsx | 2 + src/pages/register/ui/RegisterPage.tsx | 2 + 10 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/app/routes/DocumentTitle.test.tsx create mode 100644 src/app/routes/DocumentTitle.tsx diff --git a/index.html b/index.html index 0f1cb5b..95fcbea 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - wealth-ledger-frontend + WealthLedger
diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index 81d6a53..3760312 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -46,6 +46,7 @@ describe("AppRoutes", () => { screen.getByRole("heading", { level: 1, name: title }), ).toBeInTheDocument(), ); + expect(document.title).toBe(`${title} — WealthLedger`); }); it("redirects anonymous users from accounts", async () => { @@ -86,6 +87,7 @@ describe("AppRoutes", () => { name: "Нет инвестиционных счетов", }), ).toBeInTheDocument(); + expect(document.title).toBe("Счета — WealthLedger"); expect(credentials).toEqual({ email: "user@example.com", password: "a secure password", diff --git a/src/app/routes/DocumentTitle.test.tsx b/src/app/routes/DocumentTitle.test.tsx new file mode 100644 index 0000000..8d5579e --- /dev/null +++ b/src/app/routes/DocumentTitle.test.tsx @@ -0,0 +1,111 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import type { FC } from "react"; +import { MemoryRouter, useNavigate } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { AuthProvider } from "@/features/auth"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { ProtectedDocumentTitle } from "./DocumentTitle"; + +const Navigation: FC = () => { + const navigate = useNavigate(); + return ( + <> + + + + ); +}; + +const renderTitle = (path: string) => + render( + + + + + + + + , + ); + +beforeEach(() => { + document.title = "Previous page"; + server.use( + http.post("*/api/v1/auth/refresh", () => + HttpResponse.json({ + accessToken: "test-token", + user: { id: "user-1", email: "user@example.com" }, + }), + ), + ); +}); + +describe("document titles", () => { + it("updates a loading account name and follows account scope and history", async () => { + let resolveAccounts!: () => void; + const accountsReady = new Promise((resolve) => { + resolveAccounts = resolve; + }); + server.use( + http.get("*/api/v1/brokerage-accounts", async () => { + await accountsReady; + return HttpResponse.json([{ id: "a", name: "ИИС" }]); + }), + ); + renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31"); + expect(document.title).toBe("Пассивный доход — Счет — WealthLedger"); + await act(async () => resolveAccounts()); + await waitFor(() => + expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"), + ); + fireEvent.click(screen.getByRole("button", { name: "Все счета" })); + expect(document.title).toBe("Пассивный доход — Все счета — WealthLedger"); + fireEvent.click(screen.getByRole("button", { name: "Назад" })); + expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"); + }); + + it.each([ + ["bonds", "SU26238RMFS4", "Облигация", "ОФЗ 26238"], + ["stocks", "SBER", "Акция", "Сбербанк"], + ])( + "updates the %s title when instrument data arrives", + async (kind, id, fallback, name) => { + server.use( + http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () => + HttpResponse.json({ name }), + ), + ); + renderTitle(`/${kind}/${id}`); + expect(document.title).toBe(`${fallback} — WealthLedger`); + await waitFor(() => + expect(document.title).toBe(`${name} — WealthLedger`), + ); + }, + ); + + it("keeps a useful title when the instrument is unavailable", async () => { + let requested = false; + server.use( + http.get("*/api/v1/stocks/by-ticker/:ticker", () => { + requested = true; + return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); + }), + ); + renderTitle("/stocks/missing"); + await waitFor(() => expect(requested).toBe(true)); + expect(document.title).toBe("Акция — WealthLedger"); + }); +}); diff --git a/src/app/routes/DocumentTitle.tsx b/src/app/routes/DocumentTitle.tsx new file mode 100644 index 0000000..a2ab3ed --- /dev/null +++ b/src/app/routes/DocumentTitle.tsx @@ -0,0 +1,88 @@ +import { useDocumentTitle } from "@mantine/hooks"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useMatch } from "react-router"; +import { useBond } from "@/entities/bond"; +import { useBrokerageAccounts } from "@/entities/brokerage-account"; +import { useStock } from "@/entities/stock"; + +type DocumentTitleProps = { title: string }; + +const DocumentTitle: FC = (props) => { + const { t } = useTranslation(); + useDocumentTitle(`${props.title} — ${t("appName")}`); + return null; +}; + +type AccountDocumentTitleProps = { + accountId: string; + section?: string; +}; + +const AccountDocumentTitle: FC = (props) => { + const { t } = useTranslation(); + const { data } = useBrokerageAccounts(); + const accountName = + props.accountId === "all" + ? t("accounts.all") + : (data?.find((account) => account.id === props.accountId)?.name ?? + t("account.title")); + const section = + props.section === "income" + ? t("income.title") + : props.section === "operations" + ? t("ledger.title") + : props.section === "events" + ? t("events.title") + : t("portfolio.title"); + return ; +}; + +type InstrumentDocumentTitleProps = { identifier: string }; + +const BondDocumentTitle: FC = (props) => { + const { t } = useTranslation(); + const { data } = useBond(props.identifier); + return ; +}; + +const StockDocumentTitle: FC = (props) => { + const { t } = useTranslation(); + const { data } = useStock(props.identifier); + return ; +}; + +const ProtectedDocumentTitle: FC = () => { + const { t } = useTranslation(); + const account = useMatch("/accounts/:accountId/:section?"); + const bond = useMatch("/bonds/:bondId"); + const stock = useMatch("/stocks/:stockId"); + const bonds = useMatch("/bonds"); + const stocks = useMatch("/stocks"); + const settings = useMatch("/settings"); + if (account) + return ( + + ); + if (bond) return ; + if (stock) + return ; + return ( + + ); +}; + +export { ProtectedDocumentTitle }; diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx index 310e2ff..895b5b7 100644 --- a/src/app/routes/ProtectedLayout.tsx +++ b/src/app/routes/ProtectedLayout.tsx @@ -1,9 +1,15 @@ import type { FC } from "react"; import { AppBreadcrumbs } from "@/app/ui"; import { AppShell } from "@/shared/ui"; +import { ProtectedDocumentTitle } from "./DocumentTitle"; const ProtectedLayout: FC = () => { - return } />; + return ( + <> + + } /> + + ); }; export { ProtectedLayout }; diff --git a/src/app/routes/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary.test.tsx index 0acd840..7631a9f 100644 --- a/src/app/routes/RouteErrorBoundary.test.tsx +++ b/src/app/routes/RouteErrorBoundary.test.tsx @@ -55,6 +55,7 @@ describe("RouteErrorBoundary", () => { it("offers login after a public route error without error details", () => { authenticated = false; renderBoundary("/login"); + expect(document.title).toBe("Не удалось открыть страницу — WealthLedger"); expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute( "href", "/login", diff --git a/src/app/routes/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary.tsx index 3035a80..450410a 100644 --- a/src/app/routes/RouteErrorBoundary.tsx +++ b/src/app/routes/RouteErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Button, Stack, Text, Title } from "@mantine/core"; +import { useDocumentTitle } from "@mantine/hooks"; import type { FC, PropsWithChildren } from "react"; import { Component } from "react"; import { useTranslation } from "react-i18next"; @@ -40,6 +41,7 @@ class Boundary extends Component { const RouteErrorFallback: FC = (props) => { const { authenticated } = props; const { t } = useTranslation(); + useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`); const to = authenticated ? "/accounts" : "/login"; return ( diff --git a/src/pages/login/ui/LoginPage.tsx b/src/pages/login/ui/LoginPage.tsx index adb7753..fc30c22 100644 --- a/src/pages/login/ui/LoginPage.tsx +++ b/src/pages/login/ui/LoginPage.tsx @@ -1,9 +1,11 @@ +import { useDocumentTitle } from "@mantine/hooks"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { AuthPageLayout, LoginForm } from "@/features/auth"; const LoginPage: FC = () => { const { t } = useTranslation(); + useDocumentTitle(`${t("auth.login")} — ${t("appName")}`); return ( diff --git a/src/pages/not-found/ui/NotFoundPage.tsx b/src/pages/not-found/ui/NotFoundPage.tsx index c6cafb8..21d8cb8 100644 --- a/src/pages/not-found/ui/NotFoundPage.tsx +++ b/src/pages/not-found/ui/NotFoundPage.tsx @@ -1,4 +1,5 @@ import { Button, Stack, Title } from "@mantine/core"; +import { useDocumentTitle } from "@mantine/hooks"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -6,6 +7,7 @@ import { StateView } from "@/shared/ui"; const NotFoundPage: FC = () => { const { t } = useTranslation(); + useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`); return ( diff --git a/src/pages/register/ui/RegisterPage.tsx b/src/pages/register/ui/RegisterPage.tsx index fb11abc..2df460f 100644 --- a/src/pages/register/ui/RegisterPage.tsx +++ b/src/pages/register/ui/RegisterPage.tsx @@ -1,9 +1,11 @@ +import { useDocumentTitle } from "@mantine/hooks"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { AuthPageLayout, RegisterForm } from "@/features/auth"; const RegisterPage: FC = () => { const { t } = useTranslation(); + useDocumentTitle(`${t("auth.register")} — ${t("appName")}`); return (