diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
index 6e091c72..d39596dc 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ
diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
index a80af4db..93fd2ef9 100644
Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ
diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
index 62d65f15..349abe96 100644
--- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
+++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
@@ -211,7 +211,7 @@ function DrawerBody({
Notes
-
+
{roster.notes}
@@ -341,7 +341,7 @@ function DrawerEditor({
-
-
+
{submitLabel(form.isSaving, isEdit)}
diff --git a/src/domain/vendors/schemas/vendor-roster-schema.ts b/src/domain/vendors/schemas/vendor-roster-schema.ts
index 9358b12b..744a3581 100644
--- a/src/domain/vendors/schemas/vendor-roster-schema.ts
+++ b/src/domain/vendors/schemas/vendor-roster-schema.ts
@@ -35,6 +35,8 @@ const northAmericanPhone = z
const optionalEmail = z.union([z.string().email("Invalid email"), z.literal("")]);
+export const VENDOR_NOTES_MAX_LENGTH = 500;
+
export const rosterTechnicianSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
contactName: z.string(),
@@ -56,7 +58,9 @@ const baseCompanyFields = {
state: z.string(),
zip: z.string(),
googleMapsUrl: httpsUrl,
- notes: z.string(),
+ notes: z
+ .string()
+ .max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
technicians: z.array(rosterTechnicianSchema),
};
@@ -67,7 +71,7 @@ export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["companyPhone"],
- message: "Company phone or email is required",
+ message: "Provide a company phone or email (at least one required)",
});
}
});
diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx
index b56aa714..538aaf4b 100644
--- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx
+++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx
@@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
-import { useController } from "react-hook-form";
+import { useController, useFieldArray } from "react-hook-form";
import { beforeEach, describe, expect, it, vi } from "vitest";
const rosterGet = vi.fn();
@@ -321,6 +321,31 @@ describe("useVendorRosterForm prototype defaults", () => {
});
});
+describe("useVendorRosterForm technician removal", () => {
+ it("keeps a valid roster saveable after an existing technician is removed", async () => {
+ rosterQueryResult = {
+ data: rosterWithTechnicians,
+ isLoading: false,
+ isError: false,
+ error: null,
+ refetch: vi.fn(),
+ };
+ const { result } = renderHook(
+ () => {
+ const form = useVendorRosterForm({ mode: "update", companyId: 5 });
+ const technicians = useFieldArray({ control: form.control, name: "technicians" });
+ return { form, technicians };
+ },
+ { wrapper: makeWrapper(createClient()) },
+ );
+
+ await waitFor(() => expect(result.current.form.isValid).toBe(true));
+ act(() => result.current.technicians.remove(1));
+
+ await waitFor(() => expect(result.current.form.isValid).toBe(true));
+ });
+});
+
describe("useVendorRosterForm stale-selection handling", () => {
beforeEach(() => {
rosterGet.mockReset();
diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx
new file mode 100644
index 00000000..661f00d7
--- /dev/null
+++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx
@@ -0,0 +1,52 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
+import { renderWithProviders } from "@/test/test-utils";
+
+vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
+ useVendorFacets: () => ({ data: { companies: [], trades: [] }, isLoading: false }),
+}));
+
+vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
+ const actual = await vi.importActual<
+ typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
+ >("@/domain/vendors/use-cases/use-save-vendor-company-roster");
+ return {
+ ...actual,
+ useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }),
+ };
+});
+
+describe("VendorCreateModal validation", () => {
+ it("explains the company phone-or-email requirement after submission", async () => {
+ renderWithProviders(, {
+ route: "/vendors",
+ withAuth: false,
+ });
+
+ fireEvent.change(screen.getByRole("combobox", { name: /Company/ }), {
+ target: { value: "Gateway Plumbing" },
+ });
+ await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
+
+ expect(
+ await screen.findByText("Provide a company phone or email (at least one required)"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the notes limit and live character counter", async () => {
+ renderWithProviders(, {
+ route: "/vendors",
+ withAuth: false,
+ });
+
+ const notes = screen.getByRole("textbox", { name: "Notes (optional)" });
+ fireEvent.change(notes, { target: { value: "abc" } });
+
+ expect(notes).toHaveAttribute("maxlength", "500");
+ await waitFor(() =>
+ expect(notes).toHaveAccessibleDescription(/3\/500 characters · Maximum 500 characters/),
+ );
+ });
+});
diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
index 4e52aa7f..af64c2e0 100644
--- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
+++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
@@ -51,6 +51,48 @@ function rosterWith(technicians: Array>) {
}
describe("VendorDetailDrawer selected-technician display", () => {
+ it("keeps Save changes available after removing a technician from a legacy roster", async () => {
+ const roster = rosterWith([
+ {
+ id: 1,
+ contactName: "Adam",
+ phone: "314-555-0198",
+ email: "",
+ tradeSpecialties: "",
+ isActive: true,
+ totalJobs: 5,
+ },
+ {
+ id: 2,
+ contactName: "Beth",
+ phone: "314-555-0199",
+ email: "",
+ tradeSpecialties: "",
+ isActive: true,
+ totalJobs: 2,
+ },
+ ]);
+ roster.data.companyPhone = "";
+ roster.data.email = "";
+ useVendorCompanyRoster.mockReturnValue(roster);
+
+ renderWithProviders(
+ ,
+ { route: "/vendors", withAuth: false },
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "Remove technician 2" }));
+
+ const save = screen.getByRole("button", { name: "Save changes" });
+ expect(save).toBeEnabled();
+
+ await userEvent.click(save);
+
+ expect(
+ await screen.findByText("Provide a company phone or email (at least one required)"),
+ ).toBeInTheDocument();
+ });
+
it("renders no preference label when preferredContact is absent", () => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
@@ -137,6 +179,38 @@ describe("VendorDetailDrawer selected-technician display", () => {
});
describe("VendorDetailDrawer design parity", () => {
+ it("wraps long unbroken company notes inside the drawer", () => {
+ const roster = rosterWith([]);
+ roster.data.notes = "x".repeat(5000);
+ useVendorCompanyRoster.mockReturnValue(roster);
+
+ renderWithProviders(, {
+ route: "/vendors",
+ withAuth: false,
+ });
+
+ const notes = screen.getByText("x".repeat(5000));
+ expect(notes).toHaveStyle({ overflowWrap: "anywhere", whiteSpace: "pre-wrap" });
+ });
+
+ it("preserves an oversized legacy note in edit mode while showing the new limit", async () => {
+ const roster = rosterWith([]);
+ const legacyNotes = "x".repeat(501);
+ roster.data.notes = legacyNotes;
+ useVendorCompanyRoster.mockReturnValue(roster);
+
+ renderWithProviders(
+ ,
+ { route: "/vendors", withAuth: false },
+ );
+
+ const notes = await screen.findByRole("textbox", { name: "Notes (optional)" });
+ expect(notes).toHaveValue(legacyNotes);
+ expect(notes).toHaveAttribute("maxlength", "500");
+ expect(notes).toHaveStyle({ overflowWrap: "anywhere", whiteSpace: "pre-wrap" });
+ expect(screen.getByText(/501\/500 characters/)).toBeInTheDocument();
+ });
+
it("renders the company once when there is no technician name to head the panel", () => {
useVendorCompanyRoster.mockReturnValue(rosterWith([]));
diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts
index 6b94dd90..646f8e73 100644
--- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts
+++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts
@@ -3,6 +3,7 @@ import {
emptyRosterTechnician,
emptyVendorCompanyRosterForm,
isAbsoluteHttpsUrl,
+ VENDOR_NOTES_MAX_LENGTH,
vendorCompanyRosterSchema,
vendorCompanyRosterUpdateSchema,
} from "@/domain/vendors/schemas/vendor-roster-schema";
@@ -28,7 +29,9 @@ describe("vendorCompanyRosterSchema", () => {
expect(result.success).toBe(false);
if (!result.success) {
expect(
- result.error.issues.some((issue) => issue.message === "Company phone or email is required"),
+ result.error.issues.some(
+ (issue) => issue.message === "Provide a company phone or email (at least one required)",
+ ),
).toBe(true);
}
});
@@ -54,6 +57,20 @@ describe("vendorCompanyRosterSchema", () => {
expect(result.success).toBe(true);
});
+ it("accepts exactly 500 note characters and rejects 501", () => {
+ const atLimit = vendorCompanyRosterSchema.safeParse({
+ ...baseCompany,
+ notes: "x".repeat(VENDOR_NOTES_MAX_LENGTH),
+ });
+ const overLimit = vendorCompanyRosterSchema.safeParse({
+ ...baseCompany,
+ notes: "x".repeat(VENDOR_NOTES_MAX_LENGTH + 1),
+ });
+
+ expect(atLimit.success).toBe(true);
+ expect(overLimit.success).toBe(false);
+ });
+
it("accepts a technician row without a name or phone", () => {
const result = vendorCompanyRosterSchema.safeParse({
...baseCompany,