fix(vendors): make navigation and PO filters responsive

This commit is contained in:
Alexandre Brandizzi 2026-07-28 17:16:50 -03:00
parent 95cbfff301
commit e6a3899f4c
5 changed files with 190 additions and 77 deletions

View file

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

View file

@ -102,15 +102,20 @@ export default function VendorPosListPage() {
spacing={2}
sx={{
alignItems: "baseline",
flexShrink: 0,
flexWrap: "wrap",
minWidth: 0,
}}
>
<Typography variant="h5" component="h1">
<Typography variant="h5" component="h1" sx={{ flexShrink: 0, whiteSpace: "nowrap" }}>
Vendor POs
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
{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,
}}
/>
</Stack>
<Stack
@ -134,9 +143,13 @@ export default function VendorPosListPage() {
spacing={2}
sx={{
flexWrap: "wrap",
alignItems: "flex-start",
}}
>
<FormControl size="small" className="min-w-[140px]">
<FormControl
size="small"
sx={{ width: { xs: "100%", sm: 160 }, minWidth: 140, flexShrink: 0 }}
>
<InputLabel>Status</InputLabel>
<Select
label="Status"
@ -154,7 +167,10 @@ export default function VendorPosListPage() {
</Select>
</FormControl>
<FormControl size="small" className="min-w-[180px]">
<FormControl
size="small"
sx={{ width: { xs: "100%", sm: 240 }, minWidth: 180, flexShrink: 0 }}
>
<InputLabel>Vendor</InputLabel>
<Select
label="Vendor"
@ -183,6 +199,7 @@ export default function VendorPosListPage() {
setPage(0);
}}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
/>
<TextField
size="small"
@ -194,8 +211,13 @@ export default function VendorPosListPage() {
setPage(0);
}}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
/>
<Button variant="text" onClick={clearFilters}>
<Button
variant="text"
onClick={clearFilters}
sx={{ minWidth: "max-content", flexShrink: 0 }}
>
Clear filters
</Button>
</Stack>

View file

@ -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 (
<NavLink to={path} end={end} className="no-underline" title={collapsed ? label : undefined}>
{({ isActive }) => {
const isHover = hovered && !isActive;
const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover);
return (
<Link
to={path}
aria-current={active ? "page" : undefined}
className="no-underline"
title={collapsed ? label : undefined}
>
<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
onMouseEnter={() => 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 && (
/>
)}
<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={{
position: "absolute",
left: 0,
top: 8,
bottom: 8,
width: 3,
borderRadius: "0 3px 3px 0",
backgroundColor: "var(--primary)",
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>
)}
<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>
);
}}
</NavLink>
</>
)}
</Box>
</Link>
);
}
@ -122,6 +122,8 @@ type AppSidebarProps = {
export function AppSidebar({ collapsed }: AppSidebarProps) {
const width = getSidebarWidth(collapsed);
const { pathname } = useLocation();
const activePath = getActiveNavPath(pathname);
return (
<Drawer
@ -195,7 +197,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
path={path}
badge={badge}
collapsed={collapsed}
end={path === "/"}
active={path === activePath}
/>
</Box>
))}

View file

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

View file

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