From 513e15baf7dfb9dc57d03020b9cc2ed30491ccd7 Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Wed, 23 Sep 2026 09:21:16 -0600 Subject: [PATCH 1/4] feat(admin-ui): T558 the nav is four groups (gh-#779) nav-items.ts now exports NAV_GROUPS (Station, Media, Tools, Status), NAV_TOP (Dashboard), NAV_BOTTOM (Settings) and NAV_FOOTER (About, Sign out) per SPEC F203.1. NAV_ITEMS is gone; visibleNavGroups keeps the catalog gate and drops a group left empty (F203.3). Live and Wardrobe leave the nav; Station Imaging reads Station sounds and the editor reads Theme Editor. Sidebar and MobileNav render the model as today's flat list until T559 makes the groups collapsible. --- admin-ui/__specs__/app-shell.spec.tsx | 4 +- admin-ui/__specs__/gardener-nav.spec.tsx | 22 ++- .../__specs__/grouped-navigation.spec.tsx | 93 +++++++++- admin-ui/__specs__/responsive-a11y.spec.tsx | 2 +- .../app/(authed)/_components/MobileNav.tsx | 11 +- admin-ui/app/(authed)/_components/Sidebar.tsx | 11 +- .../app/(authed)/_components/nav-items.ts | 163 +++++++++--------- 7 files changed, 201 insertions(+), 105 deletions(-) diff --git a/admin-ui/__specs__/app-shell.spec.tsx b/admin-ui/__specs__/app-shell.spec.tsx index 006d1a17..4d416d8f 100644 --- a/admin-ui/__specs__/app-shell.spec.tsx +++ b/admin-ui/__specs__/app-shell.spec.tsx @@ -148,13 +148,13 @@ afterEach(() => { describe("Feature: App shell", () => { describe("Scenario: shell wraps every authed route", () => { - it("renders the sidebar with Dashboard, Live, Catalog, Station Imaging, Settings (and NOT Libraries)", async () => { + it("renders the sidebar with Dashboard, Catalog, Station sounds, Settings (and NOT Libraries)", async () => { mockedUsePathname.mockReturnValue("/dashboard"); const { Sidebar } = await import("../app/(authed)/_components/Sidebar"); render(); - for (const label of ["Dashboard", "Live", "Catalog", "Station Imaging", "Settings"]) { + for (const label of ["Dashboard", "Catalog", "Station sounds", "Settings"]) { expect(screen.getByRole("link", { name: label })).toBeInTheDocument(); } // Libraries stays routable but unlisted until Q7. diff --git a/admin-ui/__specs__/gardener-nav.spec.tsx b/admin-ui/__specs__/gardener-nav.spec.tsx index 1314490f..51c0337b 100644 --- a/admin-ui/__specs__/gardener-nav.spec.tsx +++ b/admin-ui/__specs__/gardener-nav.spec.tsx @@ -1,25 +1,23 @@ // @jest-environment jsdom -// SPEC F153.10, PLAN T378 — the Gardener nav entry. +// SPEC F153.10, PLAN T378 — the Gardener nav entry, now inside the grouped Tools group +// (SPEC F203.1, PLAN T558). // -// Runner: Jest. NAV_ITEMS/visibleNavItems are plain data (no rendering needed) — same posture the -// Sidebar itself reads them with. +// Runner: Jest. NAV_GROUPS is plain data (no rendering needed) — same posture the Sidebar itself +// reads it with. import { describe, it, expect } from "@jest/globals"; -import { visibleNavItems } from "../app/(authed)/_components/nav-items"; +import { NAV_GROUPS } from "../app/(authed)/_components/nav-items"; describe("Feature: the Gardener nav entry (SPEC F153.10)", () => { - describe("Scenario: the sidebar/mobile nav item list", () => { - it("places Gardener right after Catalog", () => { - const items = visibleNavItems(false); - const catalogIndex = items.findIndex((item) => item.label === "Catalog"); - const gardenerIndex = items.findIndex((item) => item.label === "Gardener"); + describe("Scenario: the Tools group", () => { + const tools = NAV_GROUPS.find((group) => group.title === "Tools"); - expect(gardenerIndex).toBe(catalogIndex + 1); + it("places Gardener first in the Tools group", () => { + expect(tools?.items[0]?.label).toBe("Gardener"); }); it("links Gardener to /gardener", () => { - const items = visibleNavItems(false); - const gardener = items.find((item) => item.label === "Gardener"); + const gardener = tools?.items.find((item) => item.label === "Gardener"); expect(gardener?.href).toBe("/gardener"); }); diff --git a/admin-ui/__specs__/grouped-navigation.spec.tsx b/admin-ui/__specs__/grouped-navigation.spec.tsx index 820b298b..8a19dc80 100644 --- a/admin-ui/__specs__/grouped-navigation.spec.tsx +++ b/admin-ui/__specs__/grouped-navigation.spec.tsx @@ -4,14 +4,82 @@ // BDD specification — Jest. RED at plan time: every specification is it.todo — turn it into an `it` only in the // task that makes it green (T558–T560). Each Given comment names the arrange the scenario needs. -import { describe, it } from "@jest/globals"; +import { describe, it, expect } from "@jest/globals"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { + NAV_BOTTOM, + NAV_FOOTER, + NAV_GROUPS, + NAV_TOP, + visibleNavGroups, + type NavGroup, +} from "../app/(authed)/_components/nav-items"; + +const ROOT = path.resolve(__dirname, ".."); + +/** Recursively lists files under `dir` with one of `exts`, skipping build/dep dirs (house pattern, + * see app-shell.spec.tsx's own `collectFiles`). */ +function collectFiles(dir: string, exts: string[], out: string[] = []): string[] { + const SKIP = new Set(["node_modules", ".next"]); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (SKIP.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectFiles(full, exts, out); + } else if (exts.some((ext) => entry.name.endsWith(ext))) { + out.push(full); + } + } + return out; +} describe("Feature: Grouped navigation", () => { describe("Scenario: the nav model", () => { // Given: NAV_GROUPS, NAV_TOP, NAV_BOTTOM, NAV_FOOTER from nav-items.ts - it.todo("AC1 — NAV_GROUPS is Station, Media, Tools, Status with the ratified items in order"); - it.todo("AC2 — NAV_TOP is Dashboard, NAV_BOTTOM is Settings, NAV_FOOTER is About then Sign out"); - it.todo("AC3 — no module exports or imports NAV_ITEMS"); + it("AC1 — NAV_GROUPS is Station, Media, Tools, Status with the ratified items in order", () => { + const shape = NAV_GROUPS.map((group) => ({ + title: group.title, + items: group.items.map((item) => item.label), + })); + + expect(shape).toEqual([ + { title: "Station", items: ["Station sounds", "Ads", "Personas", "Schedule", "Shows"] }, + { title: "Media", items: ["Catalog", "Announcements", "Community Catalog"] }, + { title: "Tools", items: ["Gardener", "Theme Editor"] }, + { title: "Status", items: ["Booth log", "Health"] }, + ]); + }); + + it("AC2 — NAV_TOP is Dashboard, NAV_BOTTOM is Settings, NAV_FOOTER is About then Sign out", () => { + const shape = { + top: NAV_TOP.map((item) => item.label), + bottom: NAV_BOTTOM.map((item) => item.label), + footer: NAV_FOOTER.map((entry) => entry.label), + }; + + expect(shape).toEqual({ top: ["Dashboard"], bottom: ["Settings"], footer: ["About", "Sign out"] }); + }); + + it("AC3 — the retired flat nav array is imported and exported nowhere", () => { + // Built from parts, not the literal identifier — this spec proves the retired token is gone + // from the codebase, so it must not itself reintroduce a source hit of that same token. + const retiredExportName = ["NAV", "ITEMS"].join("_"); + + const files = [ + ...collectFiles(path.join(ROOT, "app"), [".ts", ".tsx"]), + ...collectFiles(path.join(ROOT, "__specs__"), [".ts", ".tsx"]), + ]; + const navItemsPath = path.join(ROOT, "app", "(authed)", "_components", "nav-items.ts"); + const offenders = files.filter((f) => readFileSync(f, "utf-8").includes(retiredExportName)); + + // Single assertion that still proves the scan wasn't vacuous: it must have covered + // nav-items.ts, and found zero offenders. + expect({ scannedModel: files.includes(navItemsPath), offenders }).toEqual({ + scannedModel: true, + offenders: [], + }); + }); }); describe("Scenario: the sidebar at /ads", () => { @@ -36,12 +104,23 @@ describe("Feature: Grouped navigation", () => { describe("Scenario: the catalog gate", () => { // Given: catalog unavailable - it.todo("AC9 — Media shows Catalog and Announcements only"); + it("AC9 — Media shows Catalog and Announcements only", () => { + const media = visibleNavGroups(false).find((group) => group.title === "Media"); + + expect(media?.items.map((item) => item.label)).toEqual(["Catalog", "Announcements"]); + }); }); describe("Scenario: an empty group", () => { - // Given: every Tools item hidden - it.todo("AC10 — no Tools heading renders"); + // Given: every Tools item hidden — the house Tools group carries no gate, so this drives + // visibleNavGroups with its own test group (per nav-items.ts's own remarks on that parameter). + it("AC10 — no Tools heading renders", () => { + const groups: NavGroup[] = [ + { title: "Tools", items: [{ href: "/gardener", label: "Gardener", iconName: "restore", requiresCatalog: true }] }, + ]; + + expect(visibleNavGroups(false, groups)).toEqual([]); + }); }); describe("Scenario: the retired routes", () => { diff --git a/admin-ui/__specs__/responsive-a11y.spec.tsx b/admin-ui/__specs__/responsive-a11y.spec.tsx index 16765b75..2ac3e2a6 100644 --- a/admin-ui/__specs__/responsive-a11y.spec.tsx +++ b/admin-ui/__specs__/responsive-a11y.spec.tsx @@ -254,7 +254,7 @@ describe("Feature: Responsive and accessible console", () => { expect(dialog).toContainElement(document.activeElement as HTMLElement); // The same section list as the persistent Sidebar (SPEC F28.13: the // drawer is the same nav, not a second one that can drift). - for (const label of ["Dashboard", "Live", "Catalog", "Station Imaging", "Settings"]) { + for (const label of ["Dashboard", "Catalog", "Station sounds", "Settings"]) { expect(screen.getByRole("link", { name: label })).toBeInTheDocument(); } diff --git a/admin-ui/app/(authed)/_components/MobileNav.tsx b/admin-ui/app/(authed)/_components/MobileNav.tsx index 3b033b08..3acb1406 100644 --- a/admin-ui/app/(authed)/_components/MobileNav.tsx +++ b/admin-ui/app/(authed)/_components/MobileNav.tsx @@ -8,7 +8,7 @@ import { logout } from "@/app/login/actions"; import { Tooltip } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { Icon } from "./Icon"; -import { NAV_LINK_CLASSES, isActiveSection, visibleNavItems } from "./nav-items"; +import { NAV_BOTTOM, NAV_LINK_CLASSES, NAV_TOP, isActiveSection, visibleNavGroups } from "./nav-items"; const ICON_BUTTON_CLASSES = "flex h-10 w-10 shrink-0 items-center justify-center rounded-[6px] text-mute transition-colors duration-[120ms] ease-out hover:bg-surface hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"; @@ -29,7 +29,7 @@ interface MobileNavProps { /** * Sub-1024px nav drawer (SPEC F28.13): a hamburger in the shell header opens - * the same `NAV_ITEMS` the persistent `Sidebar` renders at ≥1024px, inside a + * the same nav model the persistent `Sidebar` renders at ≥1024px, inside a * Radix Dialog reused for the trap (as confirm-dialog.tsx does — FocusScope * + DismissableLayer are Radix's job, not hand-rolled here). Unlike * `useConfirm()`, this trigger is a real rendered ``, so @@ -41,10 +41,15 @@ interface MobileNavProps { * wraps each `Dialog.Trigger`/`Dialog.Close` from the outside, not as their * `asChild` target, so Radix's own prop-cloning onto the real ` - + setOpen(false)} /> diff --git a/admin-ui/app/(authed)/_components/NavSections.tsx b/admin-ui/app/(authed)/_components/NavSections.tsx new file mode 100644 index 00000000..b82b6d56 --- /dev/null +++ b/admin-ui/app/(authed)/_components/NavSections.tsx @@ -0,0 +1,188 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import { useId } from "react"; +import { logout } from "@/app/login/actions"; +import { cn } from "@/lib/utils"; +import { Icon } from "./Icon"; +import { + NAV_BOTTOM, + NAV_FOOTER, + NAV_GROUP_HEADING_CLASSES, + NAV_LINK_CLASSES, + NAV_TOP, + isActiveSection, + visibleNavGroups, + type NavGroup, + type NavItem, +} from "./nav-items"; +import { useNavGroupOpenState } from "./useNavGroupOpenState"; + +export interface NavSectionsProps { + /** Current route (`usePathname()`) — drives both the active-link highlight and which group + * opens by the route rule (SPEC F203.2). */ + pathname: string; + /** Mirrors `Sidebar`/`MobileNav`'s own `catalogEnabled` prop (PLAN T102, SPEC F90.1). */ + catalogEnabled: boolean; + /** Called after every nav `Link` click and the sign-out submit. `MobileNav` passes this to + * close its drawer; `Sidebar` has nothing to close and leaves it undefined. */ + onNavigate?: () => void; +} + +/** + * The nav model both `Sidebar` (persistent, ≥1024px) and `MobileNav` (drawer, <1024px) render: + * `NAV_TOP`, the visible groups as collapsible sections, `NAV_BOTTOM`, then the `NAV_FOOTER` + * entries (About, Sign out) — SPEC F203.1's "Sidebar and MobileNav render the same model", now + * true of the markup itself rather than two hand-kept-in-sync copies of it (PLAN T559 fix round, + * finding #5). Neither component owns any of this JSX; they differ only in the shell around it (a + * persistent `