Merge branch 'dev' into feat/sh-98-portal-lifecycle-recovered

This commit is contained in:
Alexandre Brandizzi 2026-08-11 08:53:28 -03:00 • committed by GitHub
commit bd12351c87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 427 additions and 16 deletions

View file

@ -41,3 +41,25 @@ jobs:
cache: npm
- run: npm ci
- run: npm run verify
vendor-visual-regression:
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: vendor-visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14

1
.gitignore vendored
View file

@ -10,6 +10,7 @@
/coverage
/test-results
/playwright-report
/playwright-report-visual
/blob-report
/playwright/.cache

View file

@ -2,6 +2,7 @@ dist
build
node_modules
coverage
playwright-report-visual
package-lock.json
*.tsbuildinfo
@ -10,4 +11,4 @@ src/pages/**
src/**/*.js
src/**/*.jsx
src/**/*.css
!src/styles/globals.css
!src/styles/globals.css

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -556,6 +556,20 @@ test.describe("Vendor directory prototype parity", () => {
await mockVendorApi(page, { listError: true });
await page.goto("/vendors");
const mainBox = await page.getByRole("main").boundingBox();
expect(mainBox).toMatchObject({ x: 0, width: 390 });
const navigation = page.getByRole("navigation", { name: "Primary navigation" });
await expect(navigation).not.toBeVisible();
const openNavigation = page.getByRole("button", { name: "Toggle navigation" });
await openNavigation.focus();
await page.keyboard.press("Enter");
await expect(navigation).toBeVisible();
await expect(page.getByRole("link", { name: "Sites" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(navigation).not.toBeVisible();
await expect(openNavigation).toBeFocused();
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
@ -569,4 +583,23 @@ test.describe("Vendor directory prototype parity", () => {
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
await page.getByRole("button", { name: "Close filters" }).click();
});
test("keeps protected shell destinations available and marks their selected state", async ({
page,
}) => {
await mockVendorApi(page);
for (const destination of [
{ path: "/workorders", label: "Work Orders" },
{ path: "/accounts", label: "Accounts" },
{ path: "/locations", label: "Sites" },
{ path: "/notifications", label: "Notifications" },
]) {
await page.goto(destination.path);
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
"aria-current",
"page",
);
}
});
});

261
e2e/vendors/vendors.visual.spec.ts vendored Normal file
View file

@ -0,0 +1,261 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const TEST_USER = {
token: "vendor-visual-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Vendor Visual Admin",
id: "1",
};
const activeVendors = [
{
Id: 1,
CompanyId: 101,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
Email: "dispatch@gateway.test",
Phone: "314-555-0198",
CompanyPhone: "314-555-0100",
Address: "1 Market St",
City: "St. Louis",
State: "MO",
Zip: "63101",
TradeSpecialties: "Plumbing, Backflow Preventers",
GoogleMapsUrl: "https://maps.google.com/gateway",
Notes: "Preferred after-hours vendor",
TotalJobs: 72,
IsActive: true,
PreferredContact: "Email",
},
{
Id: 2,
CompanyId: 102,
CompanyName: "Metro HVAC",
ContactName: "Maya Chen",
Email: "maya@metro.test",
Phone: "314-555-0112",
CompanyPhone: "314-555-0110",
Address: "2 Pine St",
City: "Clayton",
State: "MO",
Zip: "63105",
TradeSpecialties: "HVAC",
GoogleMapsUrl: "",
Notes: "",
TotalJobs: 18,
IsActive: true,
},
];
const inactiveVendors = [
{
...activeVendors[0],
Id: 3,
CompanyId: 103,
CompanyName: "Drain Cleaning Bros",
ContactName: "Drew Banks",
TotalJobs: 151,
IsActive: false,
},
];
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "default") {
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (!url.pathname.startsWith("/api/")) {
await route.continue();
return;
}
if (url.pathname.includes("/DropdownOptions/ByCategory/Trade")) {
await fulfillJson(route, {
data: [
{ id: 1, value: "Plumbing" },
{ id: 2, value: "HVAC" },
],
});
return;
}
if (url.pathname.endsWith("/api/Vendor/facets")) {
await fulfillJson(route, {
data: {
companies: activeVendors.map((vendor) => ({
companyId: vendor.CompanyId,
name: vendor.CompanyName,
})),
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
locations: [
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
],
jobBuckets: [
{ id: "under-50", label: "Under 50" },
{ id: "50-99", label: "50–99" },
{ id: "100-149", label: "100–149" },
{ id: "150-plus", label: "150+" },
],
},
});
return;
}
if (url.pathname.endsWith("/api/Vendor/GetVendorList")) {
const isCount = url.searchParams.get("pageSize") === "1";
if (mode === "error" && !isCount) {
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
return;
}
const source =
mode === "empty"
? []
: url.searchParams.get("isActive") === "false"
? inactiveVendors
: activeVendors;
await fulfillJson(route, {
Data: isCount ? source.slice(0, 1) : source,
TotalCount: source.length,
PageNumber: 1,
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
});
return;
}
if (url.pathname.startsWith("/api/vendor-company-roster")) {
await fulfillJson(route, {
companyId: 101,
rowVersion: "rv-visual",
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "https://maps.google.com/gateway",
notes: "Preferred after-hours vendor",
technicians: [
{
id: 1,
contactName: "Adam Whyte",
phone: "314-555-0198",
email: "dispatch@gateway.test",
preferredContact: "Email",
tradeSpecialties: "Plumbing, Backflow Preventers",
isActive: true,
totalJobs: 72,
},
],
});
return;
}
await fulfillJson(route, { data: {} });
});
}
async function openVendorPage(page: Page, mode: "default" | "empty" | "error" = "default") {
await mockVendorApi(page, mode);
await page.goto("/vendors");
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({
content: `
*, *::before, *::after { transition: none !important; animation: none !important; }
.MuiDrawer-paper { transform: none !important; }
.Toastify__toast-container { display: none !important; }
.tsqd-parent-container { display: none !important; }
`,
});
}
async function expectStableScreenshot(page: Page, name: string) {
await page.waitForTimeout(250);
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await page.screenshot({ animations: "disabled", caret: "hide" });
await expect(page).toHaveScreenshot(name);
}
test.describe("Vendor deterministic pixel regression", () => {
test("list", async ({ page }) => {
await openVendorPage(page);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expectStableScreenshot(page, "vendor-list.png");
});
test("filter", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Open vendor filters" }).click();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await expectStableScreenshot(page, "vendor-filter.png");
});
test("add", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
await expectStableScreenshot(page, "vendor-add.png");
});
test("detail", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
await expect(page.getByRole("button", { name: "Close drawer" })).toBeVisible();
await expectStableScreenshot(page, "vendor-detail.png");
});
test("edit", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible();
await expectStableScreenshot(page, "vendor-edit.png");
});
test("inactive", async ({ page }) => {
await openVendorPage(page);
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expectStableScreenshot(page, "vendor-inactive.png");
});
test("empty", async ({ page }) => {
await openVendorPage(page, "empty");
await expect(page.getByText("No active technicians found")).toBeVisible();
await expectStableScreenshot(page, "vendor-empty.png");
});
test("error", async ({ page }) => {
await openVendorPage(page, "error");
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/server error|vendor directory unavailable/i,
);
await expectStableScreenshot(page, "vendor-error.png");
});
test("mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openVendorPage(page);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expectStableScreenshot(page, "vendor-mobile.png");
await page.getByRole("button", { name: "Toggle navigation" }).click();
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
await expectStableScreenshot(page, "vendor-mobile-navigation.png");
});
});

View file

@ -10,6 +10,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",

View file

@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testIgnore: "**/*.visual.spec.ts",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,

View file

@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.visual.spec.ts",
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]],
outputDir: "test-results/visual",
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
// Keep the changed-pixel budget strict while ignoring sub-perceptual
// antialiasing differences between local and hosted container runtimes.
maxDiffPixels: 5,
threshold: 0.1,
},
},
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:4174",
colorScheme: "light",
deviceScaleFactor: 1,
locale: "en-US",
launchOptions: {
args: ["--disable-gpu", "--font-render-hinting=none"],
},
reducedMotion: "reduce",
timezoneId: "UTC",
trace: "retain-on-failure",
viewport: { width: 1440, height: 1000 },
},
webServer: {
command: "npm run dev -- --port 4174 --strictPort",
url: "http://localhost:4174",
reuseExistingServer: false,
timeout: 120_000,
},
});

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { Box, useMediaQuery, useTheme } from "@mui/material";
import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
export default function AdminShellLayout() {
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
const [navCollapsed, setNavCollapsed] = useState(false);
const sidebarWidth = getSidebarWidth(navCollapsed);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const sidebarWidth = isDesktop ? getSidebarWidth(navCollapsed) : 0;
const handleToggleNav = () => {
if (isDesktop) {
setNavCollapsed((value) => !value);
return;
}
setMobileNavOpen((value) => !value);
};
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar collapsed={navCollapsed} />
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"

View file

@ -41,6 +41,8 @@ export function VendorsListToolbar({
onChange={onSearchChange}
placeholder="Search name, company, trade…"
aria-label="Search vendors"
fullWidth
className="w-full md:w-56"
inputClassName="h-8 py-0 text-[13px]"
/>
<ToolbarButton
@ -49,7 +51,7 @@ export function VendorsListToolbar({
onClick={onOpenFilters}
aria-label="Open vendor filters"
badge={appliedFilterCount}
className="w-[88.609375px] text-[13px]"
className="min-w-[88.609375px] flex-1 text-[13px] md:w-[88.609375px] md:flex-none"
>
Filters
</ToolbarButton>
@ -57,7 +59,7 @@ export function VendorsListToolbar({
variant="primary"
icon={<Plus size={16} />}
onClick={onAddVendor}
className="w-[115.546875px] border-0 px-3 text-[13px]"
className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none"
>
Add Vendor
</ToolbarButton>

View file

@ -32,9 +32,10 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
type NavItemProps = NavItemDef & {
collapsed: boolean;
active: boolean;
onNavigate?: () => void;
};
function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) {
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);
@ -45,6 +46,7 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
aria-current={active ? "page" : undefined}
className="no-underline"
title={collapsed ? label : undefined}
onClick={onNavigate}
>
<Box
onMouseEnter={() => setHovered(true)}
@ -118,18 +120,24 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
type AppSidebarProps = {
collapsed: boolean;
mobile?: boolean;
open?: boolean;
onClose?: () => void;
};
export function AppSidebar({ collapsed }: AppSidebarProps) {
export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) {
const width = getSidebarWidth(collapsed);
const { pathname } = useLocation();
const activePath = getActiveNavPath(pathname);
return (
<Drawer
variant="permanent"
variant={mobile ? "temporary" : "permanent"}
open={open}
onClose={onClose}
ModalProps={{ keepMounted: true }}
sx={{
width,
width: mobile ? 0 : width,
flexShrink: 0,
"& .MuiDrawer-paper": {
width,
@ -157,7 +165,13 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
</Box>
<Box component="nav" className="flex-1 overflow-y-auto overflow-x-hidden" sx={{ py: 1 }}>
<Box
component="nav"
id="primary-navigation"
aria-label="Primary navigation"
className="flex-1 overflow-y-auto overflow-x-hidden"
sx={{ py: 1 }}
>
{navGroups.map((group, groupIndex) => (
<Box key={group.title || `group-${groupIndex}`}>
{collapsed ? (
@ -198,6 +212,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
badge={badge}
collapsed={collapsed}
active={path === activePath}
onNavigate={mobile ? onClose : undefined}
/>
</Box>
))}

View file

@ -24,15 +24,16 @@ export function PageHeader({
}: PageHeaderProps) {
return (
<Stack
direction="row"
direction={{ xs: "column", md: "row" }}
className={cn(
surface &&
"-mx-2.5 -mt-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface) px-5 py-4",
className,
)}
sx={{
alignItems: "center",
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
gap: { xs: 2, md: 0 },
}}
>
<Stack spacing={0}>
@ -64,7 +65,16 @@ export function PageHeader({
</Text>
</Stack>
{Boolean(actions) && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
<Stack
direction="row"
spacing={1}
useFlexGap
sx={{
alignItems: "center",
flexWrap: { xs: "wrap", md: "nowrap" },
width: { xs: "100%", md: "auto" },
}}
>
{actions}
</Stack>
)}

View file

@ -6,6 +6,7 @@ import {
ClipboardList,
FileText,
LayoutDashboard,
MapPin,
Bell,
BarChart3,
Star,
@ -52,7 +53,10 @@ export const navGroups: NavGroup[] = [
},
{
title: "CUSTOMERS & SITES",
items: [{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" }],
items: [
{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" },
{ label: "Sites", icon: MapPin, path: "/locations" },
],
},
// {
// title: "INTERNAL",