mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): keep sidebar scope unchanged
This commit is contained in:
parent
000aaae92d
commit
c4dd664e3f
14 changed files with 181 additions and 503 deletions
20
e2e/vendors/vendor-operations.spec.ts
vendored
20
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -38,14 +38,16 @@ test.describe("Vendor operations", () => {
|
|||
|
||||
await page.goto("/vendors/insights");
|
||||
|
||||
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",
|
||||
);
|
||||
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");
|
||||
});
|
||||
|
||||
test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => {
|
||||
|
|
@ -252,7 +254,7 @@ test.describe("Vendor operations", () => {
|
|||
});
|
||||
|
||||
await page.goto("/vendors/preferences");
|
||||
await page.getByLabel("Site", { exact: true }).click();
|
||||
await page.getByLabel("Site").click();
|
||||
await page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
|
||||
|
|
|
|||
4
e2e/vendors/vendors.spec.ts
vendored
4
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -795,9 +795,9 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
|
||||
for (const destination of [
|
||||
{ path: "/workorders", label: "Work Orders" },
|
||||
{ path: "/vendors", label: "Vendors" },
|
||||
{ path: "/accounts", label: "Accounts" },
|
||||
{ path: "/locations", label: "Sites" },
|
||||
{ path: "/account", label: "My Account" },
|
||||
{ path: "/notifications", label: "Notifications" },
|
||||
]) {
|
||||
await page.goto(destination.path);
|
||||
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
||||
|
||||
export default function AccountPage() {
|
||||
return <PlaceholderPage title="My Account" />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
||||
|
||||
export default function CompletionDocsPage() {
|
||||
return <PlaceholderPage title="Completion Docs" />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
||||
|
||||
export default function ServicesPage() {
|
||||
return <PlaceholderPage title="Services" />;
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +1,120 @@
|
|||
import { useLocation } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Box, Drawer } from "@mui/material";
|
||||
|
||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||
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";
|
||||
import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
|
||||
|
||||
type NavGroupHeadingProps = {
|
||||
title: string;
|
||||
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 & {
|
||||
collapsed: boolean;
|
||||
showDivider: boolean;
|
||||
active: boolean;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
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 }} />;
|
||||
}
|
||||
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);
|
||||
|
||||
return (
|
||||
<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",
|
||||
}}
|
||||
<Link
|
||||
to={path}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="no-underline"
|
||||
title={collapsed ? label : undefined}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -74,10 +128,7 @@ 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
|
||||
|
|
@ -121,25 +172,47 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
|||
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||
sx={{ py: 1 }}
|
||||
>
|
||||
{visibleGroups.map((group, groupIndex) => (
|
||||
{navGroups.map((group, groupIndex) => (
|
||||
<Box key={group.title || `group-${groupIndex}`}>
|
||||
<NavGroupHeading
|
||||
title={group.title}
|
||||
collapsed={collapsed}
|
||||
showDivider={groupIndex > 0}
|
||||
/>
|
||||
{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 }} />
|
||||
)}
|
||||
|
||||
<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" }}>
|
||||
<SidebarNavItem
|
||||
<NavItem
|
||||
label={label}
|
||||
icon={icon}
|
||||
path={path}
|
||||
badge={badge}
|
||||
collapsed={collapsed}
|
||||
active={path === activePath}
|
||||
onNavigate={onNavigate}
|
||||
onNavigate={mobile ? onClose : undefined}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
|
@ -147,15 +220,6 @@ 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
// BarChart2,
|
||||
BriefcaseBusiness,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
DollarSign,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
MapPin,
|
||||
Settings,
|
||||
UserRound,
|
||||
Wrench,
|
||||
Bell,
|
||||
BarChart3,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
|
||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||
|
||||
export type NavItemDef = {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
|
|
@ -23,66 +22,52 @@ 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: "OPERATIONS",
|
||||
title: "",
|
||||
items: [
|
||||
{ label: "Work Orders", icon: ClipboardList, path: "/workorders" },
|
||||
{ label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 },
|
||||
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
|
||||
{ label: "Notifications", icon: Bell, path: "/notifications" },
|
||||
// { label: "Reports", icon: BarChart2, path: "/reports" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "DATA MANAGEMENT",
|
||||
title: "VENDORS",
|
||||
items: [
|
||||
{ label: "Vendors", icon: Building2, path: "/vendors" },
|
||||
{ label: "Sites", icon: MapPin, path: "/locations" },
|
||||
{ label: "Services", icon: Wrench, path: "/services" },
|
||||
{ label: "Completion Docs", icon: FileText, path: "/completion-docs" },
|
||||
{ label: "Vendor POs", icon: FileText, path: "/vendor-pos" },
|
||||
{ label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" },
|
||||
{ label: "Site Preferences", icon: Star, path: "/vendors/preferences" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: "CUSTOMERS & SITES",
|
||||
items: [
|
||||
{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" },
|
||||
{ label: "Sites", icon: MapPin, path: "/locations" },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// 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 collectNavPaths()
|
||||
return navGroups
|
||||
.flatMap((group) => group.items)
|
||||
.map((item) => item.path)
|
||||
.filter((path) =>
|
||||
path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ h6 {
|
|||
}
|
||||
|
||||
.text-nav-group {
|
||||
font-family: var(--font-display);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,79 +1,20 @@
|
|||
import { getActiveNavPath, getVisibleBadge, getVisibleNavGroups, navGroups } from "@/config/menu";
|
||||
import { getActiveNavPath } 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/insights", "/vendors"],
|
||||
["/locations", "/locations"],
|
||||
["/services", "/services"],
|
||||
["/completion-docs", "/completion-docs"],
|
||||
["/employees", "/employees"],
|
||||
["/employees/12", "/employees"],
|
||||
["/uplifts", "/uplifts"],
|
||||
["/account", "/account"],
|
||||
["/vendors/42/edit", "/vendors"],
|
||||
["/vendors/insights", "/vendors/insights"],
|
||||
["/vendors/preferences", "/vendors/preferences"],
|
||||
["/vendor-pos/42", "/vendor-pos"],
|
||||
])("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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue