fix: complete SH-283 vendor QA paths (#148)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
This commit is contained in:
Alexandre Brandizzi 2026-08-27 15:03:36 -03:00 • committed by GitHub
parent 3b1fe016fe
commit 7ec522e8ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 103 additions and 8 deletions

View file

@ -475,6 +475,28 @@ test.describe("Vendor directory prototype parity", () => {
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);
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",

View file

@ -8,6 +8,15 @@ interface VendorRosterConflictAlertProps {
}
export function VendorRosterConflictAlert({ conflict, onReload }: VendorRosterConflictAlertProps) {
if (conflict.kind === "duplicate") {
return (
<Alert severity="warning">
<AlertTitle>Company name already exists</AlertTitle>
{conflict.message}
</Alert>
);
}
if (conflict.kind === "stale") {
return (
<Alert

View file

@ -67,8 +67,7 @@ function CompanyNameField({
{...field}
label="Company (required)"
placeholder="Start typing a company…"
required
slotProps={{ inputLabel: { required: false } }}
slotProps={{ htmlInput: { required: true } }}
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
@ -111,14 +110,13 @@ function CompanyNameField({
<TextField
{...params}
label="Company (required)"
required
placeholder="Start typing a company…"
error={Boolean(errors.name)}
helperText={errors.name?.message}
inputRef={field.ref}
slotProps={{
...params.slotProps,
inputLabel: { ...params.slotProps?.inputLabel, required: false },
htmlInput: { ...params.slotProps?.htmlInput, required: true },
}}
/>
)}

View file

@ -179,8 +179,13 @@ export function mapRosterConflict(raw: unknown, fallbackMessage: string): Vendor
const item = asRecord(raw);
const blockedRaw = item.blockedWorkOrders ?? item.BlockedWorkOrders ?? item.openWorkOrders;
const blockedWorkOrders = Array.isArray(blockedRaw) ? blockedRaw.map(mapBlockedWorkOrder) : [];
const code = readString(item, "code", "Code");
const kind: VendorRosterConflictKind =
blockedWorkOrders.length > 0 ? "open-work-orders" : "stale";
code === "duplicate_vendor_company_name"
? "duplicate"
: blockedWorkOrders.length > 0
? "open-work-orders"
: "stale";
const message = readString(item, "message", "Message") || fallbackMessage;
return { kind, message, blockedWorkOrders };
}

View file

@ -145,7 +145,7 @@ export interface VendorRosterBlockedWorkOrder {
scheduledDate?: string;
}
export type VendorRosterConflictKind = "open-work-orders" | "stale";
export type VendorRosterConflictKind = "duplicate" | "open-work-orders" | "stale";
export interface VendorRosterConflict {
kind: VendorRosterConflictKind;

View file

@ -67,9 +67,12 @@ describe("VendorCreateModal validation", () => {
const company = screen.getByRole("combobox", { name: "Company (required)" });
expect(company).toBeRequired();
expect(screen.getByText("Company (required)")).toBeInTheDocument();
expect(screen.getAllByText("Company (required)")).toHaveLength(2);
expect(screen.queryByText("*")).not.toBeInTheDocument();
expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull();
expect(company.closest(".MuiFormControl-root")?.querySelector("legend")).toHaveTextContent(
/^Company \(required\)$/,
);
});
it("labels the plain company field exactly, required, with no generated asterisk", () => {
@ -77,9 +80,12 @@ describe("VendorCreateModal validation", () => {
const company = screen.getByRole("textbox", { name: "Company (required)" });
expect(company).toBeRequired();
expect(screen.getByText("Company (required)")).toBeInTheDocument();
expect(screen.getAllByText("Company (required)")).toHaveLength(2);
expect(screen.queryByText("*")).not.toBeInTheDocument();
expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull();
expect(company.closest(".MuiFormControl-root")?.querySelector("legend")).toHaveTextContent(
/^Company \(required\)$/,
);
});
it("shows the phone-or-email error once without marking either input individually invalid", async () => {

View file

@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { VendorRosterConflictAlert } from "@/app/(protected)/vendors/_components/vendor-roster-conflict-alert";
describe("VendorRosterConflictAlert", () => {
it("explains a duplicate company without offering a stale-data reload", () => {
render(
<VendorRosterConflictAlert
conflict={{
kind: "duplicate",
message: "Another vendor company already uses that name.",
blockedWorkOrders: [],
}}
onReload={vi.fn()}
/>,
);
expect(screen.getByText("Company name already exists")).toBeInTheDocument();
expect(screen.getByText("Another vendor company already uses that name.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reload" })).not.toBeInTheDocument();
});
});

View file

@ -118,6 +118,25 @@ describe("vendorCompanyRosterApi", () => {
);
});
it("throws a duplicate conflict for the stable create response", async () => {
apiPost.mockRejectedValueOnce(
httpError(409, {
code: "duplicate_vendor_company_name",
message: "Another vendor company already uses that name.",
}),
);
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toSatisfy(
(error: unknown) => {
if (!isVendorRosterConflictError(error)) return false;
return (
error.conflict.kind === "duplicate" &&
error.conflict.message === "Another vendor company already uses that name."
);
},
);
});
it("rethrows non-conflict errors untouched", async () => {
const generic = new Error("boom");
apiPost.mockRejectedValueOnce(generic);

View file

@ -113,6 +113,20 @@ describe("vendor roster mapper", () => {
expect(conflict.message).toBe("stale rowversion");
});
it("classifies the stable duplicate-company response", () => {
const conflict = mapRosterConflict(
{
code: "duplicate_vendor_company_name",
message: "Another vendor company already uses that name.",
},
"fallback",
);
expect(conflict.kind).toBe("duplicate");
expect(conflict.message).toBe("Another vendor company already uses that name.");
expect(conflict.blockedWorkOrders).toEqual([]);
});
it("preserves an explicit Phone/Email/Text preferred contact on read", () => {
expect(
mapRosterTechnician({ contactName: "A", preferredContact: "Phone" }).preferredContact,