diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index 97d23975..13c7b1eb 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -16,9 +16,68 @@ async function fulfillJson(route: Route, body: unknown, status = 200) { async function authenticate(page: Page) { await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + await page.route("**/api/Dashboard/Stats", (route) => + fulfillJson(route, { + data: { + openWorkOrders: 0, + pendingApprovals: 0, + scheduledPMs: 0, + activeAssets: 0, + }, + }), + ); + await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] })); } test.describe("Vendor operations", () => { + test("keeps the most specific vendor navigation item exclusively current", async ({ page }) => { + await authenticate(page); + await page.route("**/api/vendor-operations/insights*", (route) => + fulfillJson(route, { data: [] }), + ); + + 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"); + }); + + test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await authenticate(page); + await page.route("**/api/dispatches*", (route) => fulfillJson(route, { items: [], total: 0 })); + await page.route("**/api/Vendor/GetVendorList*", (route) => + fulfillJson(route, { Data: [], TotalCount: 0 }), + ); + + await page.goto("/vendor-pos"); + + await expect(page.getByRole("heading", { name: "Vendor POs" })).toBeVisible(); + await expect(page.getByText("0 total", { exact: true })).toBeVisible(); + await expect(page.getByPlaceholder("Search PO #, dispatch, vendor, WO...")).toBeVisible(); + await expect(page.locator("label").filter({ hasText: /^Status$/ })).toBeVisible(); + await expect(page.locator("label").filter({ hasText: /^Vendor$/ })).toBeVisible(); + await expect(page.getByLabel("From", { exact: true })).toBeVisible(); + await expect(page.getByLabel("To", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible(); + + await expect + .poll(() => + page + .getByPlaceholder("Search PO #, dispatch, vendor, WO...") + .evaluate((element) => element.clientWidth), + ) + .toBeGreaterThanOrEqual(280); + }); + test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({ page, }) => { diff --git a/src/app/(protected)/vendor-pos/index.tsx b/src/app/(protected)/vendor-pos/index.tsx index 81d07243..a597a866 100644 --- a/src/app/(protected)/vendor-pos/index.tsx +++ b/src/app/(protected)/vendor-pos/index.tsx @@ -102,15 +102,20 @@ export default function VendorPosListPage() { spacing={2} sx={{ alignItems: "baseline", + flexShrink: 0, + flexWrap: "wrap", + minWidth: 0, }} > - + Vendor POs {totalCount} total @@ -126,7 +131,11 @@ export default function VendorPosListPage() { setPage(0); }} placeholder="Search PO #, dispatch, vendor, WO..." - className="min-w-[280px]" + sx={{ + width: { xs: "100%", sm: 360 }, + minWidth: 280, + flexShrink: 0, + }} /> - + Status - diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 83702dc1..24278149 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; -import { NavLink } from "react-router"; +import { Link, useLocation } from "react-router"; import { Box, Drawer } from "@mui/material"; import { BrandLockup, BrandMark } from "@/components/common/brand-lockup"; -import { getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu"; +import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu"; function getNavItemAppearance(isActive: boolean, isHover: boolean) { if (isActive) { @@ -31,88 +31,88 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) { type NavItemProps = NavItemDef & { collapsed: boolean; - end?: boolean; + active: boolean; }; -function NavItem({ label, icon: Icon, path, collapsed, end, badge }: NavItemProps) { +function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) { const [hovered, setHovered] = useState(false); + const isHover = hovered && !active; + const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover); return ( - - {({ isActive }) => { - const isHover = hovered && !isActive; - const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover); - - 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 && ( setHovered(true)} - onMouseLeave={() => setHovered(false)} + component="span" 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: isActive ? 600 : 500, - color: itemColor, - backgroundColor: itemBg, - boxShadow: isActive ? "var(--color-sidebar-item-active-shadow)" : "none", - transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)", - mb: "4px", - textAlign: "left", + position: "absolute", + left: 0, + top: 8, + bottom: 8, + width: 3, + borderRadius: "0 3px 3px 0", + backgroundColor: "var(--primary)", }} - > - {isActive && ( + /> + )} + + {!collapsed && ( + <> + {label} + {badge != null && ( + > + {badge} + )} - - {!collapsed && ( - <> - {label} - {badge != null && ( - - {badge} - - )} - - )} - - ); - }} - + + )} + + ); } @@ -122,6 +122,8 @@ type AppSidebarProps = { export function AppSidebar({ collapsed }: AppSidebarProps) { const width = getSidebarWidth(collapsed); + const { pathname } = useLocation(); + const activePath = getActiveNavPath(pathname); return ( ))} diff --git a/src/config/menu.ts b/src/config/menu.ts index 03716882..8dc95e9e 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -59,3 +59,13 @@ export const navGroups: NavGroup[] = [ // items: [{ label: "Documents", icon: FileText, path: "/documents" }], // }, ]; + +export function getActiveNavPath(pathname: string): string | undefined { + return navGroups + .flatMap((group) => group.items) + .map((item) => item.path) + .filter((path) => + path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`), + ) + .sort((firstPath, secondPath) => secondPath.length - firstPath.length)[0]; +} diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts new file mode 100644 index 00000000..ac75b38c --- /dev/null +++ b/src/test/config/menu.test.ts @@ -0,0 +1,20 @@ +import { getActiveNavPath } from "@/config/menu"; +import { describe, expect, it } from "vitest"; + +describe("getActiveNavPath", () => { + it.each([ + ["/", "/"], + ["/vendors", "/vendors"], + ["/vendors/42", "/vendors"], + ["/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(); + }); +});