feat: show page context in browser tab titles

This commit is contained in:
2026-09-11 11:26:11 +03:00
parent ebba6ab25d
commit 2580134683
10 changed files with 218 additions and 2 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wealth-ledger-frontend</title>
<title>WealthLedger</title>
</head>
<body>
<div id="root"></div>
+2
View File
@@ -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",
+111
View File
@@ -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 (
<>
<button type="button" onClick={() => navigate("/accounts/all/income")}>
Все счета
</button>
<button type="button" onClick={() => navigate(-1)}>
Назад
</button>
</>
);
};
const renderTitle = (path: string) =>
render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<AuthProvider>
<ProtectedDocumentTitle />
<Navigation />
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>,
);
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<void>((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");
});
});
+88
View File
@@ -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<DocumentTitleProps> = (props) => {
const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`);
return null;
};
type AccountDocumentTitleProps = {
accountId: string;
section?: string;
};
const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (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 <DocumentTitle title={`${section} — ${accountName}`} />;
};
type InstrumentDocumentTitleProps = { identifier: string };
const BondDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation();
const { data } = useBond(props.identifier);
return <DocumentTitle title={data?.name ?? t("bond.instrument")} />;
};
const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation();
const { data } = useStock(props.identifier);
return <DocumentTitle title={data?.name ?? t("stock.share")} />;
};
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 (
<AccountDocumentTitle
accountId={account.params.accountId ?? ""}
section={account.params.section}
/>
);
if (bond) return <BondDocumentTitle identifier={bond.params.bondId ?? ""} />;
if (stock)
return <StockDocumentTitle identifier={stock.params.stockId ?? ""} />;
return (
<DocumentTitle
title={t(
bonds
? "navigation.bonds"
: stocks
? "navigation.stocks"
: settings
? "navigation.settings"
: "navigation.accounts",
)}
/>
);
};
export { ProtectedDocumentTitle };
+7 -1
View File
@@ -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 <AppShell contentAboveOutlet={<AppBreadcrumbs />} />;
return (
<>
<ProtectedDocumentTitle />
<AppShell contentAboveOutlet={<AppBreadcrumbs />} />
</>
);
};
export { ProtectedLayout };
@@ -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",
+2
View File
@@ -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<BoundaryProps, State> {
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const { authenticated } = props;
const { t } = useTranslation();
useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
const to = authenticated ? "/accounts" : "/login";
return (
+2
View File
@@ -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 (
<AuthPageLayout title={t("auth.login")}>
+2
View File
@@ -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 (
<Stack>
+2
View File
@@ -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 (
<AuthPageLayout title={t("auth.register")}>