feat(web): page <h1> + document.title on list/form routes (#57)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 17:17:01 +02:00
parent 70025e1e71
commit 6e1f5ea50f
7 changed files with 81 additions and 25 deletions
+5
View File
@@ -6,7 +6,9 @@ import type { components } from "../api/schema";
import { useAuthorities, useCreateAuthority } from "../api/queries"; import { useAuthorities, useCreateAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor"; import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageTitle } from "@/components/ui/page-title";
import { AuthorityRow } from "./authority-row"; import { AuthorityRow } from "./authority-row";
import { useDocumentTitle } from "../lib/use-document-title";
type LabelInput = components["schemas"]["LabelInput"]; type LabelInput = components["schemas"]["LabelInput"];
@@ -26,6 +28,8 @@ export function AuthoritiesPage() {
const [labels, setLabels] = useState<LabelInput[]>([]); const [labels, setLabels] = useState<LabelInput[]>([]);
const [error, setError] = useState(false); const [error, setError] = useState(false);
useDocumentTitle(t("nav.authorities"));
if (!isValidKind) return <Navigate to="/authorities/person" replace />; if (!isValidKind) return <Navigate to="/authorities/person" replace />;
const onCreate = (event: FormEvent) => { const onCreate = (event: FormEvent) => {
@@ -45,6 +49,7 @@ export function AuthoritiesPage() {
return ( return (
<div className="overflow-auto p-4"> <div className="overflow-auto p-4">
<PageTitle className="mb-3">{t("nav.authorities")}</PageTitle>
<div role="tablist" className="mb-3 flex gap-2"> <div role="tablist" className="mb-3 flex gap-2">
{KINDS.map((k) => ( {KINDS.map((k) => (
<NavLink <NavLink
+19 -10
View File
@@ -1,25 +1,34 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema"; import type { components } from "../api/schema";
import { FieldList } from "./field-list"; import { FieldList } from "./field-list";
import { FieldForm } from "./field-form"; import { FieldForm } from "./field-form";
import { useDocumentTitle } from "../lib/use-document-title";
import { PageTitle } from "@/components/ui/page-title";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export function FieldsPage() { export function FieldsPage() {
const { t } = useTranslation();
const [selected, setSelected] = useState<FieldDefinitionView | null>(null); const [selected, setSelected] = useState<FieldDefinitionView | null>(null);
useDocumentTitle(t("fields.title"));
return ( return (
<div className="grid h-full grid-cols-[20rem_1fr]"> <div className="flex h-full flex-col">
<div className="overflow-hidden border-r"> <PageTitle className="px-4 pt-4 pb-2">{t("fields.title")}</PageTitle>
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} /> <div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
</div> <div className="overflow-hidden border-r">
<div className="overflow-hidden"> <FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
<FieldForm </div>
key={selected?.key ?? "create"} <div className="overflow-hidden">
editing={selected} <FieldForm
onDone={() => setSelected(null)} key={selected?.key ?? "create"}
/> editing={selected}
onDone={() => setSelected(null)}
/>
</div>
</div> </div>
</div> </div>
); );
+5
View File
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import { ObjectForm, type ObjectFormValues } from "./object-form"; import { ObjectForm, type ObjectFormValues } from "./object-form";
import { useCreateObject, useSetFields, FieldRejection } from "../api/queries"; import { useCreateObject, useSetFields, FieldRejection } from "../api/queries";
import { useDocumentTitle } from "../lib/use-document-title";
import { PageTitle } from "@/components/ui/page-title";
export function ObjectNewPage() { export function ObjectNewPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -12,6 +14,8 @@ export function ObjectNewPage() {
const setFields = useSetFields(); const setFields = useSetFields();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useDocumentTitle(t("objects.new"));
const onSubmit = async (values: ObjectFormValues) => { const onSubmit = async (values: ObjectFormValues) => {
setError(null); setError(null);
@@ -44,6 +48,7 @@ export function ObjectNewPage() {
return ( return (
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-2xl">
<PageTitle className="mb-4">{t("objects.new")}</PageTitle>
<ObjectForm <ObjectForm
mode="create" mode="create"
formError={error} formError={error}
+11 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { screen, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { renderApp } from "../test/render"; import { renderApp } from "../test/render";
@@ -39,6 +39,16 @@ afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
test("renders the page heading and sets the document title", async () => {
setViewport(true);
renderApp(tree(), { route: "/objects" });
expect(
await screen.findByRole("heading", { level: 1, name: /objects/i }),
).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/objects \| /i));
});
test("the table is the landing view; no detail panel until a row is opened", async () => { test("the table is the landing view; no detail panel until a row is opened", async () => {
setViewport(true); setViewport(true);
renderApp(tree(), { route: "/objects" }); renderApp(tree(), { route: "/objects" });
+9 -2
View File
@@ -5,6 +5,8 @@ import { X } from "lucide-react";
import { ObjectsTable } from "./objects-table"; import { ObjectsTable } from "./objects-table";
import { useMediaQuery } from "../lib/use-media-query"; import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { PageTitle } from "@/components/ui/page-title";
const ObjectDetailDrawer = lazy(() => const ObjectDetailDrawer = lazy(() =>
import("./object-detail-drawer").then((m) => ({ default: m.ObjectDetailDrawer })), import("./object-detail-drawer").then((m) => ({ default: m.ObjectDetailDrawer })),
@@ -23,11 +25,16 @@ export function ObjectsPage() {
const open = Boolean(detailMatch ?? editMatch); const open = Boolean(detailMatch ?? editMatch);
const isWide = useMediaQuery("(min-width: 1024px)"); const isWide = useMediaQuery("(min-width: 1024px)");
useDocumentTitle(t("nav.objects"));
const closeDetail = () => navigate(`/objects?${searchParams}`); const closeDetail = () => navigate(`/objects?${searchParams}`);
const table = ( const table = (
<div className="overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<ObjectsTable /> <PageTitle className="px-4 pt-4 pb-2">{t("nav.objects")}</PageTitle>
<div className="flex-1 overflow-hidden">
<ObjectsTable />
</div>
</div> </div>
); );
+16 -6
View File
@@ -1,15 +1,25 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SearchPanel } from "./search-panel"; import { SearchPanel } from "./search-panel";
import { useDocumentTitle } from "../lib/use-document-title";
import { PageTitle } from "@/components/ui/page-title";
export function SearchPage() { export function SearchPage() {
const { t } = useTranslation();
useDocumentTitle(t("nav.search"));
return ( return (
<div className="grid h-full grid-cols-[24rem_1fr]"> <div className="flex h-full flex-col">
<div className="overflow-hidden border-r"> <PageTitle className="px-4 pt-4 pb-2">{t("nav.search")}</PageTitle>
<SearchPanel /> <div className="grid flex-1 grid-cols-[24rem_1fr] overflow-hidden">
</div> <div className="overflow-hidden border-r">
<div className="overflow-hidden"> <SearchPanel />
<Outlet /> </div>
<div className="overflow-hidden">
<Outlet />
</div>
</div> </div>
</div> </div>
); );
+16 -6
View File
@@ -1,15 +1,25 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { VocabularyList } from "./vocabulary-list"; import { VocabularyList } from "./vocabulary-list";
import { useDocumentTitle } from "../lib/use-document-title";
import { PageTitle } from "@/components/ui/page-title";
export function VocabulariesPage() { export function VocabulariesPage() {
const { t } = useTranslation();
useDocumentTitle(t("nav.vocabularies"));
return ( return (
<div className="grid h-full grid-cols-[20rem_1fr]"> <div className="flex h-full flex-col">
<div className="overflow-hidden border-r"> <PageTitle className="px-4 pt-4 pb-2">{t("nav.vocabularies")}</PageTitle>
<VocabularyList /> <div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
</div> <div className="overflow-hidden border-r">
<div className="overflow-hidden"> <VocabularyList />
<Outlet /> </div>
<div className="overflow-hidden">
<Outlet />
</div>
</div> </div>
</div> </div>
); );