mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-08 18:02:50 +02:00
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:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user