feat(vendors): structure the address into Street/City/State with autocomplete (#176)

* feat(vendors): structure the address into Street/City/State with autocomplete

The Vendor form carried one free-text "Address (optional)" line, with City,
State and Zip already present but hidden behind display:none, and a separate
"Google Maps URL" input someone had to paste into by hand.

Street Address, City and State are now three required fields. Typing three
characters in Street Address offers up to four candidates; picking one fills
all three at once, and typing without picking stays plain free text. The
Google Maps URL input is gone — the location is derived from the address, the
Street Address itself is the link in view mode, and a keyless map preview
renders once all three parts are present. Zip stays in the payload but out of
the form; the ticket scopes the visible set to three.

The suggestion algorithm, candidate cities and copy are ported from the
approved prototype rather than invented, so dev and design agree on what a
dispatcher sees. Suggestions are deterministic for a given input on purpose:
a reshuffling list moves a row out from under the pointer mid-click.

Test fixtures that predate the requirement now carry an address, so each test
still fails for the reason it is about. The two drawer tests asserting the
stored-URL "Open in Google Maps" row are rewritten to the behaviour that
replaced it.

Delivers SH-271.

* feat(work-orders): show the vendor location map in the Vendor dialog

The last of SH-271's five acceptance bullets. The Work Order Vendor dialog
gets one composed address line from the vendor dropdown payload, not the
structured parts the form and detail drawer hold, so the preview takes the
line directly — it is saved data either way, and the completeness rule exists
to stop a map of half-typed input, not to reject a stored address.

The dialog's hand-built maps URL now goes through the shared helper, so the
link and the preview cannot drift apart.

* test(vendors): update the browser specs and pixel baselines for the new address

`npm run verify` does not run Playwright, so the first push went out with the
browser suite still asserting the UI this ticket removes. Three assertions
were stale: the combined "Address (optional)" input, the "Google Maps URL
(optional)" input, and the detail drawer's separate "Open in Google Maps"
row — now replaced by the Street Address itself being the link, checked
against the derived href. A fourth test created a vendor without an address,
which the new requirement blocks; it fills one, so the test still fails only
for the reason it is about.

Baselines regenerated in mcr.microsoft.com/playwright:v1.61.1-noble, the image
CI uses — macOS font rendering produces different pixels. Exactly three of the
sixteen were rewritten (vendor add, edit, detail); the rest, including every
Work Orders shot, are byte-identical, which is the evidence that this change
stays inside the surfaces it claims.

* test(vendors): keep the map preview out of the pixel baselines

The visual suite mocks `**/api/**` and nothing else, so the address map
preview's iframe reached maps.google.com for real. Whether that frame paints,
and what it paints, depends on the network and on what Google serves that
minute — which is why `vendor-edit` failed in CI at 18178 differing pixels
while passing in a container that could not reach Google. Regenerating the
baseline would not have fixed it; it would have moved the flake.

Aborting the request pins the frame to a blank box, so the shot measures our
layout and nothing else. The committed baselines are unchanged by this — they
were already correct — and a second container run with no --update passes
16/16, which is the evidence the shot is now stable rather than merely green
once.

* fix(vendors): complete structured address map flows

* test(vendors): align add visual baseline with CI

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
This commit is contained in:
Alexandre Brandizzi 2026-09-15 16:13:15 -03:00 • committed by GitHub
parent 3f3af07f8e
commit 80be702733
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 973 additions and 108 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View file

@ -587,10 +587,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("");
@ -643,9 +649,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();
@ -693,6 +705,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);
});
});