diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts
index d3fb7ceb..27672d30 100644
--- a/src/domain/vendors/mappers/vendor-roster-mapper.ts
+++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts
@@ -123,7 +123,8 @@ export function mapVendorRosterToBackend(values: unknown): Record
, , ". */
+ label: string;
+};
+
+/** Minimum characters before any suggestion appears. */
+export const ADDRESS_SUGGESTION_MIN_LENGTH = 3;
+
+/** How many candidates the field offers at most. */
+export const ADDRESS_SUGGESTION_LIMIT = 4;
+
+/**
+ * Candidate cities, ported verbatim from the prototype.
+ *
+ * These are plausible distribution-centre metros rather than a geocoder: the
+ * point is that a dispatcher sees a short, stable list to pick from, not that
+ * the address is real.
+ */
+const SUGGESTION_CITIES: ReadonlyArray> = [
+ { city: "Dallas", state: "TX" },
+ { city: "Indianapolis", state: "IN" },
+ { city: "Memphis", state: "TN" },
+ { city: "St. Louis", state: "MO" },
+ { city: "Orlando", state: "FL" },
+ { city: "Austin", state: "TX" },
+ { city: "Huntsville", state: "AL" },
+ { city: "Rockford", state: "IL" },
+ { city: "Columbus", state: "OH" },
+ { city: "Tampa", state: "FL" },
+ { city: "Cleveland", state: "OH" },
+ { city: "Atlanta", state: "GA" },
+ { city: "Chicago", state: "IL" },
+ { city: "Phoenix", state: "AZ" },
+ { city: "Portland", state: "OR" },
+ { city: "Jersey City", state: "NJ" },
+ { city: "Denver", state: "CO" },
+ { city: "Miami", state: "FL" },
+];
+
+/**
+ * Deterministic index from a seed string.
+ *
+ * Deterministic on purpose: the same typed text must offer the same candidates
+ * every keystroke and every render, or the list reshuffles under the pointer
+ * while someone is trying to click a row.
+ */
+function seededIndex(seed: string, size: number): number {
+ let hash = 0;
+ for (let index = 0; index < seed.length; index += 1) {
+ hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
+ }
+ return hash % size;
+}
+
+/**
+ * Up to four candidates for what the dispatcher has typed so far.
+ *
+ * The typed text is always kept as the street; only City and State are
+ * proposed. Selecting a candidate fills all three, and typing without selecting
+ * leaves plain free text — the field never locks the user out of their own
+ * input.
+ */
+export function suggestAddresses(input: string): AddressSuggestion[] {
+ const street = input.trim();
+ if (street.length < ADDRESS_SUGGESTION_MIN_LENGTH) return [];
+
+ const seen = new Set();
+ const suggestions: AddressSuggestion[] = [];
+ for (
+ let attempt = 0;
+ suggestions.length < ADDRESS_SUGGESTION_LIMIT && attempt < SUGGESTION_CITIES.length;
+ attempt += 1
+ ) {
+ const candidate =
+ SUGGESTION_CITIES[seededIndex(`${street}#${attempt}`, SUGGESTION_CITIES.length)];
+ if (!candidate) continue;
+ const key = `${candidate.city},${candidate.state}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ suggestions.push({
+ label: `${street}, ${candidate.city}, ${candidate.state}`,
+ street,
+ city: candidate.city,
+ state: candidate.state,
+ });
+ }
+ return suggestions;
+}
+
+/** The single-line form Google is asked about. Empty when there is nothing to ask. */
+export function buildAddressQuery(parts: Partial): string {
+ return [parts.street, parts.city, parts.state]
+ .map((part) => part?.trim() ?? "")
+ .filter(Boolean)
+ .join(" ");
+}
+
+/** Link target for the clickable Street Address. Empty string when unmappable. */
+export function mapsSearchUrl(parts: Partial): string {
+ const query = buildAddressQuery(parts);
+ return query
+ ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`
+ : "";
+}
+
+/**
+ * Embed URL for the inline preview.
+ *
+ * `output=embed` is the keyless form: it renders in an iframe with no API key,
+ * no billing account and no Places request, which is what SH-271 requires.
+ */
+export function mapsEmbedUrl(parts: Partial): string {
+ const query = buildAddressQuery(parts);
+ return query ? `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed` : "";
+}
+
+/** A preview is only meaningful once all three parts are present. */
+export function hasCompleteAddress(parts: Partial): boolean {
+ return Boolean(parts.street?.trim() && parts.city?.trim() && parts.state?.trim());
+}
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 2fef475b..3202bd40 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,6 +1,6 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import type { ReactNode } from "react";
+import type { FormEvent, ReactNode } from "react";
import { useController, useFieldArray } from "react-hook-form";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -133,7 +133,6 @@ function submitValues(
city: "St. Louis",
state: "MO",
zip: "63101",
- googleMapsUrl: "",
notes: "",
technicians,
};
@@ -300,7 +299,6 @@ describe("useVendorRosterForm prototype defaults", () => {
city: "",
state: "",
zip: "",
- googleMapsUrl: "",
notes: "",
technicians: [
{
@@ -322,6 +320,46 @@ describe("useVendorRosterForm prototype defaults", () => {
});
});
+describe("useVendorRosterForm legacy stored Maps URL", () => {
+ beforeEach(() => {
+ rosterGet.mockReset();
+ saveMutate.mockReset();
+ });
+
+ it("saves an edit of a legacy roster whose stored Maps URL is plain HTTP", async () => {
+ const legacyRoster: VendorCompanyRoster = {
+ ...roster,
+ googleMapsUrl: "http://maps.example.test/legacy",
+ };
+ rosterQueryResult = {
+ data: legacyRoster,
+ isLoading: false,
+ isError: false,
+ error: null,
+ refetch: vi.fn(),
+ };
+ const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), {
+ wrapper: makeWrapper(createClient()),
+ });
+
+ // Exercise the real resolver: while the form schema validated the hidden
+ // loaded URL, this submit never reached the save path.
+ await act(async () => {
+ await result.current.handleSubmit(result.current.submit)({
+ preventDefault: () => undefined,
+ } as unknown as FormEvent);
+ });
+
+ expect(saveMutate).toHaveBeenCalledTimes(1);
+ const input = saveMutate.mock.calls[0]?.[0] as {
+ mode: string;
+ values: VendorCompanyRosterFormValues;
+ };
+ expect(input.mode).toBe("update");
+ expect(input.values).not.toHaveProperty("googleMapsUrl");
+ });
+});
+
describe("useVendorRosterForm technician removal", () => {
it("keeps a valid roster saveable after an existing technician is removed", async () => {
rosterQueryResult = {
diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx
index feb5f59b..e96fd031 100644
--- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx
+++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx
@@ -51,9 +51,25 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
target: { value: name },
});
+ // SH-271 made the structured address required. These tests are about the
+ // phone-or-email rule, so the address is filled to keep the form's only
+ // outstanding problem the one under test.
+ fillRequiredAddress();
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
}
+function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) {
+ fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), {
+ target: { value: street },
+ });
+ fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
+ target: { value: city },
+ });
+ fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), {
+ target: { value: state },
+ });
+}
+
describe("VendorCreateModal validation", () => {
it("explains the company phone-or-email requirement after submission", async () => {
renderCreateModal();
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 d13a9174..8777567d 100644
--- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
+++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx
@@ -49,9 +49,11 @@ function rosterWith(technicians: Array>) {
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
- address: "",
- city: "",
- state: "",
+ // SH-271: Street/City/State are required, so a saved roster carries them.
+ // Tests that need the incomplete case blank them explicitly.
+ address: "6735 Trippel Rd",
+ city: "Theodore",
+ state: "AL",
zip: "",
googleMapsUrl: "",
notes: "",
@@ -343,7 +345,9 @@ describe("VendorDetailDrawer design parity", () => {
expect(screen.getByText("Preferred after-hours vendor")).toBeInTheDocument();
});
- it("labels the Google Maps field below Address even when no URL is stored", () => {
+ it("shows Street, City and State as separate rows with no stored-URL Google Maps row", () => {
+ // SH-271 removed the "Open in Google Maps" row and the stored URL behind
+ // it; the location is derived from the structured address instead.
useVendorCompanyRoster.mockReturnValue(
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
);
@@ -353,15 +357,37 @@ describe("VendorDetailDrawer design parity", () => {
withAuth: false,
});
- expect(screen.getByText("Google Maps")).toBeInTheDocument();
+ expect(screen.getByText("Street Address")).toBeInTheDocument();
+ expect(screen.getByText("City")).toBeInTheDocument();
+ expect(screen.getByText("State")).toBeInTheDocument();
+ expect(screen.queryByText("Google Maps")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /Open in Google Maps/ })).not.toBeInTheDocument();
});
- it("links out to Google Maps when a URL is stored", () => {
+ it("makes the street address itself the link to Google Maps", () => {
+ useVendorCompanyRoster.mockReturnValue(
+ rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
+ );
+
+ renderWithProviders(, {
+ route: "/vendors",
+ withAuth: false,
+ });
+
+ expect(screen.getByRole("link", { name: "6735 Trippel Rd" })).toHaveAttribute(
+ "href",
+ "https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL",
+ );
+ });
+
+ it("renders no map preview and no link until the address is complete", () => {
+ // A preview built from half an address points somewhere misleading.
const roster = rosterWith([
{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 },
]);
- roster.data.googleMapsUrl = "https://maps.google.com/?q=1+Industrial+Pkwy";
+ roster.data.address = "";
+ roster.data.city = "";
+ roster.data.state = "";
useVendorCompanyRoster.mockReturnValue(roster);
renderWithProviders(, {
@@ -369,10 +395,8 @@ describe("VendorDetailDrawer design parity", () => {
withAuth: false,
});
- expect(screen.getByRole("link", { name: /Open in Google Maps/ })).toHaveAttribute(
- "href",
- "https://maps.google.com/?q=1+Industrial+Pkwy",
- );
+ expect(screen.queryByRole("link", { name: /Trippel/ })).not.toBeInTheDocument();
+ expect(document.querySelector("iframe")).toBeNull();
});
it("puts the status opposite Total Jobs and drops the redundant Status label", () => {
diff --git a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx
new file mode 100644
index 00000000..28a3b106
--- /dev/null
+++ b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx
@@ -0,0 +1,142 @@
+import { fireEvent, screen, waitFor, within } 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 { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
+import {
+ emptyVendorCompanyRosterForm,
+ vendorCompanyRosterSchema,
+ type VendorCompanyRosterFormValues,
+} from "@/domain/vendors/schemas/vendor-roster-schema";
+import { suggestAddresses } from "@/lib/address/vendor-address";
+import { renderWithProviders } from "@/test/test-utils";
+
+const TYPED_STREET = "415 Murphy Rd";
+const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0];
+
+if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street");
+
+function AddressFormHarness({
+ onValid,
+}: {
+ onValid: (values: VendorCompanyRosterFormValues) => void;
+}) {
+ const { control, handleSubmit, formState } = useForm({
+ defaultValues: emptyVendorCompanyRosterForm,
+ resolver: zodResolver(vendorCompanyRosterSchema),
+ mode: "onChange",
+ });
+ return (
+
+ );
+}
+
+function fillCompanyBasics(): void {
+ fireEvent.change(screen.getByRole("textbox", { name: "Company (required)" }), {
+ target: { value: "Gateway Plumbing" },
+ });
+ fireEvent.change(screen.getByRole("textbox", { name: "Company Phone (optional)" }), {
+ target: { value: "3145550100" },
+ });
+}
+
+describe("VendorRosterFormFields address autocomplete", () => {
+ it("keeps the picked street when a suggestion whose label differs is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, { withAuth: false });
+
+ const street = screen.getByRole("combobox", { name: "Street Address (required)" });
+ await user.type(street, TYPED_STREET);
+
+ const listbox = await screen.findByRole("listbox");
+ const option = within(listbox)
+ .getAllByRole("option")
+ .find((candidate) => candidate.textContent === FIRST_SUGGESTION.label);
+ expect(option).toBeDefined();
+ expect(FIRST_SUGGESTION.label).not.toBe(FIRST_SUGGESTION.street);
+
+ await user.click(option as HTMLElement);
+
+ await waitFor(() => {
+ expect(street).toHaveValue(FIRST_SUGGESTION.street);
+ });
+ expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(
+ FIRST_SUGGESTION.city,
+ );
+ expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(
+ FIRST_SUGGESTION.state,
+ );
+ });
+
+ it("submits the settled structured parts, not the option label", async () => {
+ const user = userEvent.setup();
+ const onValid = vi.fn();
+ renderWithProviders(, { withAuth: false });
+
+ const street = screen.getByRole("combobox", { name: "Street Address (required)" });
+ await user.type(street, TYPED_STREET);
+ const listbox = await screen.findByRole("listbox");
+ await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement);
+ fillCompanyBasics();
+
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1));
+ const values = onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues;
+ expect(values.address).toBe(FIRST_SUGGESTION.street);
+ expect(values.city).toBe(FIRST_SUGGESTION.city);
+ expect(values.state).toBe(FIRST_SUGGESTION.state);
+ });
+
+ it("clears every structured address part from the autocomplete clear control", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, { withAuth: false });
+
+ const street = screen.getByRole("combobox", { name: "Street Address (required)" });
+ await user.type(street, TYPED_STREET);
+ const listbox = await screen.findByRole("listbox");
+ await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement);
+
+ expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(
+ FIRST_SUGGESTION.city,
+ );
+ expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(
+ FIRST_SUGGESTION.state,
+ );
+ await user.click(screen.getByRole("button", { name: "Clear" }));
+
+ expect(street).toHaveValue("");
+ expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue("");
+ expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue("");
+ });
+});
+
+describe("VendorRosterFormFields map preview empty state", () => {
+ it("shows the accessible placeholder while incomplete, then the map once complete", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, { withAuth: false });
+
+ expect(screen.getByRole("status")).toHaveTextContent(/street, city, and state/i);
+ expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument();
+
+ await user.type(
+ screen.getByRole("combobox", { name: "Street Address (required)" }),
+ TYPED_STREET,
+ );
+ expect(screen.getByRole("status")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
+ target: { value: "Theodore" },
+ });
+ fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), {
+ target: { value: "AL" },
+ });
+
+ expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument();
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx
index b3572218..dd0070b8 100644
--- a/src/test/app/(protected)/vendors/vendors-list.test.tsx
+++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx
@@ -244,6 +244,99 @@ describe("VendorsListPage", () => {
);
});
+ it("shows omitted trade names on the +N badge tooltip on hover and keyboard focus", async () => {
+ setupDefaults();
+ const multiTradeVendor: VendorListItem = {
+ ...activeVendor,
+ tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning",
+ };
+ useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
+ if (params.pageSize === 1) return result([], 1);
+ return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
+ });
+
+ renderWithProviders(, { route: "/vendors", withAuth: false });
+
+ const badge = screen.getByTestId("vendor-trade-badge");
+ expect(badge).toHaveTextContent("+2");
+ expect(screen.getByText("Backflow Preventers")).toBeInTheDocument();
+ expect(badge).toHaveAttribute("aria-label", "+2 more trades: Water Heaters, Drain Cleaning");
+
+ await userEvent.hover(badge);
+ const tooltip = await screen.findByRole("tooltip");
+ expect(tooltip).toHaveTextContent("Water Heaters, Drain Cleaning");
+ expect(tooltip).not.toHaveTextContent(/Backflow/);
+ await userEvent.unhover(badge);
+ await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
+
+ fireEvent.keyDown(document, { key: "Tab" });
+ badge.focus();
+ expect(badge).toHaveFocus();
+ expect(await screen.findByRole("tooltip")).toHaveTextContent("Water Heaters, Drain Cleaning");
+ });
+
+ it("does not show the +N badge when the vendor has a single trade", () => {
+ setupDefaults();
+ useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
+ if (params.pageSize === 1) return result([], 1);
+ return params.isActive ? result([activeVendor], 1) : result([], 0);
+ });
+
+ renderWithProviders(, { route: "/vendors", withAuth: false });
+
+ expect(screen.queryByTestId("vendor-trade-badge")).not.toBeInTheDocument();
+ expect(screen.getByText("Backflow Preventers")).toBeInTheDocument();
+ });
+
+ it("does not open the vendor detail when the +N badge is clicked or activated by keyboard", async () => {
+ setupDefaults();
+ useVendorCompanyRoster.mockReturnValue({
+ data: activeRoster,
+ isLoading: false,
+ isError: false,
+ error: null,
+ refetch: vi.fn(),
+ });
+ const multiTradeVendor: VendorListItem = {
+ ...activeVendor,
+ tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning",
+ };
+ useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
+ if (params.pageSize === 1) return result([], 1);
+ return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
+ });
+
+ renderWithProviders(, { route: "/vendors", withAuth: false });
+
+ const badge = screen.getByTestId("vendor-trade-badge");
+
+ await userEvent.click(badge);
+ expect(screen.queryByRole("heading", { level: 2, name: "Adam Whyte" })).not.toBeInTheDocument();
+
+ badge.focus();
+ await userEvent.keyboard("{Enter}");
+ await userEvent.keyboard("{Space}");
+ expect(screen.queryByRole("heading", { level: 2, name: "Adam Whyte" })).not.toBeInTheDocument();
+ });
+
+ it("relies on the MUI tooltip instead of a native title attribute on the +N badge", () => {
+ setupDefaults();
+ const multiTradeVendor: VendorListItem = {
+ ...activeVendor,
+ tradeSpecialties: "Backflow Preventers, Water Heaters",
+ };
+ useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
+ if (params.pageSize === 1) return result([], 1);
+ return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
+ });
+
+ renderWithProviders(, { route: "/vendors", withAuth: false });
+
+ const badge = screen.getByTestId("vendor-trade-badge");
+ expect(badge).not.toHaveAttribute("title");
+ expect(badge).toHaveAttribute("tabIndex", "0");
+ });
+
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
setupDefaults();
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
diff --git a/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx
new file mode 100644
index 00000000..013dcc2d
--- /dev/null
+++ b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx
@@ -0,0 +1,90 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import {
+ VENDOR_DIALOG_SURFACE_MAX_HEIGHT,
+ VendorDialog,
+} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
+import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
+import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
+import { renderWithProviders } from "@/test/test-utils";
+
+vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
+ useCreateVendor: () => ({
+ mutateAsync: vi.fn(),
+ isPending: false,
+ }),
+}));
+
+const LIVE_VENDOR: VendorDropdownItem = {
+ id: 10,
+ companyName: "Gateway Plumbing",
+ contactName: "Jordan Lee",
+ tradeSpecialties: "Plumbing",
+ address: "900 Live St",
+ distanceMiles: 4.2,
+ email: "dispatch@gateway.test",
+};
+
+describe("VendorDialogCompanyCard map preview queries", () => {
+ it("embeds the live vendor address", () => {
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ const frame = screen.getByTitle("Map of 900 Live St");
+ expect(frame).toHaveAttribute(
+ "src",
+ "https://www.google.com/maps?q=900%20Live%20St&output=embed",
+ );
+ });
+
+ it("embeds the frozen company's composed snapshot address, not the live one", () => {
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ const frame = screen.getByTitle("Map of 200 Vendor Ave, Chicago, IL, 60602");
+ expect(frame).toHaveAttribute(
+ "src",
+ "https://www.google.com/maps?q=200%20Vendor%20Ave%2C%20Chicago%2C%20IL%2C%2060602&output=embed",
+ );
+ expect(screen.queryByTitle("Map of 900 Live St")).not.toBeInTheDocument();
+ });
+});
+
+describe("VendorDialog scroll boundary", () => {
+ it("bounds the dialog surface and scrolls internally", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const surface = screen.getByRole("dialog");
+ expect(VENDOR_DIALOG_SURFACE_MAX_HEIGHT).toBe("55vh");
+ expect(surface.style.maxHeight).toBe(VENDOR_DIALOG_SURFACE_MAX_HEIGHT);
+ expect(surface.style.display).toBe("flex");
+
+ const content = screen.getByRole("dialog").querySelector(".MuiDialogContent-root");
+ expect(content).not.toBeNull();
+ expect(content).toHaveStyle({ overflowY: "auto", minHeight: "0px" });
+ });
+});
diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
index 5b1e50c8..2bcf1cf6 100644
--- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
@@ -152,6 +152,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(within(sheet).getByText("HVAC")).toBeInTheDocument();
expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument();
+ fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
+ fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
expect(applyAdvancedFilters).toHaveBeenCalled();
expect(setAdvOpen).toHaveBeenCalledWith(false);
@@ -341,6 +343,45 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
});
+ it("places Aveta Compliance immediately above Uplift", () => {
+ const view = renderWithProviders(, {
+ route: "/workorders",
+ withAuth: false,
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
+ advOpen = true;
+ view.rerender();
+
+ const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
+ const aveta = within(sheet).getByRole("button", { name: /Aveta Compliance/i });
+ const uplift = within(sheet).getByRole("button", { name: /^Uplift/i });
+ expect(aveta.closest(".border-b")?.nextElementSibling).toBe(uplift.closest(".border-b"));
+ });
+
+ it("keeps Clear all and Apply filters disabled until a filter is selected", () => {
+ const view = renderWithProviders(, {
+ route: "/workorders",
+ withAuth: false,
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
+ advOpen = true;
+ view.rerender();
+
+ const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
+ const clearAll = within(sheet).getByRole("button", { name: /clear all/i });
+ const apply = within(sheet).getByRole("button", { name: /apply filters/i });
+ expect(clearAll).toBeDisabled();
+ expect(apply).toBeDisabled();
+
+ fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
+ fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i }));
+
+ expect(clearAll).toBeEnabled();
+ expect(apply).toBeEnabled();
+ });
+
it("applies Aveta required only through Advanced Filters", () => {
const view = renderWithProviders(, {
route: "/workorders",
diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
index c466867d..c3fb07b9 100644
--- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
@@ -211,7 +211,9 @@ describe("WorkOrdersListHeader slice affordances", () => {
);
expect(screen.getByRole("button", { name: /new wo/i })).toBeInTheDocument();
- expect(screen.getByLabelText("Advanced Filters")).toBeInTheDocument();
+ const filters = screen.getByRole("button", { name: "Advanced Filters" });
+ expect(filters).toBeInTheDocument();
+ expect(filters).toHaveTextContent("Filters");
});
});
diff --git a/src/test/components/common/address-map-preview.test.tsx b/src/test/components/common/address-map-preview.test.tsx
new file mode 100644
index 00000000..c1564424
--- /dev/null
+++ b/src/test/components/common/address-map-preview.test.tsx
@@ -0,0 +1,68 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { AddressMapPreview } from "@/components/common/address-map-preview";
+import { renderWithProviders } from "@/test/test-utils";
+
+const complete = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" };
+
+function mapFrame() {
+ return screen.getByTitle("Map of 6735 Trippel Rd Theodore AL");
+}
+
+describe("AddressMapPreview structured input", () => {
+ it("renders a compact accessible placeholder while the address is incomplete", () => {
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ const placeholder = screen.getByRole("status");
+ expect(placeholder).toHaveTextContent(/street, city, and state/i);
+ expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument();
+ });
+
+ it("transitions from the placeholder to the iframe once the address completes", () => {
+ const { rerender } = renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ expect(screen.getByRole("status")).toBeInTheDocument();
+
+ rerender();
+ const frame = mapFrame();
+ expect(frame).toBeInTheDocument();
+ expect(frame).toHaveAttribute(
+ "src",
+ "https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed",
+ );
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ });
+
+ it("stays blank for incomplete input when the placeholder is not opted into", () => {
+ const { container } = renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ expect(container.querySelector("iframe")).toBeNull();
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ });
+});
+
+describe("AddressMapPreview pre-composed query", () => {
+ it("renders the map only when the saved query is non-empty", () => {
+ const { container, rerender } = renderWithProviders(, {
+ withAuth: false,
+ });
+
+ expect(screen.getByTitle("Map of 900 Live St")).toBeInTheDocument();
+
+ rerender();
+ expect(container.querySelector("iframe")).toBeNull();
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/components/ui/collapsible-section.test.tsx b/src/test/components/ui/collapsible-section.test.tsx
new file mode 100644
index 00000000..0d1d1994
--- /dev/null
+++ b/src/test/components/ui/collapsible-section.test.tsx
@@ -0,0 +1,16 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { CollapsibleSection } from "@/components/ui/collapsible-section";
+import { renderWithProviders } from "@/test/test-utils";
+
+describe("CollapsibleSection", () => {
+ it("renders the section title at the 14px body size token", () => {
+ renderWithProviders(child, {
+ withAuth: false,
+ });
+
+ const title = screen.getByRole("button", { name: /Aveta Compliance/i }).querySelector("span");
+ expect(title).toHaveClass("text-[length:var(--text-base)]");
+ });
+});
diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts
index 65494d15..4c9a463f 100644
--- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts
+++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts
@@ -66,7 +66,7 @@ describe("vendor roster mapper", () => {
name: "Gateway Plumbing",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
- googleMapsUrl: "https://maps.google.com/x",
+ googleMapsUrl: "http://maps.google.com/legacy",
tradeSpecialties: "Plumbing",
rowVersion: "rv-2",
technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }],
@@ -82,6 +82,9 @@ describe("vendor roster mapper", () => {
expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" }),
]);
expect(payload).not.toHaveProperty("tradeSpecialties");
+ // The stored Maps URL is deprecated: a legacy value reaching the mapper
+ // (from stale callers or old records) must not be emitted to the backend.
+ expect(payload).not.toHaveProperty("googleMapsUrl");
});
it("omits rowVersion from the create payload when absent", () => {
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 d5772d1a..da7bcff4 100644
--- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts
+++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts
@@ -3,7 +3,6 @@ import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
emptyRosterTechnician,
emptyVendorCompanyRosterForm,
- isAbsoluteHttpsUrl,
VENDOR_NOTES_MAX_LENGTH,
vendorCompanyRosterSchema,
vendorCompanyRosterUpdateSchema,
@@ -14,6 +13,11 @@ const baseCompany = {
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
+ // SH-271 made the structured address required, so a company that is meant to
+ // parse cleanly has to carry one.
+ address: "6735 Trippel Rd",
+ city: "Theodore",
+ state: "AL",
};
describe("vendorCompanyRosterSchema", () => {
@@ -56,9 +60,11 @@ describe("vendorCompanyRosterSchema", () => {
it("accepts a company with zero technicians when phone is present", () => {
const result = vendorCompanyRosterSchema.safeParse({
- ...emptyVendorCompanyRosterForm,
+ ...baseCompany,
name: "Solo Co",
+ email: "",
companyPhone: "(314) 555-0100",
+ technicians: [],
});
expect(result.success).toBe(true);
if (result.success) expect(result.data).not.toHaveProperty("companyContact");
@@ -98,21 +104,26 @@ describe("vendorCompanyRosterSchema", () => {
expect(result.success).toBe(true);
});
- it("rejects malformed emails, non-HTTPS map URLs, and partial phones", () => {
+ it("rejects malformed emails and partial phones", () => {
const result = vendorCompanyRosterSchema.safeParse({
...baseCompany,
email: "not-an-email",
- googleMapsUrl: "http://maps.google.com/x",
technicians: [{ ...emptyRosterTechnician, contactName: "Adam", phone: "(314)" }],
});
expect(result.success).toBe(false);
});
- it("rejects a maps URL that is a non-absolute https prefix string", () => {
- expect(isAbsoluteHttpsUrl("https://")).toBe(false);
- expect(isAbsoluteHttpsUrl("maps.google.com/x")).toBe(false);
- expect(isAbsoluteHttpsUrl("https://maps.google.com/x")).toBe(true);
- expect(isAbsoluteHttpsUrl("")).toBe(true);
+ it("tolerates a legacy stored Maps URL instead of failing HTTPS validation", () => {
+ // Legacy records carry a stored googleMapsUrl — often plain HTTP. The form
+ // no longer holds the field, so leftover values loaded from old data must
+ // neither fail validation nor re-enter the parsed output.
+ const result = vendorCompanyRosterSchema.safeParse({
+ ...baseCompany,
+ googleMapsUrl: "http://maps.google.com/legacy",
+ });
+
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.data).not.toHaveProperty("googleMapsUrl");
});
it("rejects an unknown preferred contact", () => {
diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx
index f03c2a27..aaec2167 100644
--- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx
+++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx
@@ -74,7 +74,6 @@ function formValues(
city: roster.city,
state: roster.state,
zip: roster.zip,
- googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
technicians: [newTechnician],
...overrides,
diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts
index f0547378..735ef2d7 100644
--- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts
+++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts
@@ -36,7 +36,6 @@ const values = {
city: roster.city,
state: roster.state,
zip: roster.zip,
- googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
technicians: roster.technicians.map((technician) => ({
id: technician.id,
diff --git a/src/test/lib/address/vendor-address.test.ts b/src/test/lib/address/vendor-address.test.ts
new file mode 100644
index 00000000..4a2736dc
--- /dev/null
+++ b/src/test/lib/address/vendor-address.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import {
+ ADDRESS_SUGGESTION_LIMIT,
+ ADDRESS_SUGGESTION_MIN_LENGTH,
+ buildAddressQuery,
+ hasCompleteAddress,
+ mapsEmbedUrl,
+ mapsSearchUrl,
+ suggestAddresses,
+} from "@/lib/address/vendor-address";
+
+describe("suggestAddresses", () => {
+ it("offers nothing until the minimum length is reached", () => {
+ expect(suggestAddresses("")).toEqual([]);
+ expect(suggestAddresses("12")).toEqual([]);
+ expect(suggestAddresses(" 12 ")).toEqual([]);
+ expect(suggestAddresses("123").length).toBeGreaterThan(0);
+ });
+
+ it("caps the list so the dropdown stays scannable", () => {
+ expect(suggestAddresses("6735 Trippel Rd").length).toBeLessThanOrEqual(
+ ADDRESS_SUGGESTION_LIMIT,
+ );
+ });
+
+ it("keeps the typed text as the street and only proposes city and state", () => {
+ const [first] = suggestAddresses("415 Murphy Rd");
+ expect(first?.street).toBe("415 Murphy Rd");
+ expect(first?.city).toBeTruthy();
+ expect(first?.state).toBeTruthy();
+ expect(first?.label).toBe(`415 Murphy Rd, ${first?.city}, ${first?.state}`);
+ });
+
+ it("is stable for the same input", () => {
+ // The list must not reshuffle between renders, or a row moves out from
+ // under the pointer mid-click.
+ expect(suggestAddresses("4255 Anson Blvd")).toEqual(suggestAddresses("4255 Anson Blvd"));
+ });
+
+ it("never repeats a city and state pair", () => {
+ const suggestions = suggestAddresses("3050 Gateway Commerce Dr");
+ const keys = suggestions.map((s) => `${s.city},${s.state}`);
+ expect(new Set(keys).size).toBe(keys.length);
+ });
+
+ it("trims the typed text before using it", () => {
+ const [first] = suggestAddresses(" 1715 Wall Triana Hwy ");
+ expect(first?.street).toBe("1715 Wall Triana Hwy");
+ });
+
+ it("exposes the minimum length it enforces", () => {
+ expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH))).not.toEqual([]);
+ expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH - 1))).toEqual([]);
+ });
+});
+
+describe("address queries and links", () => {
+ const full = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" };
+
+ it("joins the parts it has and skips the ones it does not", () => {
+ expect(buildAddressQuery(full)).toBe("6735 Trippel Rd Theodore AL");
+ expect(buildAddressQuery({ street: "", city: "Dallas", state: "TX" })).toBe("Dallas TX");
+ expect(buildAddressQuery({})).toBe("");
+ });
+
+ it("builds a search link and an embed link for the same address", () => {
+ expect(mapsSearchUrl(full)).toBe(
+ "https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL",
+ );
+ expect(mapsEmbedUrl(full)).toBe(
+ "https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed",
+ );
+ });
+
+ it("returns empty strings rather than a link to nowhere", () => {
+ // An href of "" renders as no link; a link to a bare maps search would look
+ // like an address we do not have.
+ expect(mapsSearchUrl({})).toBe("");
+ expect(mapsEmbedUrl({ street: " " })).toBe("");
+ });
+
+ it("needs all three parts before a preview is meaningful", () => {
+ expect(hasCompleteAddress(full)).toBe(true);
+ expect(hasCompleteAddress({ street: "6735 Trippel Rd", city: "Theodore", state: " " })).toBe(
+ false,
+ );
+ expect(hasCompleteAddress({ street: "6735 Trippel Rd" })).toBe(false);
+ });
+});