diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx index 2fa187e..9173246 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx @@ -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( - {children} + + {children} + , ); } @@ -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(); + }); }); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx index 68f376c..721917d 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx @@ -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 (
-

{title}

+

+ {Icon && } + {title} +

{tz && tz !== "UTC" && ( {tz} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx index bd27821..ed3382e 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx @@ -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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx index 85d03d5..b165934 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx @@ -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 ( <> - + { 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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx index 679e901..1e1132c 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx @@ -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 (
- - - {t("channels.title")} - - } - /> + {error && } diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx index d5c5f3f..f24b4c8 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx @@ -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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx index 01da959..b107e94 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx @@ -15,6 +15,7 @@ import { RouteTypeBadge } from "@/components/RouteTypeBadge"; import { IconAdvertisements, IconChannel, + IconDashboard, IconMessages, IconNodes, IconPackets, @@ -428,7 +429,7 @@ export function DashboardPage() { return ( <> - + {visibleChartCount > 0 && ( <> diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx index 9a06850..6d4c44b 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx @@ -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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx index 6d8895c..5140758 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx @@ -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 (
- + {countBadgeText} {showFilteredBadge && ( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx index 1336ff8..d30d379 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx @@ -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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx index f858125..077ded4 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx @@ -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 ( <> - +

{t("members_page.empty_state")}

{t("members_page.empty_description")}

@@ -207,7 +207,7 @@ export function Members() { return ( <> - + {t("common.count_entity", { count: formatNumber(operators.length + members.length), diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx index 627f5f5..509b0f8 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx @@ -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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx index 8121532..0d40e7b 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx @@ -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 ( <> - + { 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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx index fdda2d4..5d50ba7 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx @@ -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}
-

{displayName}

+

+ + {displayName} +

{tagDescription && (

{tagDescription}

)} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx index fddfed3..b714474 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx @@ -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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx index 9cc97b4..f68bb9e 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx @@ -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 (
- + { 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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx index 717e299..eb45e65 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx @@ -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() { ]} /> +

+ + {leaf || t("packets.detail_title")} +

+ {notFound && ( { 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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx index dec9de0..de4a727 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx @@ -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() { ]} /> +

+ + {leaf || t("packets.detail_title")} +

+ {notFound && ( )} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx index 7124443..dcbcf42 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx @@ -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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx index 42a3518..5b2da54 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx @@ -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 (
- + { 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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx index 7906d93..7ba2d18 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx @@ -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") }, ]} /> - + {isOwner && ( {t("user_profile.edit_profile")} @@ -374,7 +375,7 @@ function OwnProfileView() { { label: t("user_profile.title") }, ]} /> - + {flashMessage ? ( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx index 85892b1..0c171da 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx @@ -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 () => { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx index 921120e..7098792 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx @@ -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 (
- - - {t("routes.title")} - - } - /> + diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index 5c6495b..014b915 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -19,6 +19,7 @@ "admin": "Beheer", "tags": "Labels", "tag": "Label", + "channels": "Kanalen", "routes": "Routes" }, "common": {