feat(vendors): merge dev after address changes

This commit is contained in:
Codex Review Integration 2026-09-15 16:15:04 -03:00
commit fa1f4467e2
25 changed files with 973 additions and 108 deletions

View file

@ -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");

View file

@ -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());

View file

@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [
"city",
"state",
"zip",
"googleMapsUrl",
"notes",
] as const;

View file

@ -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,

View file

@ -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 (
<Stack spacing={1.5}>
<Field label="Company" value={roster.name} />
<Field label="Company Phone" value={roster.companyPhone} />
<Field label="Company Email" value={roster.email} />
<Field label="Address" value={address} />
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
Google Maps
Street Address
</Text>
{roster.googleMapsUrl ? (
{roster.address && mapsUrl ? (
<Link
href={roster.googleMapsUrl}
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
sx={{ "&:hover": { textDecoration: "underline" } }}
>
<LaunchIcon fontSize="small" />
Open in Google Maps
{roster.address}
</Link>
) : (
<Text variant="description">—</Text>
<Text variant="description">{roster.address || "—"}</Text>
)}
</Box>
<Stack direction="row" spacing={2}>
<Box sx={{ flex: 1 }}>
<Field label="City" value={roster.city} />
</Box>
<Box sx={{ flex: 1 }}>
<Field label="State" value={roster.state} />
</Box>
</Stack>
<AddressMapPreview address={address} />
</Stack>
);
}

View file

@ -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 (
<Box>
@ -148,50 +154,58 @@ function CompanyFields({
onClearSelectedCompany={onClearSelectedCompany}
/>
<VendorCompanyContactFields control={control} errors={errors} />
<Controller
control={control}
name="address"
render={({ field }) => (
<TextField
{...field}
label="Address (optional)"
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
fullWidth
/>
)}
<AddressAutocompleteField
value={streetField.value ?? ""}
onInputChange={streetField.onChange}
onSelect={(parts) => {
// 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."
}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
<Controller
control={control}
name="city"
render={({ field }) => <TextField {...field} label="City" fullWidth />}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...cityField}
value={cityField.value ?? ""}
label="City (required)"
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
slotProps={{ htmlInput: { required: true } }}
/>
<Controller
control={control}
name="state"
render={({ field }) => (
<TextField {...field} label="State" placeholder="e.g., MO" fullWidth />
)}
/>
<Controller
control={control}
name="zip"
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
<TextField
{...stateField}
value={stateField.value ?? ""}
label="State (required)"
placeholder="e.g., MO"
error={Boolean(errors.state)}
helperText={errors.state?.message}
fullWidth
slotProps={{ htmlInput: { required: true } }}
/>
</Stack>
<Controller
control={control}
name="googleMapsUrl"
render={({ field }) => (
<TextField
{...field}
label="Google Maps URL (optional)"
placeholder="https://maps.google.com/…"
error={Boolean(errors.googleMapsUrl)}
helperText={errors.googleMapsUrl?.message}
fullWidth
/>
)}
name="zip"
render={({ field }) => <TextField {...field} label="Zip" sx={{ display: "none" }} />}
/>
<AddressMapPreview
address={{
street: streetField.value ?? "",
city: cityField.value ?? "",
state: stateField.value ?? "",
}}
placeholderWhenIncomplete
/>
<Controller
control={control}

View file

@ -1,4 +1,5 @@
import { ExternalLink, Mail, MapPin, Phone, User } from "lucide-react";
import { AddressMapPreview } from "@/components/common/address-map-preview";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
@ -122,6 +123,12 @@ export function VendorDialogCompanyCard({
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
{/*
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.
*/}
<AddressMapPreview query={model.address} height={140} />
</div>
);
}

View file

@ -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<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogContent
className="p-6"
onClick={(e) => e.stopPropagation()}
style={{ overflowY: "auto", minHeight: 0 }}
>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Vendor
</DialogTitle>

View file

@ -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 (
<Autocomplete<AddressSuggestion, false, false, true>
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 "<street>, <city>, <state>" 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) => (
<TextField
{...params}
label={label}
placeholder={placeholder}
error={error}
helperText={helperText}
fullWidth
// Required is set on the input itself, never via MUI's `required`
// prop: this codebase spells the requirement in the label text and
// asserts that no generated asterisk renders anywhere.
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps?.htmlInput, required },
}}
/>
)}
/>
);
}

View file

@ -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<AddressParts>;
/**
* 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 (
<Box
role="status"
sx={{
borderRadius: 1,
border: "1px dashed",
borderColor: "divider",
minHeight: 56,
display: "flex",
alignItems: "center",
justifyContent: "center",
px: 1.5,
py: 1,
}}
>
<Text variant="caption" tone="muted" sx={{ textAlign: "center" }}>
{INCOMPLETE_PLACEHOLDER_MESSAGE}
</Text>
</Box>
);
}
return (
<Box
sx={{
borderRadius: 1,
overflow: "hidden",
border: 1,
borderColor: "divider",
lineHeight: 0,
}}
>
<Box
component="iframe"
title={`Map of ${query}`}
src={mapsEmbedUrl({ street: query })}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
sx={{ display: "block", width: "100%", height, border: 0 }}
/>
</Box>
);
}

View file

@ -123,7 +123,8 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
// The stored Maps URL is deprecated: reads keep it for legacy records, but
// roster writes must not carry it back to the backend.
notes: readString(item, "notes", "Notes"),
technicians: Array.isArray(techniciansRaw)
? techniciansRaw.map(mapRosterTechnicianToBackend)

View file

@ -8,21 +8,6 @@ const preferredContactEnum = z.enum(
VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]],
);
export function isAbsoluteHttpsUrl(value: string): boolean {
if (value.trim() === "") return true;
try {
const parsed = new URL(value);
return parsed.protocol === "https:";
} catch {
return false;
}
}
const httpsUrl = z
.string()
.trim()
.refine(isAbsoluteHttpsUrl, "Google Maps URL must be a valid HTTPS link");
export function isValidNorthAmericanPhone(value: string): boolean {
if (value === "") return true;
const digits = value.replace(/\D+/g, "");
@ -57,11 +42,15 @@ const baseCompanyFields = {
companyContact: z.never().optional(),
companyPhone: northAmericanPhone,
email: optionalEmail,
address: z.string(),
city: z.string(),
state: z.string(),
// SH-271: the single combined Address line became three structured parts,
// all required, and the stored Google Maps URL was retired from the form.
// `googleMapsUrl` stays on the read models for legacy records but is not a
// form value: loading it would validate and re-emit a deprecated field the
// user can no longer see or edit.
address: z.string().trim().min(1, "Street Address is required"),
city: z.string().trim().min(1, "City is required"),
state: z.string().trim().min(1, "State is required"),
zip: z.string(),
googleMapsUrl: httpsUrl,
notes: z
.string()
.max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
@ -117,7 +106,6 @@ export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = {
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [],
};

View file

@ -26,6 +26,9 @@ export interface SaveVendorCompanyRosterContext {
conflict?: unknown;
}
// `googleMapsUrl` is deliberately absent: it is a deprecated read-only field,
// so neither the additive companyFields patch nor the status-only diff may
// ever transmit it.
const COMPANY_FIELDS = [
"name",
"companyPhone",
@ -34,7 +37,6 @@ const COMPANY_FIELDS = [
"city",
"state",
"zip",
"googleMapsUrl",
"notes",
] as const;

View file

@ -0,0 +1,135 @@
/**
* Structured address helpers shared by Vendors and, when it is built, Sites.
*
* The suggestion list is deliberately MOCKED — SH-271 asks for a real-feeling
* autocomplete with no Google Places call and no API key. The algorithm,
* candidate pool and copy are ported from the approved prototype so dev and
* design agree on what a dispatcher sees.
*/
export type AddressParts = {
street: string;
city: string;
state: string;
};
export type AddressSuggestion = AddressParts & {
/** What the option row reads as: "<typed street>, <city>, <state>". */
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<Omit<AddressParts, "street">> = [
{ 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<string>();
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<AddressParts>): 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<AddressParts>): 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<AddressParts>): 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<AddressParts>): boolean {
return Boolean(parts.street?.trim() && parts.city?.trim() && parts.state?.trim());
}

View file

@ -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<HTMLFormElement>);
});
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 = {

View file

@ -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();

View file

@ -49,9 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
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(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
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(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
@ -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", () => {

View file

@ -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<VendorCompanyRosterFormValues>({
defaultValues: emptyVendorCompanyRosterForm,
resolver: zodResolver(vendorCompanyRosterSchema),
mode: "onChange",
});
return (
<form onSubmit={handleSubmit(onValid)}>
<VendorRosterFormFields control={control} errors={formState.errors} />
<button type="submit">Submit</button>
</form>
);
}
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(<AddressFormHarness onValid={vi.fn()} />, { 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(<AddressFormHarness onValid={onValid} />, { 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(<AddressFormHarness onValid={vi.fn()} />, { 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(<AddressFormHarness onValid={vi.fn()} />, { 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();
});
});

View file

@ -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(
<VendorDialogCompanyCard company="Gateway Plumbing" vendor={LIVE_VENDOR} />,
{ 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(
<VendorDialogCompanyCard
company="Frozen Vendor"
vendor={LIVE_VENDOR}
frozenCompany={{
name: "Frozen Vendor",
address: "200 Vendor Ave",
city: "Chicago",
state: "IL",
zip: "60602",
}}
/>,
{ 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(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId={10}
company="Gateway Plumbing"
tech="Jordan Lee"
vendors={[LIVE_VENDOR]}
onSave={vi.fn()}
/>,
);
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" });
});
});

View file

@ -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(
<AddressMapPreview address={{ street: "6735 Trippel Rd" }} placeholderWhenIncomplete />,
{ 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(
<AddressMapPreview
address={{ street: "6735 Trippel Rd", city: "Theodore" }}
placeholderWhenIncomplete
/>,
{ withAuth: false },
);
expect(screen.getByRole("status")).toBeInTheDocument();
rerender(<AddressMapPreview address={complete} placeholderWhenIncomplete />);
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(
<AddressMapPreview address={{ street: "6735 Trippel Rd" }} />,
{ 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(<AddressMapPreview query="900 Live St" />, {
withAuth: false,
});
expect(screen.getByTitle("Map of 900 Live St")).toBeInTheDocument();
rerender(<AddressMapPreview query=" " />);
expect(container.querySelector("iframe")).toBeNull();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -74,7 +74,6 @@ function formValues(
city: roster.city,
state: roster.state,
zip: roster.zip,
googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
technicians: [newTechnician],
...overrides,

View file

@ -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,

View file

@ -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);
});
});