mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 21:42:06 +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) {
|
async function authenticate(page: Page) {
|
||||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
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.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 ({
|
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -102,15 +102,20 @@ export default function VendorPosListPage() {
|
||||||
spacing={2}
|
spacing={2}
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: "baseline",
|
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
|
Vendor POs
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{totalCount} total
|
{totalCount} total
|
||||||
|
|
@ -126,7 +131,11 @@ export default function VendorPosListPage() {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
placeholder="Search PO #, dispatch, vendor, WO..."
|
placeholder="Search PO #, dispatch, vendor, WO..."
|
||||||
className="min-w-[280px]"
|
sx={{
|
||||||
|
width: { xs: "100%", sm: 360 },
|
||||||
|
minWidth: 280,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack
|
<Stack
|
||||||
|
|
@ -134,9 +143,13 @@ export default function VendorPosListPage() {
|
||||||
spacing={2}
|
spacing={2}
|
||||||
sx={{
|
sx={{
|
||||||
flexWrap: "wrap",
|
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>
|
<InputLabel>Status</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
label="Status"
|
label="Status"
|
||||||
|
|
@ -154,7 +167,10 @@ export default function VendorPosListPage() {
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl size="small" className="min-w-[180px]">
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
sx={{ width: { xs: "100%", sm: 240 }, minWidth: 180, flexShrink: 0 }}
|
||||||
|
>
|
||||||
<InputLabel>Vendor</InputLabel>
|
<InputLabel>Vendor</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
label="Vendor"
|
label="Vendor"
|
||||||
|
|
@ -183,6 +199,7 @@ export default function VendorPosListPage() {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -194,8 +211,13 @@ export default function VendorPosListPage() {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
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
|
Clear filters
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { NavLink } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { Box, Drawer } from "@mui/material";
|
import { Box, Drawer } from "@mui/material";
|
||||||
|
|
||||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
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) {
|
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
|
@ -31,88 +31,88 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
|
|
||||||
type NavItemProps = NavItemDef & {
|
type NavItemProps = NavItemDef & {
|
||||||
collapsed: boolean;
|
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 [hovered, setHovered] = useState(false);
|
||||||
|
const isHover = hovered && !active;
|
||||||
|
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink to={path} end={end} className="no-underline" title={collapsed ? label : undefined}>
|
<Link
|
||||||
{({ isActive }) => {
|
to={path}
|
||||||
const isHover = hovered && !isActive;
|
aria-current={active ? "page" : undefined}
|
||||||
const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover);
|
className="no-underline"
|
||||||
|
title={collapsed ? label : undefined}
|
||||||
return (
|
>
|
||||||
|
<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
|
<Box
|
||||||
onMouseEnter={() => setHovered(true)}
|
component="span"
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "absolute",
|
||||||
display: "flex",
|
left: 0,
|
||||||
alignItems: "center",
|
top: 8,
|
||||||
width: "100%",
|
bottom: 8,
|
||||||
height: 40,
|
width: 3,
|
||||||
gap: "12px",
|
borderRadius: "0 3px 3px 0",
|
||||||
px: collapsed ? 0 : "12px",
|
backgroundColor: "var(--primary)",
|
||||||
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",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{isActive && (
|
)}
|
||||||
|
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<Box className="truncate flex-1">{label}</Box>
|
||||||
|
{badge != null && (
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
|
className="inline-flex shrink-0 items-center justify-center"
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
height: 18,
|
||||||
left: 0,
|
minWidth: 18,
|
||||||
top: 8,
|
px: "6px",
|
||||||
bottom: 8,
|
borderRadius: "9999px",
|
||||||
width: 3,
|
backgroundColor: "var(--accent)",
|
||||||
borderRadius: "0 3px 3px 0",
|
color: "#ffffff",
|
||||||
backgroundColor: "var(--primary)",
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{badge}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
|
</>
|
||||||
{!collapsed && (
|
)}
|
||||||
<>
|
</Box>
|
||||||
<Box className="truncate flex-1">{label}</Box>
|
</Link>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +122,8 @@ type AppSidebarProps = {
|
||||||
|
|
||||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||||
const width = getSidebarWidth(collapsed);
|
const width = getSidebarWidth(collapsed);
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const activePath = getActiveNavPath(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
@ -195,7 +197,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||||
path={path}
|
path={path}
|
||||||
badge={badge}
|
badge={badge}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
end={path === "/"}
|
active={path === activePath}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,13 @@ export const navGroups: NavGroup[] = [
|
||||||
// items: [{ label: "Documents", icon: FileText, path: "/documents" }],
|
// 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