feat(web): show section icons in page headers across all pages

Add a typed icon prop to PageHeader that renders the section icon at
h-8 w-8 inside the heading, replacing the ad-hoc span wrapper used by
Channels and Routes. Migrate all 10 list pages to pass their section
icon, and add section icons to the 3 detail page headers (PacketDetail,
PacketGroupDetail, NodeDetail).

Normalize Channels/Routes title keys to the entities.* namespace
(used everywhere else) and add the missing entities.channels key to
nl.json ('Kanalen'). Visible text is unchanged.

Update PageHeader tests for the icon contract and add a focused icon
assertion to each affected page test.
This commit is contained in:
Louis King
2026-07-24 23:06:03 +01:00
parent e0c1a6725c
commit 78fab35229
29 changed files with 115 additions and 38 deletions
@@ -1,16 +1,23 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import type { ComponentType, ReactNode } from "react";
import { describe, expect, it } from "vitest";
import { AppConfigProvider } from "@/context/AppConfigContext";
import { PageHeader } from "@/components/PageHeader";
import { IconNodes } from "@/components/icons";
import { makeConfig } from "@/test/makeConfig";
import type { AppConfig } from "@/types/config";
function renderHeader(config: AppConfig = makeConfig(), children?: ReactNode) {
function renderHeader(
config: AppConfig = makeConfig(),
children?: ReactNode,
icon?: ComponentType<{ className?: string }>,
) {
return render(
<AppConfigProvider config={config}>
<PageHeader title="Nodes">{children}</PageHeader>
<PageHeader title="Nodes" icon={icon}>
{children}
</PageHeader>
</AppConfigProvider>,
);
}
@@ -41,4 +48,21 @@ describe("PageHeader", () => {
expect(screen.getByText("EST")).toBeInTheDocument();
expect(screen.getByText("extra badge")).toBeInTheDocument();
});
it("renders the icon inside the heading when provided", () => {
renderHeader(makeConfig(), undefined, IconNodes);
const svg = screen
.getByRole("heading", { name: "Nodes" })
.querySelector("svg");
expect(svg).not.toBeNull();
expect(svg?.getAttribute("class")).toContain("h-8");
expect(svg?.getAttribute("class")).toContain("w-8");
});
it("renders no icon when omitted", () => {
renderHeader();
expect(
screen.getByRole("heading", { name: "Nodes" }).querySelector("svg"),
).toBeNull();
});
});
@@ -1,18 +1,23 @@
import type { ReactNode } from "react";
import type { ComponentType, ReactNode } from "react";
import { useAppConfig } from "@/context/AppConfigContext";
export function PageHeader({
title,
icon: Icon,
children,
}: {
title: ReactNode;
icon?: ComponentType<{ className?: string }>;
children?: ReactNode;
}) {
const config = useAppConfig();
const tz = config.timezone || "";
return (
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{title}</h1>
<h1 className="text-3xl font-bold flex items-center gap-2">
{Icon && <Icon className="h-8 w-8" />}
{title}
</h1>
<div className="flex items-center gap-2">
{tz && tz !== "UTC" && (
<span className="text-sm opacity-60">{tz}</span>
@@ -40,6 +40,11 @@ describe("Advertisements", () => {
await waitFor(() => {
expect(screen.getAllByText("AdNode").length).toBeGreaterThanOrEqual(1);
});
expect(
screen
.getByRole("heading", { name: "entities.advertisements" })
.querySelector("svg"),
).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
@@ -8,6 +8,7 @@ import { qk } from "@/utils/queryKeys";
import { useFormatDateTime } from "@/utils/format";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
import { IconAdvertisements } from "@/components/icons";
import { Pagination } from "@/components/Pagination";
import {
FilterForm,
@@ -235,7 +236,7 @@ export function Advertisements() {
return (
<>
<PageHeader title={t("entities.advertisements")} />
<PageHeader title={t("entities.advertisements")} icon={IconAdvertisements} />
<ListToolbar
total={total}
@@ -52,6 +52,9 @@ describe("Channels", () => {
expect(screen.getByText("Public")).toBeInTheDocument();
expect(screen.getByText("Ops")).toBeInTheDocument();
});
expect(
screen.getByRole("heading", { name: "entities.channels" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
@@ -283,7 +283,7 @@ export function Channels() {
const config = useAppConfig();
const oidcEnabled = config.oidc_enabled;
const isAdmin = hasRole("admin");
usePageTitle("channels.title");
usePageTitle("entities.channels");
const queryClient = useQueryClient();
@@ -364,14 +364,7 @@ export function Channels() {
return (
<div>
<PageHeader
title={
<span className="flex items-center gap-2">
<IconChannel className="h-8 w-8" />
{t("channels.title")}
</span>
}
/>
<PageHeader title={t("entities.channels")} icon={IconChannel} />
{error && <ErrorAlert message={error} />}
@@ -45,6 +45,9 @@ describe("Dashboard", () => {
await waitFor(() => {
expect(document.querySelector(".loading-spinner")).toBeNull();
});
expect(
screen.getByRole("heading", { name: "entities.dashboard" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
@@ -15,6 +15,7 @@ import { RouteTypeBadge } from "@/components/RouteTypeBadge";
import {
IconAdvertisements,
IconChannel,
IconDashboard,
IconMessages,
IconNodes,
IconPackets,
@@ -428,7 +429,7 @@ export function DashboardPage() {
return (
<>
<PageHeader title={t("entities.dashboard")} />
<PageHeader title={t("entities.dashboard")} icon={IconDashboard} />
{visibleChartCount > 0 && (
<>
@@ -55,6 +55,9 @@ describe("MapPage", () => {
await waitFor(() => {
expect(screen.getByTestId("mock-map")).toBeInTheDocument();
});
expect(
screen.getByRole("heading", { name: "entities.map" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
@@ -26,6 +26,7 @@ import {
import { FilterToggle, OperatorSelect } from "@/components/FilterForm";
import { ErrorAlert, Loading } from "@/components/Alerts";
import { PageHeader } from "@/components/PageHeader";
import { IconMap } from "@/components/icons";
const MAX_BOUNDS_RADIUS_KM = 20;
@@ -386,7 +387,7 @@ export function MapPage() {
return (
<div>
<PageHeader title={t("entities.map")}>
<PageHeader title={t("entities.map")} icon={IconMap}>
<span className="badge badge-lg">{countBadgeText}</span>
{showFilteredBadge && (
<span className="badge badge-lg badge-ghost">
@@ -32,6 +32,9 @@ describe("Members", () => {
});
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.queryByText("TestUser")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "entities.members" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an empty state when no visible profiles exist", async () => {
@@ -14,7 +14,7 @@ import { Loading, ErrorAlert } from "@/components/Alerts";
import { CallsignBadge, RoleBadge } from "@/components/Badges";
import { EmptyState } from "@/components/EmptyState";
import { PageHeader } from "@/components/PageHeader";
import { IconAntenna, IconUsers } from "@/components/icons";
import { IconAntenna, IconMembers, IconUsers } from "@/components/icons";
import { usePageTitle } from "@/hooks/usePageTitle";
interface MemberNode {
@@ -184,7 +184,7 @@ export function Members() {
if (visible.length === 0) {
return (
<>
<PageHeader title={t("entities.members")} />
<PageHeader title={t("entities.members")} icon={IconMembers} />
<EmptyState>
<p className="text-lg">{t("members_page.empty_state")}</p>
<p className="text-sm mt-2">{t("members_page.empty_description")}</p>
@@ -207,7 +207,7 @@ export function Members() {
return (
<>
<PageHeader title={t("entities.members")}>
<PageHeader title={t("entities.members")} icon={IconMembers}>
<span className="badge badge-lg">
{t("common.count_entity", {
count: formatNumber(operators.length + members.length),
@@ -40,6 +40,9 @@ describe("Messages", () => {
await waitFor(() => {
expect(screen.getAllByText("Hello world").length).toBeGreaterThanOrEqual(1);
});
expect(
screen.getByRole("heading", { name: "entities.messages" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
@@ -18,6 +18,7 @@ import {
} from "@/utils/messageHelpers";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
import { IconMessages } from "@/components/icons";
import { Pagination } from "@/components/Pagination";
import {
FilterForm,
@@ -278,7 +279,7 @@ export function Messages() {
return (
<>
<PageHeader title={t("entities.messages")} />
<PageHeader title={t("entities.messages")} icon={IconMessages} />
<ListToolbar
total={total}
@@ -62,6 +62,9 @@ describe("NodeDetail", () => {
await waitFor(() => {
expect(document.querySelector(".loading-spinner")).toBeNull();
});
expect(
screen.getByRole("heading", { name: "DetailNode" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
@@ -15,7 +15,7 @@ import { ErrorAlert, Loading, SuccessAlert } from "@/components/Alerts";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { CopyableValue } from "@/components/CopyableValue";
import { IconEdit, IconPlus, IconTrash } from "@/components/icons";
import { IconEdit, IconNodes, IconPlus, IconTrash } from "@/components/icons";
import { MeshQrCode } from "@/components/MeshQrCode";
import { Modal } from "@/components/Modal";
import { NotFoundState } from "@/components/NotFoundState";
@@ -606,7 +606,10 @@ export function NodeDetailPage() {
{emoji}
</span>
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold">{displayName}</h1>
<h1 className="text-3xl font-bold flex items-center gap-2">
<IconNodes className="h-8 w-8" />
{displayName}
</h1>
{tagDescription && (
<p className="opacity-70 mt-2">{tagDescription}</p>
)}
@@ -43,6 +43,9 @@ describe("Nodes", () => {
await waitFor(() => {
expect(screen.getAllByText("TestNode").length).toBeGreaterThanOrEqual(1);
});
expect(
screen.getByRole("heading", { name: "entities.nodes" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
@@ -10,6 +10,7 @@ import { useFormatDateTime } from "@/utils/format";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
import { Pagination } from "@/components/Pagination";
import { IconNodes } from "@/components/icons";
import {
FilterForm,
FilterField,
@@ -249,7 +250,7 @@ export function Nodes() {
return (
<div>
<PageHeader title={t("entities.nodes")} />
<PageHeader title={t("entities.nodes")} icon={IconNodes} />
<ListToolbar
total={total}
@@ -53,6 +53,9 @@ describe("PacketDetail", () => {
expect(screen.getAllByText("abc123").length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText("Observer1")).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "abc123" }).querySelector("svg"),
).not.toBeNull();
});
it("shows not-found state on a 404 error", async () => {
@@ -8,6 +8,7 @@ import { useFormatDateTime } from "@/utils/format";
import { Loading, WarningBadge } from "@/components/Alerts";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { NotFoundState } from "@/components/NotFoundState";
import { IconPackets } from "@/components/icons";
import { DefinitionGrid } from "@/components/Definition";
import {
buildChannelNames,
@@ -90,6 +91,11 @@ export function PacketDetail() {
]}
/>
<h1 className="text-3xl font-bold flex items-center gap-2 mb-6">
<IconPackets className="h-8 w-8" />
{leaf || t("packets.detail_title")}
</h1>
{notFound && (
<NotFoundState
message={t("common.entity_not_found_details", {
@@ -53,6 +53,9 @@ describe("PacketGroupDetail", () => {
await waitFor(() => {
expect(screen.getAllByText("grouphash").length).toBeGreaterThanOrEqual(1);
});
expect(
screen.getByRole("heading", { name: "grouphash" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
@@ -21,7 +21,7 @@ import {
import { groupByObserver } from "@/utils/packetGroupHelpers";
import { Loading, WarningBadge } from "@/components/Alerts";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { IconSatelliteDish } from "@/components/icons";
import { IconPackets, IconSatelliteDish } from "@/components/icons";
import { NotFoundState } from "@/components/NotFoundState";
import { TimeAgo } from "@/components/TimeAgo";
import { DefinitionGrid } from "@/components/Definition";
@@ -356,6 +356,11 @@ export function PacketGroupDetail() {
]}
/>
<h1 className="text-3xl font-bold flex items-center gap-2 mb-6">
<IconPackets className="h-8 w-8" />
{leaf || t("packets.detail_title")}
</h1>
{notFound && (
<NotFoundState tone="warning" message={t("packets.not_found_retention")} />
)}
@@ -43,6 +43,9 @@ describe("Packets", () => {
await waitFor(() => {
expect(screen.getByText("hash1")).toBeInTheDocument();
});
expect(
screen.getByRole("heading", { name: "entities.packets" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
@@ -23,7 +23,7 @@ import { Loading } from "@/components/Alerts";
import { ListToolbar } from "@/components/ListToolbar";
import { PageHeader } from "@/components/PageHeader";
import { EmptyState, EmptyRow } from "@/components/EmptyState";
import { IconPath, IconRuler, IconSatelliteDish } from "@/components/icons";
import { IconPath, IconPackets, IconRuler, IconSatelliteDish } from "@/components/icons";
const EVENT_TYPES = [
"advertisement",
@@ -251,7 +251,7 @@ export function Packets() {
return (
<div>
<PageHeader title={t("entities.packets")} />
<PageHeader title={t("entities.packets")} icon={IconPackets} />
<ListToolbar
total={packets !== null ? total : null}
@@ -38,6 +38,9 @@ describe("Profile (public view)", () => {
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
});
expect(screen.getAllByText("AB1CDE").length).toBeGreaterThanOrEqual(1);
expect(
screen.getByRole("heading", { name: "user_profile.title" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
@@ -11,6 +11,7 @@ import { Loading, ErrorAlert, SuccessAlert } from "@/components/Alerts";
import { CallsignBadge, RoleBadge } from "@/components/Badges";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { PageHeader } from "@/components/PageHeader";
import { IconUser } from "@/components/icons";
import { TimeAgo } from "@/components/TimeAgo";
import { usePageTitle } from "@/hooks/usePageTitle";
@@ -253,7 +254,7 @@ function PublicProfileView({ id }: { id: string }) {
{ label: profile.name || t("common.unnamed") },
]}
/>
<PageHeader title={t("user_profile.title")}>
<PageHeader title={t("user_profile.title")} icon={IconUser}>
{isOwner && (
<Link to="/profile" className="btn btn-primary btn-sm">
{t("user_profile.edit_profile")}
@@ -374,7 +375,7 @@ function OwnProfileView() {
{ label: t("user_profile.title") },
]}
/>
<PageHeader title={t("user_profile.title")} />
<PageHeader title={t("user_profile.title")} icon={IconUser} />
{flashMessage ? (
<SuccessAlert message={flashMessage} />
@@ -69,6 +69,9 @@ describe("Routes", () => {
await waitFor(() => {
expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1);
});
expect(
screen.getByRole("heading", { name: "entities.routes" }).querySelector("svg"),
).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
@@ -1192,7 +1192,7 @@ export function RoutesPage() {
isAdmin || (!!r.created_by && r.created_by === currentUserId);
const mine = searchParams.get("mine") === "true";
const [filterOpen, setFilterOpen] = useState(false);
usePageTitle("routes.title");
usePageTitle("entities.routes");
const queryClient = useQueryClient();
@@ -1501,14 +1501,7 @@ export function RoutesPage() {
return (
<div>
<PageHeader
title={
<span className="flex items-center gap-2">
<IconPath className="h-8 w-8" />
{t("routes.title")}
</span>
}
/>
<PageHeader title={t("entities.routes")} icon={IconPath} />
<SummaryStrip routes={routes} />
@@ -19,6 +19,7 @@
"admin": "Beheer",
"tags": "Labels",
"tag": "Label",
"channels": "Kanalen",
"routes": "Routes"
},
"common": {