diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts
index a8bf963a..b8342ed7 100644
--- a/e2e/vendors/vendors.spec.ts
+++ b/e2e/vendors/vendors.spec.ts
@@ -668,10 +668,16 @@ test.describe("Vendor directory prototype parity", () => {
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
- await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
- await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
- "https://maps.google.com/gateway",
+ // SH-271: one combined "Address (optional)" line became three required
+ // fields, and the hand-pasted Google Maps URL input is gone — the location
+ // is derived from the address now.
+ await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue(
+ "1 Market St",
);
+ await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis");
+ await expect(page.getByLabel("State (required)")).toHaveValue("MO");
+ await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0);
+ await expect(page.getByLabel("Address (optional)")).toHaveCount(0);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1);
await expect(page.getByLabel("Technician name (optional)")).toHaveValue("");
@@ -724,9 +730,15 @@ test.describe("Vendor directory prototype parity", () => {
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
- await expect(
- detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
- ).toHaveAttribute("target", "_blank");
+ // SH-271: the separate "Open in Google Maps" row is gone — the Street
+ // Address itself is the link, built from the structured parts.
+ const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first();
+ await expect(streetLink).toHaveAttribute("target", "_blank");
+ await expect(streetLink).toHaveAttribute(
+ "href",
+ "https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO",
+ );
+ await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
@@ -774,6 +786,12 @@ test.describe("Vendor directory prototype parity", () => {
).toBeEnabled();
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
+ // SH-271 made the structured address required. This test is about keyboard
+ // dismissal and free-text company creation, so the address is filled to
+ // leave those as the only things it is exercising.
+ await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St");
+ await page.getByLabel("City (required)").fill("St. Louis");
+ await page.getByLabel("State (required)").fill("MO");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts
index 9808ae49..005c9da0 100644
--- a/e2e/vendors/vendors.visual.spec.ts
+++ b/e2e/vendors/vendors.visual.spec.ts
@@ -70,6 +70,13 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
+ // The vendor address map preview embeds maps.google.com in an iframe. Its
+ // content depends on the network, on timing, and on whatever Google serves
+ // that minute, none of which belongs in a test with "deterministic pixel
+ // regression" in its name. Aborting the request pins the frame to a blank
+ // box, so the shot measures our layout and nothing else.
+ await page.route(/https:\/\/(www\.)?google\.com\/maps/, (route) => route.abort());
+
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts
index 8aea10f2..aaf1b606 100644
--- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts
+++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts
@@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [
"city",
"state",
"zip",
- "googleMapsUrl",
"notes",
] as const;
diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts
index 24d51ce8..a472546e 100644
--- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts
+++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts
@@ -40,7 +40,8 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo
city: roster.city,
state: roster.state,
zip: roster.zip,
- googleMapsUrl: roster.googleMapsUrl,
+ // The deprecated stored Maps URL is intentionally not seeded: the form has
+ // no input for it, and carrying it would re-emit a legacy value on write.
notes: roster.notes,
technicians: roster.technicians.map((technician) => ({
id: technician.id,
diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
index 49251a83..072bddb7 100644
--- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
+++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
@@ -2,7 +2,6 @@ import { useEffect, useState, type ReactNode } from "react";
import { useWatch } from "react-hook-form";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
-import LaunchIcon from "@mui/icons-material/Launch";
import {
Alert,
Box,
@@ -22,6 +21,8 @@ import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
import { useVendorRosterForm } from "./use-vendor-roster-form";
import { Text } from "@/components/ui/text";
+import { AddressMapPreview } from "@/components/common/address-map-preview";
+import { mapsSearchUrl } from "@/lib/address/vendor-address";
import type {
VendorCompanyRoster,
VendorListItem,
@@ -118,34 +119,43 @@ function DrawerHeader({
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
- const address = [roster.address, roster.city, roster.state, roster.zip]
- .filter(Boolean)
- .join(", ");
+ // SH-271 replaced the separate "Open in Google Maps" row: the Street Address
+ // itself is the link now, and the location is derived from the structured
+ // parts rather than a stored URL.
+ const address = { street: roster.address, city: roster.city, state: roster.state };
+ const mapsUrl = mapsSearchUrl(address);
return (
-
- Google Maps
+ Street Address
- {roster.googleMapsUrl ? (
+ {roster.address && mapsUrl ? (
-
- Open in Google Maps
+ {roster.address}
) : (
- —
+ {roster.address || "—"}
)}
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx
index 1878b417..cf23d357 100644
--- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx
+++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx
@@ -1,5 +1,6 @@
import {
Controller,
+ useController,
useFieldArray,
useWatch,
type Control,
@@ -21,6 +22,8 @@ import {
Typography,
} from "@mui/material";
import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field";
+import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
+import { AddressMapPreview } from "@/components/common/address-map-preview";
import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
import {
VENDOR_NOTES_MAX_LENGTH,
@@ -135,6 +138,9 @@ function CompanyFields({
onClearSelectedCompany,
}: VendorRosterFormFieldsProps) {
const notes = useWatch({ control, name: "notes" }) ?? "";
+ const { field: streetField } = useController({ control, name: "address" });
+ const { field: cityField } = useController({ control, name: "city" });
+ const { field: stateField } = useController({ control, name: "state" });
return (
@@ -148,50 +154,58 @@ function CompanyFields({
onClearSelectedCompany={onClearSelectedCompany}
/>
- (
-
- )}
+ {
+ // One pick fills all three. City and State are written through
+ // their own controllers so react-hook-form marks them dirty and
+ // revalidates — a plain assignment would be discarded on save.
+ streetField.onChange(parts.street);
+ cityField.onChange(parts.city);
+ stateField.onChange(parts.state);
+ }}
+ label="Street Address (required)"
+ required
+ error={Boolean(errors.address)}
+ helperText={
+ errors.address?.message ??
+ "Pick a suggestion to auto-fill City/State, or type your own."
+ }
/>
-
- }
+
+
- (
-
- )}
- />
- }
+
(
-
- )}
+ name="zip"
+ render={({ field }) => }
+ />
+
Open in Google Maps
)}
+ {/*
+ The card holds one composed address line for both live and frozen
+ companies, so the preview is given the line directly rather than
+ structured parts; it is saved data either way.
+ */}
+
);
}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx
index 8fca16a0..c9bf6b21 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx
@@ -11,6 +11,13 @@ import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-ord
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
+/**
+ * Height bound for the dialog surface. The company card grew with the map
+ * preview, so the dialog itself — not just the card — must cap its height and
+ * scroll internally or it outgrows short viewports.
+ */
+export const VENDOR_DIALOG_SURFACE_MAX_HEIGHT = "55vh";
+
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -56,12 +63,22 @@ export function VendorDialog({
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
- style: { borderRadius: 12, maxWidth: 560 },
+ style: {
+ borderRadius: 12,
+ maxWidth: 560,
+ maxHeight: VENDOR_DIALOG_SURFACE_MAX_HEIGHT,
+ display: "flex",
+ flexDirection: "column",
+ },
onClick: (e: MouseEvent) => e.stopPropagation(),
},
}}
>
- e.stopPropagation()}>
+ e.stopPropagation()}
+ style={{ overflowY: "auto", minHeight: 0 }}
+ >
Vendor
diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx
new file mode 100644
index 00000000..30e344c6
--- /dev/null
+++ b/src/components/common/address-autocomplete-field.tsx
@@ -0,0 +1,88 @@
+import { Autocomplete, TextField } from "@mui/material";
+import {
+ suggestAddresses,
+ type AddressParts,
+ type AddressSuggestion,
+} from "@/lib/address/vendor-address";
+
+type AddressAutocompleteFieldProps = {
+ value: string;
+ onInputChange: (value: string) => void;
+ /** Fired only when a candidate row is chosen, never while typing. */
+ onSelect: (parts: AddressParts) => void;
+ label?: string;
+ placeholder?: string;
+ required?: boolean;
+ error?: boolean;
+ helperText?: string;
+ disabled?: boolean;
+};
+
+/**
+ * Street Address input with mocked City/State completion.
+ *
+ * `freeSolo` is the whole point: typing without picking a row is a valid way to
+ * finish the field. The candidates are a convenience for filling City and State
+ * in one action, not a constraint on what the address may be.
+ *
+ * Shared rather than local to Vendors because Sites needs the identical control
+ * when it is built (SH-273).
+ */
+export function AddressAutocompleteField({
+ value,
+ onInputChange,
+ onSelect,
+ label = "Street Address",
+ placeholder = "Start typing the street address…",
+ required = false,
+ error = false,
+ helperText,
+ disabled = false,
+}: AddressAutocompleteFieldProps) {
+ return (
+
+ freeSolo
+ disabled={disabled}
+ options={suggestAddresses(value)}
+ // The options are already derived from the input; letting MUI filter them
+ // again would drop every row whose label does not literally contain the
+ // typed text.
+ filterOptions={(options) => options}
+ inputValue={value}
+ onInputChange={(_event, next, reason) => {
+ // Genuine typing and the clear control update the street. MUI also
+ // fires this callback with reason "reset" after a selection, carrying
+ // the option's full ", , " label — forwarding
+ // that would overwrite the just-picked street with the label.
+ if (reason !== "input" && reason !== "clear") return;
+ onInputChange(next);
+ }}
+ onChange={(_event, selected) => {
+ if (selected && typeof selected !== "string") {
+ onSelect({ street: selected.street, city: selected.city, state: selected.state });
+ return;
+ }
+
+ if (selected === null) onSelect({ street: "", city: "", state: "" });
+ }}
+ getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
+ renderInput={(params) => (
+
+ )}
+ />
+ );
+}
diff --git a/src/components/common/address-map-preview.tsx b/src/components/common/address-map-preview.tsx
new file mode 100644
index 00000000..5d1892ce
--- /dev/null
+++ b/src/components/common/address-map-preview.tsx
@@ -0,0 +1,99 @@
+import { Box } from "@mui/material";
+import { Text } from "@/components/ui/text";
+import {
+ buildAddressQuery,
+ hasCompleteAddress,
+ mapsEmbedUrl,
+ type AddressParts,
+} from "@/lib/address/vendor-address";
+
+type AddressMapPreviewProps = {
+ /** Structured parts, as held by a form or a detail view. */
+ address?: Partial;
+ /**
+ * An already-composed address line, for callers that hold saved data rather
+ * than live input — the Work Order vendor dialog gets one string from the
+ * vendor dropdown payload. Ignored when `address` is given.
+ */
+ query?: string;
+ height?: number;
+ /**
+ * Render a compact accessible placeholder while the structured address is
+ * incomplete, instead of nothing. Meant for Add/Edit forms, where the empty
+ * state tells the dispatcher which parts the preview is still waiting on.
+ */
+ placeholderWhenIncomplete?: boolean;
+};
+
+const INCOMPLETE_PLACEHOLDER_MESSAGE =
+ "Map preview appears once Street, City, and State are all filled.";
+
+/**
+ * Small inline map for a structured address.
+ *
+ * Uses Google's keyless `output=embed` form deliberately: SH-271 asks for a
+ * preview with no API key, so there is no Maps JavaScript API, no Places
+ * request and no billing account behind this.
+ *
+ * Structured input renders nothing until Street, City and State are all
+ * present — a preview of a half-typed address points somewhere misleading,
+ * which is worse than no map. Add/Edit forms opt into a compact placeholder
+ * for that incomplete state; a pre-composed saved line is map-only and renders
+ * only when non-empty.
+ */
+export function AddressMapPreview({
+ address,
+ query: rawQuery,
+ height = 160,
+ placeholderWhenIncomplete = false,
+}: AddressMapPreviewProps) {
+ // Structured input is held to the completeness rule; a pre-composed line is
+ // saved data and only has to be non-empty.
+ const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim();
+ const complete = address ? hasCompleteAddress(address) : Boolean(query);
+
+ if (!complete) {
+ if (!(address != null && placeholderWhenIncomplete)) return null;
+ return (
+
+
+ {INCOMPLETE_PLACEHOLDER_MESSAGE}
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
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)/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/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/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);
+ });
+});