From 964af311f8450a214dfa604b0d89eff7a1da36ca Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Tue, 23 Jun 2026 20:44:37 +0530 Subject: [PATCH] feat(sidebar): make section headings collapsible accordion Purchasing, Crewing and Administration headings are now collapsible buttons (chevron + aria-expanded/aria-controls) that collapse by default. Single-open accordion: opening one heading collapses any other open one. The section containing the active route auto-expands on mount/navigation so the user is never stranded on a hidden link. Adds a jsdom/Testing Library unit test covering default-collapsed, toggle, single-open accordion, and active-route auto-expand. Fixes #96 Co-Authored-By: Claude Opus 4.8 (1M context) --- App/components/layout/sidebar.tsx | 125 ++++++++++++++++++++---------- App/tests/unit/sidebar.test.tsx | 102 ++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 43 deletions(-) create mode 100644 App/tests/unit/sidebar.test.tsx diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index e894771..65a335c 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import Link from "next/link"; import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags"; @@ -33,6 +34,7 @@ import { UserCog, Gauge, BadgeCheck, + ChevronRight, } from "lucide-react"; import type { Role } from "@prisma/client"; @@ -117,6 +119,16 @@ const ADMIN_ITEMS: NavItem[] = [ { href: "/admin/companies", label: "Companies", icon: Briefcase }, ]; +interface Section { + id: string; + label: string; + items: NavItem[]; +} + +function isItemActive(href: string, pathname: string) { + return pathname === href || pathname.startsWith(href + "/"); +} + export function Sidebar({ userRole }: { userRole: Role }) { const pathname = usePathname(); const isAdmin = userRole === "ADMIN"; @@ -125,6 +137,31 @@ export function Sidebar({ userRole }: { userRole: Role }) { const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); + const adminItems = isAdmin ? [...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS] : visibleMgrAdmin; + + // Headed, collapsible sections (the main links above sit outside any section). + const sections: Section[] = [ + { id: "purchasing", label: "Purchasing", items: visiblePurchasing }, + { id: "crewing", label: "Crewing", items: visibleCrewing }, + { id: "administration", label: "Administration", items: adminItems }, + ].filter((s) => s.items.length > 0); + + // The section (if any) that holds the currently active route. + const activeSectionId = + sections.find((s) => s.items.some((i) => isItemActive(i.href, pathname)))?.id ?? null; + + // Single-open accordion, collapsed by default. Auto-expand the section that + // contains the active route so the user is never stranded on a hidden link. + const [openSection, setOpenSection] = useState(activeSectionId); + + // On navigation, open the section holding the new active route (which, being a + // single-open accordion, collapses any other open heading). + useEffect(() => { + if (activeSectionId) setOpenSection(activeSectionId); + }, [activeSectionId]); + + const toggleSection = (id: string) => + setOpenSection((current) => (current === id ? null : id)); return ( ); } -function SectionHeader({ label }: { label: string }) { +function SectionHeader({ + label, + isOpen, + regionId, + onToggle, +}: { + label: string; + isOpen: boolean; + regionId: string; + onToggle: () => void; +}) { return ( -
-

{label}

-
+ ); } function NavLink({ item, pathname }: { item: NavItem; pathname: string }) { - const isActive = pathname === item.href || pathname.startsWith(item.href + "/"); + const isActive = isItemActive(item.href, pathname); const Icon = item.icon; return ( ({ usePathname: () => mockPathname })); + +import { Sidebar } from "@/components/layout/sidebar"; + +beforeEach(() => { + mockPathname = "/dashboard"; +}); + +function headerButton(label: string) { + return screen.getByRole("button", { name: new RegExp(`^${label}`, "i") }); +} + +describe("Sidebar collapsible sections", () => { + it("renders section headings as toggle buttons, collapsed by default", () => { + // ADMIN sees a Purchasing-less layout? No — render a MANAGER who has + // Purchasing + Administration headed sections. + render(); + + const purchasing = headerButton("Purchasing"); + const administration = headerButton("Administration"); + + expect(purchasing).toHaveAttribute("aria-expanded", "false"); + expect(administration).toHaveAttribute("aria-expanded", "false"); + + // Collapsed → section links are not in the DOM. + expect(screen.queryByRole("link", { name: /Cost Centres/i })).not.toBeInTheDocument(); + }); + + it("expands a section and reveals its links when its header is clicked", () => { + render(); + + const purchasing = headerButton("Purchasing"); + fireEvent.click(purchasing); + + expect(purchasing).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("link", { name: /Cost Centres/i })).toBeInTheDocument(); + }); + + it("collapses other sections when one is opened (single-open accordion)", () => { + render(); + + const purchasing = headerButton("Purchasing"); + const administration = headerButton("Administration"); + + fireEvent.click(purchasing); + expect(purchasing).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(administration); + expect(administration).toHaveAttribute("aria-expanded", "true"); + // Opening Administration collapses Purchasing. + expect(purchasing).toHaveAttribute("aria-expanded", "false"); + }); + + it("toggles a section closed when its header is clicked again", () => { + render(); + + const purchasing = headerButton("Purchasing"); + fireEvent.click(purchasing); + expect(purchasing).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(purchasing); + expect(purchasing).toHaveAttribute("aria-expanded", "false"); + }); + + it("auto-expands the section containing the active route on mount", () => { + mockPathname = "/admin/vessels"; // Cost Centres lives under Administration (manager mgmt → Purchasing) + render(); + + // /admin/vessels is in the Purchasing management block for a MANAGER. + const purchasing = headerButton("Purchasing"); + expect(purchasing).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("link", { name: /Cost Centres/i })).toBeInTheDocument(); + }); + + it("keeps the PPMS brand outside any collapsible section", () => { + render(); + // Brand text is always visible regardless of section state. + expect(screen.getByText("PPMS")).toBeInTheDocument(); + }); + + it("renders the always-visible main links outside the sections", () => { + render(); + expect(screen.getByRole("link", { name: /Dashboard/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /My Profile/i })).toBeInTheDocument(); + }); + + it("scopes revealed links to the opened section", () => { + render(); + const administration = headerButton("Administration"); + fireEvent.click(administration); + + // Vendors appears under Administration for a manager. + const adminVendors = screen.getByRole("link", { name: /Vendors/i }); + expect(adminVendors).toBeInTheDocument(); + expect(within(adminVendors).queryByText("Vendors")).toBeTruthy(); + }); +});