fix(vendors): clarify required company fields (#146)
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

* fix(vendors): clarify required company fields

* test(vendors): refresh company label snapshots

* refactor(vendors): isolate contact fields

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
This commit is contained in:
Alexandre Brandizzi 2026-08-27 14:01:53 -03:00 • committed by GitHub
parent 0df17d49f4
commit 3b1fe016fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 279 additions and 58 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View file

@ -450,7 +450,30 @@ test.describe("Vendor directory prototype parity", () => {
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).click();
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();
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
@ -560,7 +583,7 @@ test.describe("Vendor directory prototype parity", () => {
await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();

View file

@ -0,0 +1,89 @@
import { Controller, useWatch, type Control, type FieldErrors } from "react-hook-form";
import { Stack, TextField } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
const COMPANY_CONTACT_ERROR_ID = "vendor-company-contact-error";
const COMPANY_PHONE_HELPER_ID = "vendor-company-phone-helper";
const COMPANY_EMAIL_HELPER_ID = "vendor-company-email-helper";
interface VendorCompanyContactFieldsProps {
control: Control<VendorCompanyRosterFormValues>;
errors: FieldErrors<VendorCompanyRosterFormValues>;
}
export function VendorCompanyContactFields({ control, errors }: VendorCompanyContactFieldsProps) {
const companyPhone = useWatch({ control, name: "companyPhone" }) ?? "";
const companyEmail = useWatch({ control, name: "email" }) ?? "";
const showCompanyContactError = Boolean(
errors.companyContact && !companyPhone.trim() && !companyEmail.trim(),
);
const contactDescribedBy = (helperId: string, hasHelper: boolean) =>
[hasHelper ? helperId : null, showCompanyContactError ? COMPANY_CONTACT_ERROR_ID : null]
.filter(Boolean)
.join(" ") || undefined;
return (
<>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Controller
control={control}
name="companyPhone"
render={({ field }) => (
<TextField
{...field}
label="Company Phone (optional)"
placeholder="(214) 555-0100"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message}
slotProps={{
formHelperText: { id: COMPANY_PHONE_HELPER_ID },
htmlInput: {
"aria-describedby": contactDescribedBy(
COMPANY_PHONE_HELPER_ID,
Boolean(errors.companyPhone?.message),
),
},
}}
fullWidth
/>
)}
/>
<Controller
control={control}
name="email"
render={({ field }) => (
<TextField
{...field}
label="Email (optional)"
placeholder="dispatch@vendor.com"
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
slotProps={{
formHelperText: { id: COMPANY_EMAIL_HELPER_ID },
htmlInput: {
"aria-describedby": contactDescribedBy(
COMPANY_EMAIL_HELPER_ID,
Boolean(errors.email?.message),
),
},
}}
fullWidth
/>
)}
/>
</Stack>
<Text variant="error" id={COMPANY_CONTACT_ERROR_ID} when={showCompanyContactError}>
{VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE}
</Text>
</>
);
}

View file

@ -28,6 +28,7 @@ import {
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
import { VendorCompanyContactFields } from "./vendor-company-contact-fields";
interface VendorRosterFormFieldsProps {
control: Control<VendorCompanyRosterFormValues>;
@ -64,9 +65,10 @@ function CompanyNameField({
render={({ field }) => (
<TextField
{...field}
label="Company"
label="Company (required)"
placeholder="Start typing a company…"
required
slotProps={{ inputLabel: { required: false } }}
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
@ -108,12 +110,16 @@ function CompanyNameField({
renderInput={(params) => (
<TextField
{...params}
label="Company"
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 },
}}
/>
)}
/>
@ -143,40 +149,7 @@ function CompanyFields({
onSelectCompany={onSelectCompany}
onClearSelectedCompany={onClearSelectedCompany}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Controller
control={control}
name="companyPhone"
render={({ field }) => (
<TextField
{...field}
label="Company Phone (optional)"
placeholder="(214) 555-0100"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message}
fullWidth
/>
)}
/>
<Controller
control={control}
name="email"
render={({ field }) => (
<TextField
{...field}
label="Email (optional)"
placeholder="dispatch@vendor.com"
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
fullWidth
/>
)}
/>
</Stack>
<VendorCompanyContactFields control={control} errors={errors} />
<Controller
control={control}
name="address"

View file

@ -51,6 +51,10 @@ export type RosterTechnicianValues = z.infer<typeof rosterTechnicianSchema>;
const baseCompanyFields = {
name: z.string().trim().min(1, "Company is required"),
// Validation-only identity for the phone-or-email group. It is never
// registered as an input and Zod omits it from parsed values when absent,
// so it cannot enter the vendor payload.
companyContact: z.never().optional(),
companyPhone: northAmericanPhone,
email: optionalEmail,
address: z.string(),
@ -64,14 +68,20 @@ const baseCompanyFields = {
technicians: z.array(rosterTechnicianSchema),
};
export const VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE =
"Provide a company phone or email (at least one required)";
// The phone-or-email rule spans two optional inputs, so it is reported at a
// validation-only path instead of being attributed to `companyPhone`, which
// would mark that input individually invalid.
export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine((data, ctx) => {
const hasPhone = Boolean(data.companyPhone && data.companyPhone.trim());
const hasEmail = Boolean(data.email && data.email.trim());
if (!hasPhone && !hasEmail) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["companyPhone"],
message: "Provide a company phone or email (at least one required)",
path: ["companyContact"],
message: VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
});
}
});

View file

@ -1,7 +1,16 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { describe, expect, it, vi } from "vitest";
import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
emptyVendorCompanyRosterForm,
vendorCompanyRosterSchema,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
@ -18,28 +27,122 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () =>
};
});
function PlainCompanyFieldsHarness() {
const { control, handleSubmit, formState } = useForm<VendorCompanyRosterFormValues>({
defaultValues: emptyVendorCompanyRosterForm,
resolver: zodResolver(vendorCompanyRosterSchema),
mode: "onChange",
});
return (
<form onSubmit={handleSubmit(() => {})}>
<VendorRosterFormFields control={control} errors={formState.errors} />
</form>
);
}
function renderCreateModal() {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
}
async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
target: { value: name },
});
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
}
describe("VendorCreateModal validation", () => {
it("explains the company phone-or-email requirement after submission", async () => {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
renderCreateModal();
await fillCompanyNameAndSubmit();
fireEvent.change(screen.getByRole("combobox", { name: /Company/ }), {
target: { value: "Gateway Plumbing" },
});
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
});
it("labels the autocomplete company field exactly, required, with no generated asterisk", () => {
renderCreateModal();
const company = screen.getByRole("combobox", { name: "Company (required)" });
expect(company).toBeRequired();
expect(screen.getByText("Company (required)")).toBeInTheDocument();
expect(screen.queryByText("*")).not.toBeInTheDocument();
expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull();
});
it("labels the plain company field exactly, required, with no generated asterisk", () => {
renderWithProviders(<PlainCompanyFieldsHarness />, { route: "/vendors", withAuth: false });
const company = screen.getByRole("textbox", { name: "Company (required)" });
expect(company).toBeRequired();
expect(screen.getByText("Company (required)")).toBeInTheDocument();
expect(screen.queryByText("*")).not.toBeInTheDocument();
expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull();
});
it("shows the phone-or-email error once without marking either input individually invalid", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
const message = await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE);
expect(message).toHaveAttribute("id");
expect(screen.getAllByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toHaveLength(1);
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
expect(phone).not.toHaveAttribute("aria-invalid", "true");
expect(email).not.toHaveAttribute("aria-invalid", "true");
expect(phone.getAttribute("aria-describedby")).toContain(message.getAttribute("id"));
expect(email.getAttribute("aria-describedby")).toContain(message.getAttribute("id"));
});
it("marks only the phone input invalid when the phone is malformed", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
await userEvent.type(phone, "314");
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(
await screen.findByText("Provide a company phone or email (at least one required)"),
).toBeInTheDocument();
expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument();
expect(phone).toHaveAttribute("aria-invalid", "true");
expect(email).not.toHaveAttribute("aria-invalid", "true");
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument();
});
it("marks only the email input invalid when the email is malformed", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
await userEvent.type(email, "not-an-email");
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(await screen.findByText("Invalid email")).toBeInTheDocument();
expect(email).toHaveAttribute("aria-invalid", "true");
expect(phone).not.toHaveAttribute("aria-invalid", "true");
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument();
});
it("clears the phone-or-email error once a valid phone is entered", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
await userEvent.type(phone, "3145550100");
await waitFor(() =>
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(),
);
});
it("shows the notes limit and live character counter", async () => {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
renderCreateModal();
const notes = screen.getByRole("textbox", { name: "Notes (optional)" });
fireEvent.change(notes, { target: { value: "abc" } });

View file

@ -329,7 +329,7 @@ describe("VendorsListPage", () => {
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
const company = screen.getByRole("textbox", { name: "Company" });
const company = screen.getByRole("textbox", { name: "Company (required)" });
await userEvent.clear(company);
await userEvent.type(company, "Draft Company Name");
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
@ -362,7 +362,9 @@ describe("VendorsListPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
expect(screen.getByRole("textbox", { name: "Company (required)" })).toHaveValue(
"Gateway Plumbing",
);
});
it("opens inline company edit when the row has no vendor id", async () => {
@ -385,6 +387,8 @@ describe("VendorsListPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
expect(screen.getByRole("textbox", { name: "Company (required)" })).toHaveValue(
"Gateway Plumbing",
);
});
});

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
emptyRosterTechnician,
emptyVendorCompanyRosterForm,
isAbsoluteHttpsUrl,
@ -30,12 +31,29 @@ describe("vendorCompanyRosterSchema", () => {
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.message === "Provide a company phone or email (at least one required)",
(issue) => issue.message === VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
),
).toBe(true);
}
});
it("reports the phone-or-email rule at the companyContact path, not on companyPhone", () => {
const result = vendorCompanyRosterSchema.safeParse({
...emptyVendorCompanyRosterForm,
name: "Solo Co",
});
expect(result.success).toBe(false);
if (!result.success) {
const issue = result.error.issues.find(
(candidate) => candidate.message === VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
);
expect(issue?.path).toEqual(["companyContact"]);
expect(result.error.issues.some((candidate) => candidate.path.includes("companyPhone"))).toBe(
false,
);
}
});
it("accepts a company with zero technicians when phone is present", () => {
const result = vendorCompanyRosterSchema.safeParse({
...emptyVendorCompanyRosterForm,
@ -43,6 +61,7 @@ describe("vendorCompanyRosterSchema", () => {
companyPhone: "(314) 555-0100",
});
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty("companyContact");
});
it("accepts a company with email only and multiple technicians", () => {