The vendor field on the PO forms was a plain native <select>, forcing
users to scroll the full vendor list. Mirror the item-search UX with a
searchable combobox that filters by vendor name and formal code
(vendorId), case-insensitively. The vendor list is already client-side,
so this is a pure in-memory filter — no API or DB change.
New VendorSelect component (components/ui/vendor-select.tsx) is a
self-contained portal-rendered combobox posting a hidden vendorId input,
so it drops into all three PO forms unchanged on the server:
- po/new/new-po-form
- po/[id]/edit/edit-po-form
- approvals/[id]/manager-edit-po-form
Preserves the optional field, "No vendor selected" empty option, and the
"{name} (CODE)" / "(unverified)" label. Unverified vendors (null code)
remain findable by name. Adds unit tests for the filter logic and
component behaviour.
Fixes #109
116 lines
5.1 KiB
TypeScript
116 lines
5.1 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import { filterVendors, vendorLabel, VendorSelect, type VendorOption } from "@/components/ui/vendor-select";
|
|
|
|
const VENDORS: VendorOption[] = [
|
|
{ id: "1", name: "Acme Marine Supplies", vendorId: "V-1001" },
|
|
{ id: "2", name: "Bluewater Engineering", vendorId: "V-2002" },
|
|
{ id: "3", name: "Coastal Spares", vendorId: null }, // unverified — no code
|
|
{ id: "4", name: "Delta Pumps", vendorId: "ACME-99" },
|
|
];
|
|
|
|
// ── Pure filter logic ─────────────────────────────────────────────────────────
|
|
|
|
describe("filterVendors", () => {
|
|
it("returns all vendors for an empty query", () => {
|
|
expect(filterVendors(VENDORS, "")).toHaveLength(4);
|
|
});
|
|
|
|
it("returns all vendors for a whitespace-only query", () => {
|
|
expect(filterVendors(VENDORS, " ")).toHaveLength(4);
|
|
});
|
|
|
|
it("matches by name (case-insensitive)", () => {
|
|
const res = filterVendors(VENDORS, "bluewater");
|
|
expect(res.map((v) => v.id)).toEqual(["2"]);
|
|
});
|
|
|
|
it("matches by code (vendorId), case-insensitive", () => {
|
|
const res = filterVendors(VENDORS, "v-2002");
|
|
expect(res.map((v) => v.id)).toEqual(["2"]);
|
|
});
|
|
|
|
it("matches on a code substring even when the name does not contain it", () => {
|
|
// "acme" appears in vendor #1's name AND in vendor #4's code (ACME-99)
|
|
const res = filterVendors(VENDORS, "acme");
|
|
expect(res.map((v) => v.id).sort()).toEqual(["1", "4"]);
|
|
});
|
|
|
|
it("finds an unverified vendor (null code) by name only", () => {
|
|
expect(filterVendors(VENDORS, "coastal").map((v) => v.id)).toEqual(["3"]);
|
|
// ...and never crashes on the null code
|
|
expect(filterVendors(VENDORS, "9999")).toHaveLength(0);
|
|
});
|
|
|
|
it("returns no matches for an unrelated query", () => {
|
|
expect(filterVendors(VENDORS, "zzz")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("vendorLabel", () => {
|
|
it("formats a verified vendor as name (CODE)", () => {
|
|
expect(vendorLabel(VENDORS[0])).toBe("Acme Marine Supplies (V-1001)");
|
|
});
|
|
it("formats an unverified vendor as name (unverified)", () => {
|
|
expect(vendorLabel(VENDORS[2])).toBe("Coastal Spares (unverified)");
|
|
});
|
|
});
|
|
|
|
// ── Component behaviour ───────────────────────────────────────────────────────
|
|
|
|
describe("VendorSelect", () => {
|
|
it("posts a hidden vendorId input with the empty default", () => {
|
|
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} />);
|
|
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
|
|
expect(hidden).toBeTruthy();
|
|
expect(hidden.value).toBe("");
|
|
});
|
|
|
|
it("honours initialValue", () => {
|
|
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} initialValue="2" />);
|
|
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
|
|
expect(hidden.value).toBe("2");
|
|
// Trigger shows the formatted label
|
|
expect(screen.getByText("Bluewater Engineering (V-2002)")).toBeTruthy();
|
|
});
|
|
|
|
it("opens and filters the list by typed query, then selects a vendor", () => {
|
|
const onChange = vi.fn();
|
|
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} onChange={onChange} />);
|
|
fireEvent.click(screen.getByRole("button"));
|
|
const search = screen.getByPlaceholderText("Search by name or code…");
|
|
fireEvent.change(search, { target: { value: "v-2002" } });
|
|
|
|
// Only the matching vendor option is in the list
|
|
expect(screen.getByText("Bluewater Engineering")).toBeTruthy();
|
|
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
|
|
|
fireEvent.mouseDown(screen.getByText("Bluewater Engineering"));
|
|
expect(onChange).toHaveBeenCalledWith("2");
|
|
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
|
|
expect(hidden.value).toBe("2");
|
|
});
|
|
|
|
it("keeps 'No vendor selected' selectable to clear the choice", () => {
|
|
const onChange = vi.fn();
|
|
const { container } = render(
|
|
<VendorSelect name="vendorId" vendors={VENDORS} initialValue="1" onChange={onChange} />
|
|
);
|
|
// The trigger shows the current selection's label; click it to open.
|
|
fireEvent.click(screen.getByText("Acme Marine Supplies (V-1001)"));
|
|
// The empty option is always present, even before typing
|
|
fireEvent.mouseDown(screen.getByText("No vendor selected"));
|
|
expect(onChange).toHaveBeenLastCalledWith("");
|
|
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
|
|
expect(hidden.value).toBe("");
|
|
});
|
|
|
|
it("shows an empty-state message when nothing matches", () => {
|
|
render(<VendorSelect name="vendorId" vendors={VENDORS} />);
|
|
fireEvent.click(screen.getByRole("button"));
|
|
fireEvent.change(screen.getByPlaceholderText("Search by name or code…"), {
|
|
target: { value: "zzz" },
|
|
});
|
|
expect(screen.getByText(/No vendors match/)).toBeTruthy();
|
|
});
|
|
});
|