mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 14:23:11 +00:00
fix(vendors): make navigation and PO filters responsive
This commit is contained in:
parent
95cbfff301
commit
e6a3899f4c
5 changed files with 190 additions and 77 deletions
59
e2e/vendors/vendor-operations.spec.ts
vendored
59
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
20
src/test/config/menu.test.ts
Normal file
20
src/test/config/menu.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue