From 3bae52519f6ea7b326997075735f156318e02b81 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 11 Sep 2026 16:42:15 -0300 Subject: [PATCH] fix(work-orders): align sidebar and advanced filters with AAP --- e2e/vendors/vendor-operations.spec.ts | 18 +- e2e/vendors/vendors.spec.ts | 4 +- src/app/(protected)/account/index.tsx | 5 + src/app/(protected)/completion-docs/index.tsx | 5 + src/app/(protected)/services/index.tsx | 5 + .../advanced-search/adv-filter-sections.tsx | 8 +- .../adv-parity-filter-sections.tsx | 20 +- .../use-advanced-search-draft.ts | 6 + .../work-orders-advanced-search-sheet.tsx | 9 +- src/components/common/placeholder-page.tsx | 20 ++ src/components/layout/app-sidebar.tsx | 210 ++++++------------ src/components/layout/sidebar-nav-item.tsx | 128 +++++++++++ src/components/ui/collapsible-section.tsx | 4 +- src/config/menu.ts | 65 +++--- src/styles/typography.css | 2 +- ...ork-orders-advanced-search-wiring.test.tsx | 41 ++++ ...ork-orders-board-core-affordances.test.tsx | 4 +- .../common/placeholder-page.test.tsx | 14 ++ .../components/layout/app-sidebar.test.tsx | 96 ++++++++ .../layout/sidebar-nav-item.test.tsx | 41 ++++ .../ui/collapsible-section.test.tsx | 16 ++ src/test/config/menu.test.ts | 69 +++++- 22 files changed, 589 insertions(+), 201 deletions(-) create mode 100644 src/app/(protected)/account/index.tsx create mode 100644 src/app/(protected)/completion-docs/index.tsx create mode 100644 src/app/(protected)/services/index.tsx create mode 100644 src/components/common/placeholder-page.tsx create mode 100644 src/components/layout/sidebar-nav-item.tsx create mode 100644 src/test/components/common/placeholder-page.test.tsx create mode 100644 src/test/components/layout/app-sidebar.test.tsx create mode 100644 src/test/components/layout/sidebar-nav-item.test.tsx create mode 100644 src/test/components/ui/collapsible-section.test.tsx diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index 13c7b1eb..7942f1c1 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -38,16 +38,14 @@ test.describe("Vendor operations", () => { await page.goto("/vendors/insights"); - const vendorLinks = page.locator('a[href^="/vendors"]'); - await expect( - vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }), - ).toHaveAttribute("aria-current", "page"); - await expect( - vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }), - ).not.toHaveAttribute("aria-current", "page"); - await expect( - vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }), - ).not.toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("link", { name: "Vendors", exact: true })).toHaveAttribute( + "aria-current", + "page", + ); + await expect(page.getByRole("link", { name: "Sites", exact: true })).not.toHaveAttribute( + "aria-current", + "page", + ); }); test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => { diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e8f54bef..a426d97a 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -795,9 +795,9 @@ test.describe("Vendor directory prototype parity", () => { for (const destination of [ { path: "/workorders", label: "Work Orders" }, - { path: "/accounts", label: "Accounts" }, + { path: "/vendors", label: "Vendors" }, { path: "/locations", label: "Sites" }, - { path: "/notifications", label: "Notifications" }, + { path: "/account", label: "My Account" }, ]) { await page.goto(destination.path); await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute( diff --git a/src/app/(protected)/account/index.tsx b/src/app/(protected)/account/index.tsx new file mode 100644 index 00000000..47d7e04b --- /dev/null +++ b/src/app/(protected)/account/index.tsx @@ -0,0 +1,5 @@ +import { PlaceholderPage } from "@/components/common/placeholder-page"; + +export default function AccountPage() { + return ; +} diff --git a/src/app/(protected)/completion-docs/index.tsx b/src/app/(protected)/completion-docs/index.tsx new file mode 100644 index 00000000..e5c9ea2f --- /dev/null +++ b/src/app/(protected)/completion-docs/index.tsx @@ -0,0 +1,5 @@ +import { PlaceholderPage } from "@/components/common/placeholder-page"; + +export default function CompletionDocsPage() { + return ; +} diff --git a/src/app/(protected)/services/index.tsx b/src/app/(protected)/services/index.tsx new file mode 100644 index 00000000..e86bc94b --- /dev/null +++ b/src/app/(protected)/services/index.tsx @@ -0,0 +1,5 @@ +import { PlaceholderPage } from "@/components/common/placeholder-page"; + +export default function ServicesPage() { + return ; +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index 812b98b6..b21120b7 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -13,11 +13,7 @@ import { import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill"; import { AdvDateRangeSection } from "./adv-date-range-section"; import { AdvMultiSelect } from "./adv-multi-select"; -import { - AdvAvetaSection, - AdvFlagInternalUpliftSections, - AdvIndicatorsSection, -} from "./adv-parity-filter-sections"; +import { AdvFlagInternalUpliftSections, AdvIndicatorsSection } from "./adv-parity-filter-sections"; import { AdvSection } from "./adv-section"; import { AdvSeverityList } from "./adv-severity-list"; import { AdvTypeGrid } from "./adv-type-grid"; @@ -131,8 +127,6 @@ export function AdvancedSearchFilterSections({ - - ({ value: pmType, label: pmType }))} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx index c5189fab..8c6b1446 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx @@ -125,6 +125,12 @@ export function AdvIndicatorsSection({ draft, setDraft }: DraftProps) { ); } +const FILTER_TOGGLE_CHECKBOX_SX = { + padding: 0, + color: "var(--color-border-hover)", + "&.Mui-checked": { color: "var(--color-primary)" }, +} as const; + export function AdvAvetaSection({ draft, setDraft }: DraftProps) { return ( @@ -133,11 +139,7 @@ export function AdvAvetaSection({ draft, setDraft }: DraftProps) { size="small" checked={draft.avetaOnly} onChange={() => setDraft((prev) => ({ ...prev, avetaOnly: !prev.avetaOnly }))} - sx={{ - padding: 0, - color: "var(--color-border-hover)", - "&.Mui-checked": { color: "var(--warning)" }, - }} + sx={FILTER_TOGGLE_CHECKBOX_SX} /> Aveta required only @@ -195,6 +197,8 @@ export function AdvFlagInternalUpliftSections({ + + diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/use-advanced-search-draft.ts b/src/app/(protected)/workorders/_components/list/advanced-search/use-advanced-search-draft.ts index 4f367f18..e1061f0e 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/use-advanced-search-draft.ts +++ b/src/app/(protected)/workorders/_components/list/advanced-search/use-advanced-search-draft.ts @@ -55,11 +55,17 @@ export function useAdvancedSearchDraft({ }; const handleClearAll = () => { + if (activeCount === 0) { + return; + } setDraft(cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS)); onClearAll(); }; const handleApply = () => { + if (activeCount === 0) { + return; + } onApply(cloneAdvancedFilters(draft)); onOpenChange(false); }; diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx index c42e1276..b18415f5 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx @@ -64,7 +64,12 @@ export function WorkOrdersAdvancedSearchSheet({ />
-
@@ -74,7 +79,7 @@ export function WorkOrdersAdvancedSearchSheet({ )}
-
diff --git a/src/components/common/placeholder-page.tsx b/src/components/common/placeholder-page.tsx new file mode 100644 index 00000000..3d110268 --- /dev/null +++ b/src/components/common/placeholder-page.tsx @@ -0,0 +1,20 @@ +import { Box } from "@mui/material"; + +import { Text } from "@/components/ui/text"; + +type PlaceholderPageProps = { + title: string; +}; + +export function PlaceholderPage({ title }: PlaceholderPageProps) { + return ( + + + {title} + + + This page is under construction. + + + ); +} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 84aeb606..99d92161 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,120 +1,66 @@ -import { useState } from "react"; -import { Link, useLocation } from "react-router"; +import { useLocation } from "react-router"; import { Box, Drawer } from "@mui/material"; import { BrandLockup, BrandMark } from "@/components/common/brand-lockup"; -import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu"; +import { SidebarNavItem } from "@/components/layout/sidebar-nav-item"; +import { Text } from "@/components/ui/text"; +import { + accountNavItem, + getActiveNavPath, + getSidebarWidth, + getVisibleNavGroups, +} from "@/config/menu"; +import { useAuthContext } from "@/providers/auth-context"; -function getNavItemAppearance(isActive: boolean, isHover: boolean) { - if (isActive) { - return { - itemColor: "var(--primary)", - iconColor: "var(--primary)", - itemBg: "var(--color-sidebar-item-active-bg)", - }; - } - - if (isHover) { - return { - itemColor: "var(--primary-hover)", - iconColor: "var(--primary-hover)", - itemBg: "var(--sidebar-accent)", - }; - } - - return { - itemColor: "var(--foreground)", - iconColor: "var(--muted-foreground)", - itemBg: "transparent", - }; -} - -type NavItemProps = NavItemDef & { +type NavGroupHeadingProps = { + title: string; collapsed: boolean; - active: boolean; - onNavigate?: () => void; + showDivider: boolean; }; -function NavItem({ label, icon: Icon, path, collapsed, active, badge, onNavigate }: NavItemProps) { - const [hovered, setHovered] = useState(false); - const isHover = hovered && !active; - const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover); +function NavGroupHeading({ title, collapsed, showDivider }: NavGroupHeadingProps) { + if (collapsed) { + if (!showDivider) { + return null; + } + + return ( + + ); + } + + if (!title) { + return ; + } return ( - - setHovered(true)} - onMouseLeave={() => setHovered(false)} - sx={{ - position: "relative", - display: "flex", - alignItems: "center", - width: "100%", - height: 40, - gap: "12px", - px: collapsed ? 0 : "12px", - justifyContent: collapsed ? "center" : "flex-start", - borderRadius: "6px", - cursor: "pointer", - fontFamily: "var(--font-sans)", - fontSize: 14, - fontWeight: active ? 600 : 500, - color: itemColor, - backgroundColor: itemBg, - boxShadow: active ? "var(--color-sidebar-item-active-shadow)" : "none", - transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)", - mb: "4px", - textAlign: "left", - }} - > - {active && ( - - )} - - {!collapsed && ( - <> - {label} - {badge != null && ( - - {badge} - - )} - - )} - - + {title} + ); } @@ -128,7 +74,10 @@ type AppSidebarProps = { export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) { const width = getSidebarWidth(collapsed); const { pathname } = useLocation(); + const { user } = useAuthContext(); const activePath = getActiveNavPath(pathname); + const visibleGroups = getVisibleNavGroups(user?.userRoles); + const onNavigate = mobile ? onClose : undefined; return ( - {navGroups.map((group, groupIndex) => ( + {visibleGroups.map((group, groupIndex) => ( - {collapsed ? ( - groupIndex > 0 && ( - - ) - ) : group.title ? ( - - {group.title} - - ) : ( - - )} + 0} + /> {group.items.map(({ label, icon, path, badge }) => ( - ))} @@ -220,6 +147,15 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: ))} + + + + ); diff --git a/src/components/layout/sidebar-nav-item.tsx b/src/components/layout/sidebar-nav-item.tsx new file mode 100644 index 00000000..eb1f98b0 --- /dev/null +++ b/src/components/layout/sidebar-nav-item.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { Link } from "react-router"; +import { Box } from "@mui/material"; + +import { getVisibleBadge, type NavItemDef } from "@/config/menu"; + +function getNavItemAppearance(isActive: boolean, isHover: boolean) { + if (isActive) { + return { + itemColor: "var(--primary)", + iconColor: "var(--primary)", + itemBg: "var(--color-sidebar-item-active-bg)", + }; + } + + if (isHover) { + return { + itemColor: "var(--primary-hover)", + iconColor: "var(--primary-hover)", + itemBg: "var(--sidebar-accent)", + }; + } + + return { + itemColor: "var(--foreground)", + iconColor: "var(--muted-foreground)", + itemBg: "transparent", + }; +} + +type SidebarNavItemProps = NavItemDef & { + collapsed: boolean; + active: boolean; + onNavigate?: () => void; +}; + +export function SidebarNavItem({ + label, + icon: Icon, + path, + collapsed, + active, + badge, + onNavigate, +}: SidebarNavItemProps) { + const [hovered, setHovered] = useState(false); + const isHover = hovered && !active; + const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover); + const visibleBadge = getVisibleBadge(badge); + + return ( + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + sx={{ + position: "relative", + display: "flex", + alignItems: "center", + width: "100%", + height: 40, + gap: "12px", + px: collapsed ? 0 : "12px", + justifyContent: collapsed ? "center" : "flex-start", + borderRadius: "6px", + cursor: "pointer", + fontFamily: "var(--font-sans)", + fontSize: 14, + fontWeight: active ? 600 : 500, + color: itemColor, + backgroundColor: itemBg, + boxShadow: active ? "var(--color-sidebar-item-active-shadow)" : "none", + transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)", + mb: "4px", + textAlign: "left", + }} + > + {active && ( + + )} + + {Boolean(!collapsed) && ( + <> + {label} + {visibleBadge != null && ( + + {visibleBadge} + + )} + + )} + + + ); +} diff --git a/src/components/ui/collapsible-section.tsx b/src/components/ui/collapsible-section.tsx index 8a870576..1d18f8f6 100644 --- a/src/components/ui/collapsible-section.tsx +++ b/src/components/ui/collapsible-section.tsx @@ -33,7 +33,9 @@ export function CollapsibleSection({ >
{Icon != null && } - {label} + + {label} +
diff --git a/src/config/menu.ts b/src/config/menu.ts index 3974c0dc..01303e59 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -1,17 +1,18 @@ import type { LucideIcon } from "lucide-react"; import { - // BarChart2, - BriefcaseBusiness, Building2, ClipboardList, + DollarSign, FileText, LayoutDashboard, MapPin, - Bell, - BarChart3, - Star, + Settings, + UserRound, + Wrench, } from "lucide-react"; +import { isAdminUser } from "@/lib/auth/user-utils"; + export type NavItemDef = { label: string; icon: LucideIcon; @@ -22,52 +23,66 @@ export type NavItemDef = { export type NavGroup = { title: string; items: NavItemDef[]; + visible?: (userRoles: string | null | undefined) => boolean; }; export const SIDEBAR_WIDTH = 244; export const SIDEBAR_COLLAPSED_WIDTH = 76; +export const accountNavItem: NavItemDef = { + label: "My Account", + icon: Settings, + path: "/account", +}; + export function getSidebarWidth(collapsed: boolean): number { return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH; } export const navGroups: NavGroup[] = [ { - title: "", + title: "OPERATIONS", items: [ - { label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 }, + { label: "Work Orders", icon: ClipboardList, path: "/workorders" }, { label: "Dashboard", icon: LayoutDashboard, path: "/" }, - { label: "Notifications", icon: Bell, path: "/notifications" }, - // { label: "Reports", icon: BarChart2, path: "/reports" }, ], }, { - title: "VENDORS", + title: "DATA MANAGEMENT", items: [ { label: "Vendors", icon: Building2, path: "/vendors" }, - { label: "Vendor POs", icon: FileText, path: "/vendor-pos" }, - { label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" }, - { label: "Site Preferences", icon: Star, path: "/vendors/preferences" }, + { label: "Sites", icon: MapPin, path: "/locations" }, + { label: "Services", icon: Wrench, path: "/services" }, + { label: "Completion Docs", icon: FileText, path: "/completion-docs" }, ], }, { - title: "CUSTOMERS & SITES", - items: [ - { label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" }, - { label: "Sites", icon: MapPin, path: "/locations" }, - ], + title: "TEAM", + visible: isAdminUser, + items: [{ label: "Team Members", icon: UserRound, path: "/employees" }], + }, + { + title: "APPROVALS", + visible: isAdminUser, + items: [{ label: "Uplift Approvals", icon: DollarSign, path: "/uplifts" }], }, - // { - // title: "INTERNAL", - // items: [{ label: "Documents", icon: FileText, path: "/documents" }], - // }, ]; +export function getVisibleNavGroups(userRoles: string | null | undefined): NavGroup[] { + return navGroups.filter((group) => group.visible == null || group.visible(userRoles)); +} + +export function getVisibleBadge(badge?: number): number | undefined { + return badge != null && badge > 0 ? badge : undefined; +} + +function collectNavPaths(): string[] { + return [...navGroups.flatMap((group) => group.items), accountNavItem].map((item) => item.path); +} + export function getActiveNavPath(pathname: string): string | undefined { - return navGroups - .flatMap((group) => group.items) - .map((item) => item.path) + return collectNavPaths() .filter((path) => path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`), ) diff --git a/src/styles/typography.css b/src/styles/typography.css index 34d61f9e..ed591c42 100644 --- a/src/styles/typography.css +++ b/src/styles/typography.css @@ -73,7 +73,7 @@ h6 { } .text-nav-group { - font-family: var(--font-mono); + font-family: var(--font-display); font-size: 10px; font-weight: 600; line-height: normal; diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 5b1e50c8..2bcf1cf6 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -152,6 +152,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(within(sheet).getByText("HVAC")).toBeInTheDocument(); expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument(); + fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i })); + fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i })); fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); expect(applyAdvancedFilters).toHaveBeenCalled(); expect(setAdvOpen).toHaveBeenCalledWith(false); @@ -341,6 +343,45 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled(); }); + it("places Aveta Compliance immediately above Uplift", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + const aveta = within(sheet).getByRole("button", { name: /Aveta Compliance/i }); + const uplift = within(sheet).getByRole("button", { name: /^Uplift/i }); + expect(aveta.closest(".border-b")?.nextElementSibling).toBe(uplift.closest(".border-b")); + }); + + it("keeps Clear all and Apply filters disabled until a filter is selected", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + const clearAll = within(sheet).getByRole("button", { name: /clear all/i }); + const apply = within(sheet).getByRole("button", { name: /apply filters/i }); + expect(clearAll).toBeDisabled(); + expect(apply).toBeDisabled(); + + fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i })); + fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i })); + + expect(clearAll).toBeEnabled(); + expect(apply).toBeEnabled(); + }); + it("applies Aveta required only through Advanced Filters", () => { const view = renderWithProviders(, { route: "/workorders", diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index e273324c..2398db28 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -211,7 +211,9 @@ describe("WorkOrdersListHeader slice affordances", () => { ); expect(screen.getByRole("button", { name: /new wo/i })).toBeInTheDocument(); - expect(screen.getByLabelText("Advanced Filters")).toBeInTheDocument(); + const filters = screen.getByRole("button", { name: "Advanced Filters" }); + expect(filters).toBeInTheDocument(); + expect(filters).toHaveTextContent("Filters"); }); }); diff --git a/src/test/components/common/placeholder-page.test.tsx b/src/test/components/common/placeholder-page.test.tsx new file mode 100644 index 00000000..0b4f7056 --- /dev/null +++ b/src/test/components/common/placeholder-page.test.tsx @@ -0,0 +1,14 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { PlaceholderPage } from "@/components/common/placeholder-page"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("PlaceholderPage", () => { + it("renders the AAP under-construction copy", () => { + renderWithProviders(, { withAuth: false }); + + expect(screen.getByRole("heading", { name: "Services" })).toBeInTheDocument(); + expect(screen.getByText("This page is under construction.")).toBeInTheDocument(); + }); +}); diff --git a/src/test/components/layout/app-sidebar.test.tsx b/src/test/components/layout/app-sidebar.test.tsx new file mode 100644 index 00000000..390256cf --- /dev/null +++ b/src/test/components/layout/app-sidebar.test.tsx @@ -0,0 +1,96 @@ +import { screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { AppSidebar } from "@/components/layout/app-sidebar"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; +import { renderWithProviders } from "@/test/test-utils"; + +function createUser(userRoles: string): AuthUser { + return { + token: "test-jwt-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "user@seahavenind.com", + userRoles, + phoneNumber: "5551234567", + fullname: "Test User", + id: 1, + }; +} + +function createAuthValue(userRoles: string): AuthContextValue { + return { + user: createUser(userRoles), + isAuthenticated: true, + isLoading: false, + error: null, + login: vi.fn(), + logout: vi.fn(), + changePassword: vi.fn(), + isLoggingIn: false, + isLoggingOut: false, + isChangingPassword: false, + loginError: null, + }; +} + +function renderSidebar(ui: ReactElement, userRoles: string, route = "/workorders") { + return renderWithProviders( + {ui}, + { + withAuth: false, + route, + }, + ); +} + +describe("AppSidebar", () => { + it("renders the AAP tree plus My Account for Admin", () => { + renderSidebar(, "Admin"); + + expect(screen.getByText("OPERATIONS")).toBeInTheDocument(); + expect(screen.getByText("DATA MANAGEMENT")).toBeInTheDocument(); + expect(screen.getByText("TEAM")).toBeInTheDocument(); + expect(screen.getByText("APPROVALS")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Work Orders" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Team Members" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Uplift Approvals" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "My Account" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Notifications" })).not.toBeInTheDocument(); + expect(screen.queryByText("11")).not.toBeInTheDocument(); + }); + + it("hides TEAM and APPROVALS for non-admin and keeps My Account", () => { + renderSidebar(, "Dispatcher"); + + expect(screen.getByText("OPERATIONS")).toBeInTheDocument(); + expect(screen.queryByText("TEAM")).not.toBeInTheDocument(); + expect(screen.queryByText("APPROVALS")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Team Members" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Uplift Approvals" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "My Account" })).toBeInTheDocument(); + }); + + it("hides group labels and item text when collapsed", () => { + renderSidebar(, "Admin"); + + expect(screen.queryByText("OPERATIONS")).not.toBeInTheDocument(); + expect(screen.queryByText("Work Orders")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Work Orders" })).toHaveAttribute( + "title", + "Work Orders", + ); + expect(screen.getByRole("link", { name: "My Account" })).toHaveAttribute("title", "My Account"); + }); + + it("marks the active leaf from the current route", () => { + renderSidebar(, "Admin", "/uplifts"); + + expect(screen.getByRole("link", { name: "Uplift Approvals" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "Work Orders" })).not.toHaveAttribute("aria-current"); + }); +}); diff --git a/src/test/components/layout/sidebar-nav-item.test.tsx b/src/test/components/layout/sidebar-nav-item.test.tsx new file mode 100644 index 00000000..cefae72d --- /dev/null +++ b/src/test/components/layout/sidebar-nav-item.test.tsx @@ -0,0 +1,41 @@ +import { ClipboardList } from "lucide-react"; +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SidebarNavItem } from "@/components/layout/sidebar-nav-item"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("SidebarNavItem", () => { + it("omits a zero badge", () => { + renderWithProviders( + , + { withAuth: false, route: "/workorders" }, + ); + + expect(screen.getByRole("link", { name: "Work Orders" })).toBeInTheDocument(); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + }); + + it("shows a positive badge", () => { + renderWithProviders( + , + { withAuth: false, route: "/uplifts" }, + ); + + expect(screen.getByText("2")).toBeInTheDocument(); + }); +}); diff --git a/src/test/components/ui/collapsible-section.test.tsx b/src/test/components/ui/collapsible-section.test.tsx new file mode 100644 index 00000000..0d1d1994 --- /dev/null +++ b/src/test/components/ui/collapsible-section.test.tsx @@ -0,0 +1,16 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { CollapsibleSection } from "@/components/ui/collapsible-section"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("CollapsibleSection", () => { + it("renders the section title at the 14px body size token", () => { + renderWithProviders(child, { + withAuth: false, + }); + + const title = screen.getByRole("button", { name: /Aveta Compliance/i }).querySelector("span"); + expect(title).toHaveClass("text-[length:var(--text-base)]"); + }); +}); diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts index ac75b38c..a5afd99d 100644 --- a/src/test/config/menu.test.ts +++ b/src/test/config/menu.test.ts @@ -1,20 +1,79 @@ -import { getActiveNavPath } from "@/config/menu"; +import { getActiveNavPath, getVisibleBadge, getVisibleNavGroups, navGroups } from "@/config/menu"; import { describe, expect, it } from "vitest"; +describe("navGroups", () => { + it("exposes the AAP flat tree in order", () => { + expect(navGroups.map((group) => group.title)).toEqual([ + "OPERATIONS", + "DATA MANAGEMENT", + "TEAM", + "APPROVALS", + ]); + expect(navGroups.flatMap((group) => group.items.map((item) => item.label))).toEqual([ + "Work Orders", + "Dashboard", + "Vendors", + "Sites", + "Services", + "Completion Docs", + "Team Members", + "Uplift Approvals", + ]); + }); + + it("does not hardcode a Work Orders badge", () => { + const workOrders = navGroups + .flatMap((group) => group.items) + .find((item) => item.label === "Work Orders"); + + expect(workOrders?.badge).toBeUndefined(); + }); +}); + +describe("getVisibleNavGroups", () => { + it("shows TEAM and APPROVALS only for Admin", () => { + expect(getVisibleNavGroups("Admin").map((group) => group.title)).toEqual([ + "OPERATIONS", + "DATA MANAGEMENT", + "TEAM", + "APPROVALS", + ]); + expect(getVisibleNavGroups("Dispatcher").map((group) => group.title)).toEqual([ + "OPERATIONS", + "DATA MANAGEMENT", + ]); + }); +}); + +describe("getVisibleBadge", () => { + it("hides missing or zero counts", () => { + expect(getVisibleBadge(undefined)).toBeUndefined(); + expect(getVisibleBadge(0)).toBeUndefined(); + expect(getVisibleBadge(2)).toBe(2); + }); +}); + describe("getActiveNavPath", () => { it.each([ ["/", "/"], + ["/workorders", "/workorders"], + ["/workorders/42", "/workorders"], ["/vendors", "/vendors"], ["/vendors/42", "/vendors"], - ["/vendors/42/edit", "/vendors"], - ["/vendors/insights", "/vendors/insights"], - ["/vendors/preferences", "/vendors/preferences"], - ["/vendor-pos/42", "/vendor-pos"], + ["/vendors/insights", "/vendors"], + ["/locations", "/locations"], + ["/services", "/services"], + ["/completion-docs", "/completion-docs"], + ["/employees", "/employees"], + ["/employees/12", "/employees"], + ["/uplifts", "/uplifts"], + ["/account", "/account"], ])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => { expect(getActiveNavPath(pathname)).toBe(expectedPath); }); it("does not match partial route segments", () => { expect(getActiveNavPath("/vendor-posts")).toBeUndefined(); + expect(getActiveNavPath("/accounts")).toBeUndefined(); }); });