feat(po): make vendor field a searchable combobox
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
This commit is contained in:
parent
85805754b5
commit
c503f839e8
5 changed files with 333 additions and 30 deletions
|
|
@ -8,6 +8,7 @@ import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
|
|
@ -244,14 +245,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
|
||||||
<label className={LABEL}>Vendor</label>
|
<label className={LABEL}>Vendor</label>
|
||||||
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}>
|
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
|
||||||
<option value="">No vendor selected</option>
|
|
||||||
{vendors.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Line Items */}
|
{/* Line Items */}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
|
|
@ -255,14 +256,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
{/* Vendor */}
|
{/* Vendor */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
|
||||||
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT_CLS}>
|
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
|
||||||
<option value="">No vendor selected</option>
|
|
||||||
{vendors.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Terms & Conditions */}
|
{/* Terms & Conditions */}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Vendor } from "@prisma/client";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { FileUploader } from "@/components/po/file-uploader";
|
import { FileUploader } from "@/components/po/file-uploader";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
|
|
@ -41,7 +42,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||||
);
|
);
|
||||||
const [vendorId, setVendorId] = useState(initialVendorId ?? "");
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
@ -229,19 +229,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Vendor (optional — can be added later)
|
Vendor (optional — can be added later)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<VendorSelect name="vendorId" vendors={vendors} initialValue={initialVendorId ?? ""} />
|
||||||
name="vendorId"
|
|
||||||
value={vendorId}
|
|
||||||
onChange={(e) => setVendorId(e.target.value)}
|
|
||||||
className={INPUT_CLS}
|
|
||||||
>
|
|
||||||
<option value="">No vendor selected</option>
|
|
||||||
{vendors.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
211
App/components/ui/vendor-select.tsx
Normal file
211
App/components/ui/vendor-select.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { ChevronDown, Search, X } from "lucide-react";
|
||||||
|
|
||||||
|
export type VendorOption = { id: string; name: string; vendorId: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter vendors by a free-text query, matching case-insensitively against the
|
||||||
|
* vendor name and the formal code (`vendorId`). An empty/whitespace query
|
||||||
|
* returns the full list unchanged.
|
||||||
|
*/
|
||||||
|
export function filterVendors<T extends VendorOption>(vendors: T[], query: string): T[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return vendors;
|
||||||
|
return vendors.filter(
|
||||||
|
(v) =>
|
||||||
|
v.name.toLowerCase().includes(q) ||
|
||||||
|
(v.vendorId ? v.vendorId.toLowerCase().includes(q) : false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label shown for a vendor: "{name} (CODE)" when verified, "{name} (unverified)" otherwise. */
|
||||||
|
export function vendorLabel(v: VendorOption): string {
|
||||||
|
return `${v.name} ${v.vendorId ? `(${v.vendorId})` : "(unverified)"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
vendors: VendorOption[];
|
||||||
|
/** Initial selected vendor id (uncontrolled — the component owns its state). */
|
||||||
|
initialValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Optional callback when the selection changes. */
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorSelect({
|
||||||
|
name,
|
||||||
|
vendors,
|
||||||
|
initialValue = "",
|
||||||
|
placeholder = "No vendor selected",
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [portalStyle, setPortalStyle] = useState<React.CSSProperties>({});
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
|
const updatePortalPos = useCallback(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
setPortalStyle({
|
||||||
|
position: "fixed",
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
zIndex: 9999,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
updatePortalPos();
|
||||||
|
}, [open, updatePortalPos]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
window.addEventListener("scroll", updatePortalPos, true);
|
||||||
|
window.addEventListener("resize", updatePortalPos);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", updatePortalPos, true);
|
||||||
|
window.removeEventListener("resize", updatePortalPos);
|
||||||
|
};
|
||||||
|
}, [open, updatePortalPos]);
|
||||||
|
|
||||||
|
// Close on outside click / Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") { setOpen(false); setQuery(""); }
|
||||||
|
}
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKey);
|
||||||
|
document.removeEventListener("mousedown", handleClick);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) searchRef.current?.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const selected = vendors.find((v) => v.id === value);
|
||||||
|
const selectedLabel = selected ? vendorLabel(selected) : "";
|
||||||
|
|
||||||
|
const filtered = filterVendors(vendors, query);
|
||||||
|
|
||||||
|
const select = useCallback((id: string) => {
|
||||||
|
setValue(id);
|
||||||
|
onChange?.(id);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setValue("");
|
||||||
|
onChange?.("");
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const dropdownPanel = (
|
||||||
|
<div
|
||||||
|
style={portalStyle}
|
||||||
|
className="rounded-lg border border-neutral-200 bg-white shadow-xl"
|
||||||
|
>
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="flex items-center gap-2 p-2 border-b border-neutral-100">
|
||||||
|
<Search className="h-4 w-4 text-neutral-400 shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by name or code…"
|
||||||
|
className="flex-1 text-sm outline-none placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button type="button" onClick={() => setQuery("")} className="text-neutral-300 hover:text-neutral-500">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options list */}
|
||||||
|
<div className="max-h-72 overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-300">
|
||||||
|
{/* "No vendor selected" empty option — always available */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); select(""); }}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-primary-50 transition-colors
|
||||||
|
${value === "" ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-500"}`}
|
||||||
|
>
|
||||||
|
No vendor selected
|
||||||
|
</button>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-3 py-5 text-sm text-center text-neutral-400">No vendors match “{query}”</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); select(v.id); }}
|
||||||
|
className={`w-full text-left flex items-baseline gap-2.5 px-3 py-2 text-sm hover:bg-primary-50 transition-colors
|
||||||
|
${value === v.id ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1 leading-snug">{v.name}</span>
|
||||||
|
<span className="font-mono text-xs text-neutral-400 shrink-0">
|
||||||
|
{v.vendorId ?? "unverified"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full">
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`w-full flex items-center justify-between gap-2 rounded-lg border
|
||||||
|
${open ? "border-primary-500 ring-2 ring-primary-500/20" : "border-neutral-300"}
|
||||||
|
bg-white text-left transition-colors px-3 py-2.5 text-sm
|
||||||
|
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20`}
|
||||||
|
>
|
||||||
|
<span className={`truncate flex-1 min-w-0 ${selectedLabel ? "text-neutral-900" : "text-neutral-400"}`}>
|
||||||
|
{selectedLabel || placeholder}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 shrink-0">
|
||||||
|
{value && (
|
||||||
|
<span role="button" tabIndex={0} onClick={handleClear}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleClear(e as unknown as React.MouseEvent)}
|
||||||
|
className="text-neutral-300 hover:text-neutral-500 transition-colors">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className={`text-neutral-400 transition-transform h-4 w-4 ${open ? "rotate-180" : ""}`} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && mounted && createPortal(dropdownPanel, document.body)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
App/tests/unit/vendor-select.test.tsx
Normal file
116
App/tests/unit/vendor-select.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue