mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
* fix(vendors): add accessible trade overflow tooltip * fix(vendors): add accessible trade overflow tooltip * refactor(vendors): satisfy row maintainability gate * feat(vendors): group directory rows by company * chore(vendors): trigger SH-281 review checks * test(vendors): align company directory visuals * test(vendors): complete company visual baselines * fix(vendors): keep empty states in viewport * test(vendors): align centered empty baseline * fix(vendors): use grouped directory endpoint * test(vendors): follow grouped directory endpoint * test(vendors): refresh combined visual snapshots --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
984 lines
40 KiB
TypeScript
984 lines
40 KiB
TypeScript
import { expect, test, type Page, type Route } from "@playwright/test";
|
||
|
||
const TEST_USER = {
|
||
token: "vendor-e2e-token",
|
||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||
email: "admin@seahavenind.com",
|
||
userRoles: "Admin",
|
||
phoneNumber: "5551234567",
|
||
fullname: "Vendor E2E 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",
|
||
AreaId: 1 as number | null,
|
||
AreaName: "East",
|
||
},
|
||
{
|
||
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,
|
||
AreaId: null as number | null,
|
||
AreaName: "",
|
||
},
|
||
];
|
||
|
||
const SERVICE_AREAS = [
|
||
{ id: 2, name: "Central" },
|
||
{ id: 1, name: "East" },
|
||
];
|
||
|
||
const inactiveVendors = [
|
||
{
|
||
...activeVendors[0],
|
||
Id: 3,
|
||
CompanyName: "Drain Cleaning Bros",
|
||
ContactName: "Drew Banks",
|
||
TotalJobs: 151,
|
||
IsActive: false,
|
||
},
|
||
];
|
||
|
||
const LONG_TEXT_VENDOR = {
|
||
CompanyName: "Gateway Plumbing and Water Systems of Greater Saint Louis",
|
||
ContactName: "Adam Christopher Whyte Senior Field Technician",
|
||
Email: "dispatch-and-after-hours-coordination@gatewayplumbing.example",
|
||
Phone: "+1 (314) 555-0198 extension 123456",
|
||
Address: "New York, NY, Estados Unidos, 123 Long Address Avenue",
|
||
City: "",
|
||
State: "",
|
||
};
|
||
|
||
interface MockState {
|
||
listUrls: string[];
|
||
createdBody?: Record<string, unknown>;
|
||
updatedBody?: Record<string, unknown>;
|
||
patchedBody?: Record<string, unknown>;
|
||
patchedCompanyId?: string;
|
||
deletedId?: string;
|
||
deleteConfirmedOpenWorkOrders?: boolean;
|
||
}
|
||
|
||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||
}
|
||
|
||
async function captureVisual(page: Page, path: string) {
|
||
if (!process.env.VENDOR_VISUAL_CAPTURE) return;
|
||
await page.waitForTimeout(400);
|
||
await page.screenshot({ path, fullPage: true });
|
||
}
|
||
|
||
async function mockVendorApi(
|
||
page: Page,
|
||
options: {
|
||
listError?: boolean;
|
||
deactivationBlocked?: boolean;
|
||
deleteConflict?: boolean;
|
||
groupedDirectory?: boolean;
|
||
legacyGateway?: boolean;
|
||
longText?: boolean;
|
||
} = {},
|
||
) {
|
||
const state: MockState = { listUrls: [] };
|
||
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
|
||
if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_VENDOR);
|
||
if (options.legacyGateway) {
|
||
vendorRecords[0].Phone = "555-1234 ext 9";
|
||
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
|
||
}
|
||
|
||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||
|
||
await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) =>
|
||
fulfillJson(route, {
|
||
data: [
|
||
{ id: 1, value: "Plumbing" },
|
||
{ id: 2, value: "HVAC" },
|
||
],
|
||
}),
|
||
);
|
||
await page.route("**/api/Vendor/facets*", (route) =>
|
||
fulfillJson(route, {
|
||
data: {
|
||
companies: activeVendors.map((vendor) => ({
|
||
companyId: vendor.CompanyId,
|
||
name: vendor.CompanyName,
|
||
companyPhone: vendor.CompanyPhone,
|
||
email: vendor.Email,
|
||
address: vendor.Address,
|
||
city: vendor.City,
|
||
state: vendor.State,
|
||
zip: vendor.Zip,
|
||
googleMapsUrl: vendor.GoogleMapsUrl,
|
||
})),
|
||
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
|
||
areas: SERVICE_AREAS,
|
||
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+" },
|
||
],
|
||
},
|
||
}),
|
||
);
|
||
await page.route("**/api/Vendor/GetVendorDirectoryList*", async (route) => {
|
||
const url = new URL(route.request().url());
|
||
state.listUrls.push(url.toString());
|
||
if (options.listError && url.searchParams.get("pageSize") !== "1") {
|
||
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
|
||
return;
|
||
}
|
||
|
||
const isActive = url.searchParams.get("isActive") !== "false";
|
||
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
|
||
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
||
const company = url.searchParams.get("companies[0]");
|
||
const areas = [...url.searchParams.entries()]
|
||
.filter(([key]) => key.startsWith("areas["))
|
||
.map(([, value]) => value);
|
||
const filtered = source.filter(
|
||
(vendor) =>
|
||
(!search ||
|
||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
|
||
.toLowerCase()
|
||
.includes(search)) &&
|
||
(!company || vendor.CompanyName === company) &&
|
||
(areas.length === 0 ||
|
||
areas.some((area) =>
|
||
area === "__unassigned__" ? vendor.AreaId == null : String(vendor.AreaId) === area,
|
||
)),
|
||
);
|
||
const isCount = url.searchParams.get("pageSize") === "1";
|
||
const responseRows = filtered.map((vendor) => {
|
||
if (!options.groupedDirectory) return vendor;
|
||
const technicians = [
|
||
{
|
||
Id: vendor.Id,
|
||
ContactName: vendor.ContactName,
|
||
Email: vendor.Email,
|
||
Phone: vendor.Phone,
|
||
TradeSpecialties: vendor.TradeSpecialties,
|
||
IsActive: vendor.IsActive,
|
||
TotalJobs: vendor.TotalJobs,
|
||
},
|
||
];
|
||
if (vendor.Id === 1) {
|
||
technicians.push({
|
||
Id: 91,
|
||
ContactName: "Robin Rivers",
|
||
Email: "robin@gateway.test",
|
||
Phone: "314-555-0191",
|
||
TradeSpecialties: "Drain Cleaning",
|
||
IsActive: true,
|
||
TotalJobs: 18,
|
||
});
|
||
}
|
||
return {
|
||
...vendor,
|
||
TotalJobs: technicians.reduce((sum, item) => sum + item.TotalJobs, 0),
|
||
Technicians: technicians,
|
||
};
|
||
});
|
||
await fulfillJson(route, {
|
||
Data: isCount ? responseRows.slice(0, 1) : responseRows,
|
||
TotalCount: filtered.length,
|
||
PageNumber: 1,
|
||
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||
});
|
||
});
|
||
await page.route(/\/api\/vendor-company-roster(?:\/\d+)?(?:\?.*)?$/, async (route) => {
|
||
const request = route.request();
|
||
const url = new URL(request.url());
|
||
const pathCompanyId = url.pathname.match(/vendor-company-roster\/(\d+)$/)?.[1];
|
||
|
||
if (request.method() === "POST") {
|
||
state.createdBody = request.postDataJSON();
|
||
await fulfillJson(route, {
|
||
...state.createdBody,
|
||
companyId: 999,
|
||
rowVersion: "rv-created",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (request.method() === "PATCH" && pathCompanyId) {
|
||
state.patchedBody = request.postDataJSON();
|
||
state.patchedCompanyId = pathCompanyId;
|
||
const anchor = vendorRecords.find((vendor) => String(vendor.CompanyId) === pathCompanyId);
|
||
if (!anchor) {
|
||
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
|
||
return;
|
||
}
|
||
const added = Array.isArray(state.patchedBody.addTechnicians)
|
||
? (state.patchedBody.addTechnicians as Array<Record<string, unknown>>)
|
||
: [];
|
||
await fulfillJson(route, {
|
||
companyId: anchor.CompanyId,
|
||
rowVersion: "rv-patched",
|
||
name: anchor.CompanyName,
|
||
companyPhone: anchor.CompanyPhone,
|
||
email: anchor.Email,
|
||
address: anchor.Address,
|
||
city: anchor.City,
|
||
state: anchor.State,
|
||
zip: anchor.Zip,
|
||
googleMapsUrl: anchor.GoogleMapsUrl,
|
||
notes: anchor.Notes,
|
||
areaId: anchor.AreaId,
|
||
areaName: anchor.AreaName,
|
||
technicians: [
|
||
...vendorRecords
|
||
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
|
||
.map((vendor) => ({
|
||
id: vendor.Id,
|
||
contactName: vendor.ContactName,
|
||
phone: vendor.Phone,
|
||
email: vendor.Email,
|
||
preferredContact: vendor.PreferredContact ?? "Phone",
|
||
tradeSpecialties: vendor.TradeSpecialties,
|
||
isActive: vendor.IsActive,
|
||
totalJobs: vendor.TotalJobs,
|
||
})),
|
||
...added.map((technician, index) => ({
|
||
id: 900 + index,
|
||
totalJobs: 0,
|
||
...technician,
|
||
})),
|
||
],
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (request.method() === "PUT" && pathCompanyId) {
|
||
state.updatedBody = request.postDataJSON();
|
||
const technicians = Array.isArray(state.updatedBody.technicians)
|
||
? (state.updatedBody.technicians as Array<Record<string, unknown>>)
|
||
: [];
|
||
for (const technician of technicians) {
|
||
const vendor = vendorRecords.find((item) => String(item.Id) === String(technician.id));
|
||
if (vendor && typeof technician.isActive === "boolean") {
|
||
vendor.IsActive = technician.isActive;
|
||
}
|
||
}
|
||
await fulfillJson(route, {
|
||
...state.updatedBody,
|
||
companyId: Number(pathCompanyId),
|
||
rowVersion: "rv-updated",
|
||
});
|
||
return;
|
||
}
|
||
|
||
const vendorId = url.searchParams.get("vendorId");
|
||
const requestedCompanyId = url.searchParams.get("companyId") ?? pathCompanyId;
|
||
const anchor = vendorId
|
||
? vendorRecords.find((vendor) => String(vendor.Id) === vendorId)
|
||
: vendorRecords.find((vendor) => String(vendor.CompanyId) === requestedCompanyId);
|
||
if (!anchor) {
|
||
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
|
||
return;
|
||
}
|
||
await fulfillJson(route, {
|
||
companyId: anchor.CompanyId,
|
||
rowVersion: "rv-1",
|
||
name: anchor.CompanyName,
|
||
companyPhone: anchor.CompanyPhone,
|
||
email: anchor.Email,
|
||
address: anchor.Address,
|
||
city: anchor.City,
|
||
state: anchor.State,
|
||
zip: anchor.Zip,
|
||
googleMapsUrl: anchor.GoogleMapsUrl,
|
||
notes: anchor.Notes,
|
||
areaId: anchor.AreaId,
|
||
areaName: anchor.AreaName,
|
||
technicians: vendorRecords
|
||
.filter((vendor) => vendor.CompanyId === anchor.CompanyId)
|
||
.map((vendor) => ({
|
||
id: vendor.Id,
|
||
contactName: vendor.ContactName,
|
||
phone: vendor.Phone,
|
||
email: vendor.Email,
|
||
preferredContact: vendor.PreferredContact ?? "Phone",
|
||
tradeSpecialties: vendor.TradeSpecialties,
|
||
isActive: vendor.IsActive,
|
||
totalJobs: vendor.TotalJobs,
|
||
})),
|
||
});
|
||
});
|
||
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
|
||
const vendorId = Number(route.request().url().split("/").at(-2));
|
||
await fulfillJson(route, {
|
||
data: {
|
||
vendorId,
|
||
canDeactivate: !options.deactivationBlocked,
|
||
openWorkOrders: options.deactivationBlocked
|
||
? [
|
||
{
|
||
workOrderId: 501,
|
||
workOrderNumber: "WO-501",
|
||
workOrderTitle: "Emergency boiler repair",
|
||
status: "Dispatched",
|
||
scheduledDate: "2026-07-24T14:00:00Z",
|
||
dispatchId: 801,
|
||
},
|
||
]
|
||
: [],
|
||
},
|
||
});
|
||
});
|
||
await page.route(/\/api\/vendors\/\d+\/portal-token$/, (route) =>
|
||
fulfillJson(route, {
|
||
data: {
|
||
portalUrl: "https://vendor.example.test/token",
|
||
issuedAt: "2026-07-31T12:00:00Z",
|
||
expiresAt: "2026-08-07T12:00:00Z",
|
||
},
|
||
}),
|
||
);
|
||
await page.route(/\/api\/vendors\/\d+(\?.*)?$/, async (route) => {
|
||
const requestUrl = new URL(route.request().url());
|
||
const id = requestUrl.pathname.split("/").pop() ?? "";
|
||
if (route.request().method() === "PUT") {
|
||
state.updatedBody = route.request().postDataJSON();
|
||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||
if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") {
|
||
vendor.IsActive = state.updatedBody.isActive;
|
||
}
|
||
await fulfillJson(route, {
|
||
data: { ...vendor, ...state.updatedBody, Id: Number(id) },
|
||
});
|
||
return;
|
||
}
|
||
if (route.request().method() === "DELETE") {
|
||
state.deleteConfirmedOpenWorkOrders =
|
||
requestUrl.searchParams.get("confirmOpenWorkOrders") === "true";
|
||
if (options.deleteConflict) {
|
||
await fulfillJson(
|
||
route,
|
||
{ message: "Vendor has open work orders and cannot be deactivated." },
|
||
409,
|
||
);
|
||
return;
|
||
}
|
||
state.deletedId = id;
|
||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||
if (options.legacyGateway && vendor) vendor.IsActive = false;
|
||
await fulfillJson(route, { status: "200" });
|
||
return;
|
||
}
|
||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||
await fulfillJson(route, { data: vendor });
|
||
});
|
||
await page.route(/\/api\/vendors\/?$/, async (route) => {
|
||
if (route.request().method() !== "POST") {
|
||
await route.fallback();
|
||
return;
|
||
}
|
||
state.createdBody = route.request().postDataJSON();
|
||
await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } });
|
||
});
|
||
|
||
return state;
|
||
}
|
||
|
||
test.describe("Vendor directory prototype parity", () => {
|
||
test("renders company-owned columns and the grouped technician roster", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||
await mockVendorApi(page, { groupedDirectory: true });
|
||
await page.goto("/vendors");
|
||
|
||
const expectedHeaders = [
|
||
"Company",
|
||
"Company Phone",
|
||
"Company Email",
|
||
"Street Address",
|
||
"City",
|
||
"State",
|
||
"Service Area",
|
||
"Trade",
|
||
"Technician",
|
||
"Technician Phone",
|
||
"Total Jobs",
|
||
"Status",
|
||
];
|
||
await expect(page.getByRole("columnheader")).toHaveCount(expectedHeaders.length + 1);
|
||
for (const header of expectedHeaders) {
|
||
await expect(page.getByRole("columnheader", { name: header, exact: true })).toBeVisible();
|
||
}
|
||
|
||
const gatewayRow = page.getByRole("button", {
|
||
name: "Open vendor details for Gateway Plumbing",
|
||
});
|
||
await expect(gatewayRow).toContainText("Adam Whyte");
|
||
await expect(gatewayRow).toContainText("90");
|
||
const rosterBadge = gatewayRow.getByTestId("vendor-technician-badge");
|
||
await expect(rosterBadge).toHaveText("+1");
|
||
await rosterBadge.hover();
|
||
await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers");
|
||
await rosterBadge.focus();
|
||
await expect(rosterBadge).toBeFocused();
|
||
await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers");
|
||
});
|
||
|
||
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
||
page,
|
||
}) => {
|
||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||
const state = await mockVendorApi(page);
|
||
await page.goto("/vendors");
|
||
|
||
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
|
||
await expect(heading).toBeVisible();
|
||
await expect(heading).toHaveCSS("font-family", /Montserrat/);
|
||
await expect(heading).toHaveCSS("font-size", "30px");
|
||
await expect(heading).toHaveCSS("font-weight", "800");
|
||
await expect(heading).toHaveCSS("line-height", "33px");
|
||
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
|
||
await expect(page.getByText("2 active companies").first()).toBeVisible();
|
||
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||
"aria-selected",
|
||
"true",
|
||
);
|
||
for (const heading of [
|
||
"Company",
|
||
"Company Phone",
|
||
"Company Email",
|
||
"Street Address",
|
||
"City",
|
||
"State",
|
||
"Service Area",
|
||
"Trade",
|
||
"Technician",
|
||
"Technician Phone",
|
||
"Total Jobs",
|
||
]) {
|
||
await expect(page.getByRole("columnheader", { name: heading, exact: true })).toBeVisible();
|
||
}
|
||
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
||
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
|
||
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
|
||
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
|
||
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
|
||
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
|
||
expect((await page.locator("table").boundingBox())?.width).toBe(2200);
|
||
expect(
|
||
(await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox())
|
||
?.height,
|
||
).toBe(47);
|
||
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
|
||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||
const tradeBadge = page.getByTestId("vendor-trade-badge").first();
|
||
await expect(tradeBadge).toHaveText("+1");
|
||
await tradeBadge.hover();
|
||
await expect(page.getByRole("tooltip")).toHaveText("Backflow Preventers");
|
||
await tradeBadge.focus();
|
||
await expect(tradeBadge).toBeFocused();
|
||
await tradeBadge.click();
|
||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||
await tradeBadge.press("Enter");
|
||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||
await expect(page.getByText("2 active companies").last()).toBeVisible();
|
||
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
|
||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
||
|
||
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
|
||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||
await expect(page.getByText(/1 active company · filtered/)).toBeVisible();
|
||
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
||
true,
|
||
);
|
||
|
||
await page.getByPlaceholder("Search name, company, trade…").fill("clayton");
|
||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||
|
||
await page.getByPlaceholder("Search name, company, trade…").clear();
|
||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
||
await page.getByRole("button", { name: /Company/ }).click();
|
||
await page.getByPlaceholder("All companies").click();
|
||
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
||
await page.getByRole("button", { name: "Apply filters" }).click();
|
||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||
await expect(page.getByText("1 company · filtered by")).toBeVisible();
|
||
await expect(page.getByText(/1 active company · filtered/)).toBeVisible();
|
||
expect(
|
||
state.listUrls.some(
|
||
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
||
),
|
||
).toBe(true);
|
||
|
||
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||
await expect(page.getByText("No inactive companies found")).toBeVisible();
|
||
await page.getByRole("button", { name: "Clear all" }).click();
|
||
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||
});
|
||
|
||
test("truncates every non-abbreviated textual column", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||
await mockVendorApi(page, { longText: true });
|
||
await page.goto("/vendors");
|
||
|
||
const row = page.locator("tbody tr").first();
|
||
const fields = [
|
||
{ cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName },
|
||
{ cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email },
|
||
{ cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address },
|
||
{ cellIndex: 8, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName },
|
||
{ cellIndex: 9, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone },
|
||
];
|
||
|
||
for (const expected of fields) {
|
||
const cell = row.locator("td").nth(expected.cellIndex);
|
||
const field = cell.getByTestId(expected.testId);
|
||
await expect(field).toBeVisible();
|
||
|
||
const metrics = await field.evaluate((element) => {
|
||
const fieldRect = element.getBoundingClientRect();
|
||
const cellRect = element.closest("td")?.getBoundingClientRect();
|
||
return {
|
||
clientWidth: element.clientWidth,
|
||
scrollWidth: element.scrollWidth,
|
||
withinCell:
|
||
cellRect !== undefined &&
|
||
fieldRect.left >= cellRect.left - 1 &&
|
||
fieldRect.right <= cellRect.right + 1,
|
||
};
|
||
});
|
||
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth);
|
||
expect(metrics.withinCell).toBe(true);
|
||
|
||
await field.hover();
|
||
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toBeVisible();
|
||
await page.mouse.move(0, 0);
|
||
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toHaveCount(0);
|
||
}
|
||
});
|
||
|
||
test("adds a technician to an existing company, then views and edits the aggregate", async ({
|
||
page,
|
||
}) => {
|
||
const state = await mockVendorApi(page);
|
||
await page.goto("/vendors");
|
||
|
||
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
||
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
|
||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||
await expect(
|
||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||
).toBeEnabled();
|
||
|
||
const companyLabel = page
|
||
.getByRole("dialog", { name: /Add Vendor/ })
|
||
.locator("label")
|
||
.filter({ hasText: "Company (required)" });
|
||
await expect(companyLabel).toBeVisible();
|
||
const companyLabelMetrics = await companyLabel.evaluate((element) => {
|
||
const field = element.closest(".MuiFormControl-root");
|
||
const labelRect = element.getBoundingClientRect();
|
||
const fieldRect = field?.getBoundingClientRect();
|
||
return {
|
||
clientWidth: element.clientWidth,
|
||
scrollWidth: element.scrollWidth,
|
||
withinFieldGeometry: fieldRect
|
||
? labelRect.left >= fieldRect.left &&
|
||
labelRect.right <= fieldRect.right &&
|
||
labelRect.top >= fieldRect.top &&
|
||
labelRect.bottom <= fieldRect.bottom
|
||
: false,
|
||
};
|
||
});
|
||
expect(companyLabelMetrics.clientWidth).toBeGreaterThanOrEqual(companyLabelMetrics.scrollWidth);
|
||
expect(companyLabelMetrics.withinFieldGeometry).toBe(true);
|
||
|
||
await page.getByRole("combobox", { name: "Company (required)" }).click();
|
||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||
const floatedCompanyLabel = page
|
||
.getByRole("dialog", { name: /Add Vendor/ })
|
||
.locator("label")
|
||
.filter({ hasText: "Company (required)" });
|
||
const floatedCompanyField = floatedCompanyLabel.locator("..");
|
||
await expect(floatedCompanyLabel).toHaveText("Company (required)");
|
||
await expect(floatedCompanyField.locator("legend")).toHaveText("Company (required)");
|
||
const floatedMetrics = await floatedCompanyLabel.evaluate((element) => {
|
||
const labelRect = element.getBoundingClientRect();
|
||
const legendRect = element
|
||
.closest(".MuiFormControl-root")
|
||
?.querySelector("legend")
|
||
?.getBoundingClientRect();
|
||
return {
|
||
labelClientWidth: element.clientWidth,
|
||
labelScrollWidth: element.scrollWidth,
|
||
legendWidth: legendRect?.width ?? 0,
|
||
labelWidth: labelRect.width,
|
||
};
|
||
});
|
||
expect(floatedMetrics.labelClientWidth).toBeGreaterThanOrEqual(floatedMetrics.labelScrollWidth);
|
||
expect(floatedMetrics.legendWidth).toBeGreaterThanOrEqual(floatedMetrics.labelWidth);
|
||
|
||
// Scrolling focus down the form and back to Company must keep the floated label
|
||
// inside the dialog's visible scrollport.
|
||
const dialogContent = page
|
||
.getByRole("dialog", { name: /Add Vendor/ })
|
||
.locator(".MuiDialogContent-root");
|
||
await page.getByLabel("Technician name (optional)").click();
|
||
await expect
|
||
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
|
||
.toBeGreaterThan(0);
|
||
await page.getByRole("combobox", { name: "Company (required)" }).click();
|
||
await expect
|
||
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
|
||
.toBeLessThanOrEqual(1);
|
||
await expect(floatedCompanyLabel).toBeVisible();
|
||
const labelScrollportMetrics = await floatedCompanyLabel.evaluate((element) => {
|
||
const content = element.closest(".MuiDialogContent-root");
|
||
return {
|
||
labelTop: element.getBoundingClientRect().top,
|
||
labelBottom: element.getBoundingClientRect().bottom,
|
||
dialogTop: content?.getBoundingClientRect().top ?? Number.NaN,
|
||
dialogBottom: content?.getBoundingClientRect().bottom ?? Number.NaN,
|
||
overflowY: content ? getComputedStyle(content).overflowY : "",
|
||
};
|
||
});
|
||
expect(["auto", "scroll"]).toContain(labelScrollportMetrics.overflowY);
|
||
expect(labelScrollportMetrics.labelTop).toBeGreaterThanOrEqual(
|
||
labelScrollportMetrics.dialogTop - 1,
|
||
);
|
||
expect(labelScrollportMetrics.labelBottom).toBeLessThanOrEqual(
|
||
labelScrollportMetrics.dialogBottom + 1,
|
||
);
|
||
|
||
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
|
||
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
||
"dispatch@gateway.test",
|
||
);
|
||
// SH-271: one combined "Address (optional)" line became three required
|
||
// fields, and the hand-pasted Google Maps URL input is gone — the location
|
||
// is derived from the address now.
|
||
await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue(
|
||
"1 Market St",
|
||
);
|
||
await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis");
|
||
await expect(page.getByLabel("State (required)")).toHaveValue("Missouri (MO)");
|
||
await expect(page.getByLabel("Service Area (optional)")).toHaveValue("East");
|
||
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0);
|
||
await expect(page.getByLabel("Address (optional)")).toHaveCount(0);
|
||
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
||
await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1);
|
||
await expect(page.getByLabel("Technician name (optional)")).toHaveValue("");
|
||
await expect(
|
||
page.getByRole("dialog", { name: /Add Vendor/ }).getByText("Adam Whyte"),
|
||
).toHaveCount(0);
|
||
await page.getByRole("button", { name: "Add technician" }).click();
|
||
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
|
||
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
||
await tradeInput.fill("Plumbing");
|
||
await tradeInput.press("Enter");
|
||
await tradeInput.fill("HVAC");
|
||
await tradeInput.press("Enter");
|
||
await expect(page.getByRole("button", { name: /Move (Plumbing|HVAC) (up|down)/ })).toHaveCount(
|
||
0,
|
||
);
|
||
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||
expect(state.patchedCompanyId).toBe("101");
|
||
expect(state.patchedBody).toMatchObject({ rowVersion: "rv-1" });
|
||
// Only the field the user actually typed into is transmitted. Address/phone/email were
|
||
// populated by selecting the company and never edited, so they must not be sent — otherwise a
|
||
// concurrent edit by another user to any of them would be silently reverted on resubmit.
|
||
expect(state.patchedBody?.companyFields).toEqual({ notes: "Created in browser E2E" });
|
||
expect(state.patchedBody?.companyFields).not.toHaveProperty("companyPhone");
|
||
expect(state.patchedBody?.companyFields).not.toHaveProperty("address");
|
||
expect(state.patchedBody?.companyFields).not.toHaveProperty("email");
|
||
expect(state.patchedBody?.companyFields).not.toHaveProperty("name");
|
||
expect(state.patchedBody?.addTechnicians).toEqual([
|
||
expect.objectContaining({
|
||
contactName: "New Technician",
|
||
tradeSpecialties: "Plumbing, HVAC",
|
||
}),
|
||
]);
|
||
const patchedTechnicians = Array.isArray(state.patchedBody?.addTechnicians)
|
||
? (state.patchedBody.addTechnicians as Array<Record<string, unknown>>)
|
||
: [];
|
||
const newTechnician = patchedTechnicians.find(
|
||
(technician) => technician.contactName === "New Technician",
|
||
);
|
||
expect(newTechnician?.preferredContact).toBeUndefined();
|
||
expect(patchedTechnicians.some((technician) => technician.contactName === "Adam Whyte")).toBe(
|
||
false,
|
||
);
|
||
expect(state.updatedBody).toBeUndefined();
|
||
|
||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
|
||
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||
// SH-271: the separate "Open in Google Maps" row is gone — the Street
|
||
// Address itself is the link, built from the structured parts.
|
||
const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first();
|
||
await expect(streetLink).toHaveAttribute("target", "_blank");
|
||
await expect(streetLink).toHaveAttribute(
|
||
"href",
|
||
"https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO",
|
||
);
|
||
await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0);
|
||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
||
|
||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||
await expect(page).toHaveURL(/\/vendors$/);
|
||
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
|
||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||
});
|
||
|
||
test("filters by Service Area and lets an admin assign one", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||
const state = await mockVendorApi(page);
|
||
await page.goto("/vendors");
|
||
|
||
const gatewayRow = page.getByRole("button", {
|
||
name: "Open vendor details for Gateway Plumbing",
|
||
});
|
||
const metroRow = page.getByRole("button", { name: "Open vendor details for Metro HVAC" });
|
||
await expect(gatewayRow.getByTestId("vendor-text-service-area")).toHaveText("East");
|
||
await expect(metroRow.getByTestId("vendor-text-service-area")).toHaveText("Unassigned");
|
||
|
||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||
await page.getByRole("button", { name: "Service Area" }).click();
|
||
await page.getByPlaceholder("All areas").click();
|
||
await expect(page.getByRole("option")).toHaveText(["Unassigned", "Central", "East"]);
|
||
await page.getByRole("option", { name: "East" }).click();
|
||
await page.getByRole("button", { name: "Apply filters" }).click();
|
||
|
||
await expect(
|
||
page.getByRole("button", { name: "Remove filter Service Area: East" }),
|
||
).toBeVisible();
|
||
await expect(gatewayRow).toBeVisible();
|
||
await expect(metroRow).toHaveCount(0);
|
||
expect(state.listUrls.some((url) => new URL(url).searchParams.get("areas[0]") === "1")).toBe(
|
||
true,
|
||
);
|
||
|
||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||
await page.getByRole("button", { name: "Service Area" }).click();
|
||
await page.getByRole("button", { name: "Clear all" }).last().click();
|
||
await page.getByPlaceholder("All areas").click();
|
||
await page.getByRole("option", { name: "Unassigned" }).click();
|
||
await page.getByRole("button", { name: "Apply filters" }).click();
|
||
await expect(page.getByText("Service Area: Unassigned")).toBeVisible();
|
||
await expect(metroRow).toBeVisible();
|
||
await expect(gatewayRow).toHaveCount(0);
|
||
|
||
await page.getByRole("button", { name: "Edit vendor Metro HVAC" }).click();
|
||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||
const areaField = detailDrawer.getByRole("combobox", { name: "Service Area (optional)" });
|
||
await expect(areaField).toBeEnabled();
|
||
await areaField.click();
|
||
await page.getByRole("option", { name: "Central" }).click();
|
||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||
await expect.poll(() => state.updatedBody?.areaId).toBe(2);
|
||
expect(state.updatedBody?.state).toBe("MO");
|
||
});
|
||
|
||
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
|
||
const state = await mockVendorApi(page, { legacyGateway: true });
|
||
await page.goto("/vendors");
|
||
|
||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||
await page.getByRole("switch", { name: "Active status" }).click();
|
||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||
await expect.poll(() => state.deletedId).toBe("1");
|
||
|
||
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
|
||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
||
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
||
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||
});
|
||
|
||
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
||
const state = await mockVendorApi(page);
|
||
await page.goto("/vendors");
|
||
|
||
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
|
||
await addButton.focus();
|
||
await page.keyboard.press("Enter");
|
||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||
await expect(addButton).toBeFocused();
|
||
|
||
await addButton.click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||
).toBeEnabled();
|
||
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
|
||
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
||
// SH-271 made the structured address required. This test is about keyboard
|
||
// dismissal and free-text company creation, so the address is filled to
|
||
// leave those as the only things it is exercising.
|
||
await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St");
|
||
await page.getByLabel("City (required)").fill("St. Louis");
|
||
await page.getByLabel("State (required)").fill("Missouri");
|
||
await page.getByRole("option", { name: "Missouri (MO)" }).click();
|
||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||
|
||
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
||
expect(state.createdBody?.technicians).toEqual([]);
|
||
expect(state.createdBody?.state).toBe("MO");
|
||
expect(state.createdBody?.areaId).toBeNull();
|
||
});
|
||
|
||
test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => {
|
||
await mockVendorApi(page);
|
||
await page.goto("/vendors");
|
||
|
||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||
await expect(detailDrawer).toBeVisible();
|
||
await expect(detailDrawer.getByRole("button", { name: "Edit" })).toBeVisible();
|
||
await expect(detailDrawer.getByRole("button", { name: "Maps" })).toHaveCount(0);
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0);
|
||
});
|
||
|
||
test("confirms deactivation past linked work orders and preserves the vendor on a raced 409", async ({
|
||
page,
|
||
}) => {
|
||
const confirmState = await mockVendorApi(page, { deactivationBlocked: true });
|
||
await page.goto("/vendors");
|
||
|
||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||
await page.getByRole("switch", { name: "Active status" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Deactivate this vendor?" });
|
||
await expect(dialog).toContainText("It still has 1 open work order");
|
||
await expect(
|
||
dialog.getByRole("link", { name: /WO-501 — Emergency boiler repair/ }),
|
||
).toHaveAttribute("href", "/workorders/501");
|
||
|
||
// SH-254: the open work orders inform the decision, they no longer block it.
|
||
const confirm = dialog.getByRole("button", { name: "Deactivate anyway" });
|
||
await expect(confirm).toBeEnabled();
|
||
await confirm.click();
|
||
|
||
await expect.poll(() => confirmState.deletedId).toBe("1");
|
||
expect(confirmState.deleteConfirmedOpenWorkOrders).toBe(true);
|
||
|
||
await page.unrouteAll({ behavior: "wait" });
|
||
|
||
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||
await page.reload();
|
||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||
await page.getByRole("switch", { name: "Active status" }).click();
|
||
await page
|
||
.getByRole("dialog", { name: "Deactivate this vendor?" })
|
||
.getByRole("button", {
|
||
name: /^Deactivate$/,
|
||
})
|
||
.click();
|
||
|
||
await expect(page.getByRole("dialog", { name: "Deactivate this vendor?" })).toContainText(
|
||
/open work orders|conflict/i,
|
||
);
|
||
expect(racedState.deletedId).toBeUndefined();
|
||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||
});
|
||
|
||
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
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 },
|
||
);
|
||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
|
||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
||
await expect(filterDrawer).toBeVisible();
|
||
const box = await filterDrawer.boundingBox();
|
||
expect(box?.width).toBeLessThanOrEqual(391);
|
||
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",
|
||
);
|
||
}
|
||
});
|
||
});
|