fix(work-orders): align sidebar and advanced filters with AAP

This commit is contained in:
Arthur Bassi 2026-09-11 16:42:15 -03:00
parent 793230f0c2
commit 3bae52519f
22 changed files with 589 additions and 201 deletions

View file

@ -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 }) => {

View file

@ -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(

View file

@ -0,0 +1,5 @@
import { PlaceholderPage } from "@/components/common/placeholder-page";
export default function AccountPage() {
return <PlaceholderPage title="My Account" />;
}

View file

@ -0,0 +1,5 @@
import { PlaceholderPage } from "@/components/common/placeholder-page";
export default function CompletionDocsPage() {
return <PlaceholderPage title="Completion Docs" />;
}

View file

@ -0,0 +1,5 @@
import { PlaceholderPage } from "@/components/common/placeholder-page";
export default function ServicesPage() {
return <PlaceholderPage title="Services" />;
}

View file

@ -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({
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
<AdvAvetaSection draft={draft} setDraft={setDraft} />
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
<AdvMultiSelect
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}

View file

@ -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 (
<AdvSection icon={ShieldCheck} label="Aveta Compliance" count={draft.avetaOnly ? 1 : 0}>
@ -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}
/>
<span className="text-sm text-muted-foreground">Aveta required only</span>
</label>
@ -195,6 +197,8 @@ export function AdvFlagInternalUpliftSections({
</button>
</AdvSection>
<AdvAvetaSection draft={draft} setDraft={setDraft} />
<AdvSection icon={DollarSign} label="Uplift" count={draft.hasUplift ? 1 : 0}>
<label className="flex cursor-pointer items-center gap-1.5 select-none">
<Checkbox
@ -207,11 +211,7 @@ export function AdvFlagInternalUpliftSections({
upliftStatuses: prev.hasUplift ? new Set() : prev.upliftStatuses,
}))
}
sx={{
padding: 0,
color: "var(--color-border-hover)",
"&.Mui-checked": { color: "var(--color-primary)" },
}}
sx={FILTER_TOGGLE_CHECKBOX_SX}
/>
<span className="text-sm text-muted-foreground">Has uplift</span>
</label>

View file

@ -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);
};

View file

@ -64,7 +64,12 @@ export function WorkOrdersAdvancedSearchSheet({
/>
<div className="flex shrink-0 items-center gap-2 border-t border-border px-4 py-3">
<Button variant="ghost" className="text-xs" onClick={handleClearAll}>
<Button
variant="ghost"
className="text-xs"
disabled={activeCount === 0}
onClick={handleClearAll}
>
Clear all
</Button>
<div className="flex flex-1 justify-center">
@ -74,7 +79,7 @@ export function WorkOrdersAdvancedSearchSheet({
</span>
)}
</div>
<Button className="px-5 text-xs" onClick={handleApply}>
<Button className="px-5 text-xs" disabled={activeCount === 0} onClick={handleApply}>
Apply filters
</Button>
</div>

View file

@ -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 (
<Box className="p-8">
<Text as="h1" variant="title" sx={{ mb: 0.25, fontSize: 18, fontWeight: 600 }}>
{title}
</Text>
<Text variant="description" tone="muted" sx={{ fontSize: 13 }}>
This page is under construction.
</Text>
</Box>
);
}

View file

@ -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 (
<Box
sx={{
height: 1,
backgroundColor: "var(--color-sidebar-border)",
mx: "14px",
my: "10px",
}}
/>
);
}
if (!title) {
return <Box sx={{ height: 8 }} />;
}
return (
<Link
to={path}
aria-current={active ? "page" : undefined}
className="no-underline"
title={collapsed ? label : undefined}
onClick={onNavigate}
<Text
as="p"
variant="label"
family="display"
tone="muted"
className="text-nav-group"
sx={{
m: 0,
px: "10px",
pt: "14px",
pb: "6px",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
lineHeight: "normal",
}}
>
<Box
onMouseEnter={() => 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 && (
<Box
component="span"
sx={{
position: "absolute",
left: 0,
top: 8,
bottom: 8,
width: 3,
borderRadius: "0 3px 3px 0",
backgroundColor: "var(--primary)",
}}
/>
)}
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
{!collapsed && (
<>
<Box className="truncate flex-1">{label}</Box>
{badge != null && (
<Box
component="span"
className="inline-flex shrink-0 items-center justify-center"
sx={{
height: 18,
minWidth: 18,
px: "6px",
borderRadius: "9999px",
backgroundColor: "var(--accent)",
color: "#ffffff",
fontFamily: "var(--font-sans)",
fontSize: 10,
fontWeight: 700,
lineHeight: 1,
}}
>
{badge}
</Box>
)}
</>
)}
</Box>
</Link>
{title}
</Text>
);
}
@ -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 (
<Drawer
@ -172,47 +121,25 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
className="flex-1 overflow-y-auto overflow-x-hidden"
sx={{ py: 1 }}
>
{navGroups.map((group, groupIndex) => (
{visibleGroups.map((group, groupIndex) => (
<Box key={group.title || `group-${groupIndex}`}>
{collapsed ? (
groupIndex > 0 && (
<Box
sx={{
height: 1,
backgroundColor: "var(--color-sidebar-border)",
mx: "14px",
my: "10px",
}}
/>
)
) : group.title ? (
<Box
component="p"
className="text-nav-group"
sx={{
m: 0,
px: "10px",
pt: "12px",
pb: "6px",
}}
>
{group.title}
</Box>
) : (
<Box sx={{ height: 8 }} />
)}
<NavGroupHeading
title={group.title}
collapsed={collapsed}
showDivider={groupIndex > 0}
/>
<Box component="ul" sx={{ listStyle: "none", m: 0, p: "0 8px" }}>
{group.items.map(({ label, icon, path, badge }) => (
<Box component="li" key={path} sx={{ listStyle: "none" }}>
<NavItem
<SidebarNavItem
label={label}
icon={icon}
path={path}
badge={badge}
collapsed={collapsed}
active={path === activePath}
onNavigate={mobile ? onClose : undefined}
onNavigate={onNavigate}
/>
</Box>
))}
@ -220,6 +147,15 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
</Box>
))}
</Box>
<Box sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}>
<SidebarNavItem
{...accountNavItem}
collapsed={collapsed}
active={accountNavItem.path === activePath}
onNavigate={onNavigate}
/>
</Box>
</Box>
</Drawer>
);

View file

@ -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 (
<Link
to={path}
aria-label={label}
aria-current={active ? "page" : undefined}
className="no-underline"
title={collapsed ? label : undefined}
onClick={onNavigate}
>
<Box
onMouseEnter={() => 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 && (
<Box
component="span"
sx={{
position: "absolute",
left: 0,
top: 8,
bottom: 8,
width: 3,
borderRadius: "0 3px 3px 0",
backgroundColor: "var(--primary)",
}}
/>
)}
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
{Boolean(!collapsed) && (
<>
<Box className="truncate flex-1">{label}</Box>
{visibleBadge != null && (
<Box
component="span"
className="inline-flex shrink-0 items-center justify-center"
sx={{
height: 18,
minWidth: 18,
px: "6px",
borderRadius: "9999px",
backgroundColor: "var(--accent)",
color: "#ffffff",
fontFamily: "var(--font-sans)",
fontSize: 10,
fontWeight: 700,
lineHeight: 1,
}}
>
{visibleBadge}
</Box>
)}
</>
)}
</Box>
</Link>
);
}

View file

@ -33,7 +33,9 @@ export function CollapsibleSection({
>
<div className="flex items-center gap-2">
{Icon != null && <Icon size={16} className="text-primary" />}
<span className="text-sm font-semibold text-foreground">{label}</span>
<span className="text-[length:var(--text-base)] font-semibold text-foreground">
{label}
</span>
</div>
<div className="flex items-center gap-2">
<CountBadge count={count} />

View file

@ -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}/`),
)

View file

@ -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;

View file

@ -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(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
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(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
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(<WorkOrdersListPage />, {
route: "/workorders",

View file

@ -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");
});
});

View file

@ -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(<PlaceholderPage title="Services" />, { withAuth: false });
expect(screen.getByRole("heading", { name: "Services" })).toBeInTheDocument();
expect(screen.getByText("This page is under construction.")).toBeInTheDocument();
});
});

View file

@ -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(
<AuthContext.Provider value={createAuthValue(userRoles)}>{ui}</AuthContext.Provider>,
{
withAuth: false,
route,
},
);
}
describe("AppSidebar", () => {
it("renders the AAP tree plus My Account for Admin", () => {
renderSidebar(<AppSidebar collapsed={false} />, "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(<AppSidebar collapsed={false} />, "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(<AppSidebar collapsed />, "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(<AppSidebar collapsed={false} />, "Admin", "/uplifts");
expect(screen.getByRole("link", { name: "Uplift Approvals" })).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("link", { name: "Work Orders" })).not.toHaveAttribute("aria-current");
});
});

View file

@ -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(
<SidebarNavItem
label="Work Orders"
icon={ClipboardList}
path="/workorders"
badge={0}
collapsed={false}
active={false}
/>,
{ withAuth: false, route: "/workorders" },
);
expect(screen.getByRole("link", { name: "Work Orders" })).toBeInTheDocument();
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
it("shows a positive badge", () => {
renderWithProviders(
<SidebarNavItem
label="Uplift Approvals"
icon={ClipboardList}
path="/uplifts"
badge={2}
collapsed={false}
active={false}
/>,
{ withAuth: false, route: "/uplifts" },
);
expect(screen.getByText("2")).toBeInTheDocument();
});
});

View file

@ -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(<CollapsibleSection label="Aveta Compliance">child</CollapsibleSection>, {
withAuth: false,
});
const title = screen.getByRole("button", { name: /Aveta Compliance/i }).querySelector("span");
expect(title).toHaveClass("text-[length:var(--text-base)]");
});
});

View file

@ -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();
});
});