diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index a8bf963a..b8342ed7 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -668,10 +668,16 @@ test.describe("Vendor directory prototype parity", () => { await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( "dispatch@gateway.test", ); - await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St"); - await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue( - "https://maps.google.com/gateway", + // SH-271: one combined "Address (optional)" line became three required + // fields, and the hand-pasted Google Maps URL input is gone — the location + // is derived from the address now. + await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue( + "1 Market St", ); + await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis"); + await expect(page.getByLabel("State (required)")).toHaveValue("MO"); + await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0); + await expect(page.getByLabel("Address (optional)")).toHaveCount(0); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1); await expect(page.getByLabel("Technician name (optional)")).toHaveValue(""); @@ -724,9 +730,15 @@ test.describe("Vendor directory prototype parity", () => { await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); - await expect( - detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(), - ).toHaveAttribute("target", "_blank"); + // SH-271: the separate "Open in Google Maps" row is gone — the Street + // Address itself is the link, built from the structured parts. + const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first(); + await expect(streetLink).toHaveAttribute("target", "_blank"); + await expect(streetLink).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO", + ); + await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0); await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); @@ -774,6 +786,12 @@ test.describe("Vendor directory prototype parity", () => { ).toBeEnabled(); await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC"); await page.getByLabel("Company Phone (optional)").fill("3145550199"); + // SH-271 made the structured address required. This test is about keyboard + // dismissal and free-text company creation, so the address is filled to + // leave those as the only things it is exercising. + await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St"); + await page.getByLabel("City (required)").fill("St. Louis"); + await page.getByLabel("State (required)").fill("MO"); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts index 9808ae49..005c9da0 100644 --- a/e2e/vendors/vendors.visual.spec.ts +++ b/e2e/vendors/vendors.visual.spec.ts @@ -70,6 +70,13 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = " await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z")); await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + // The vendor address map preview embeds maps.google.com in an iframe. Its + // content depends on the network, on timing, and on whatever Google serves + // that minute, none of which belongs in a test with "deterministic pixel + // regression" in its name. Aborting the request pins the frame to a blank + // box, so the shot measures our layout and nothing else. + await page.route(/https:\/\/(www\.)?google\.com\/maps/, (route) => route.abort()); + await page.route("**/api/**", async (route) => { const request = route.request(); const url = new URL(request.url()); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 8aea10f2..aaf1b606 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [ "city", "state", "zip", - "googleMapsUrl", "notes", ] as const; diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index 24d51ce8..a472546e 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -40,7 +40,8 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, + // The deprecated stored Maps URL is intentionally not seeded: the form has + // no input for it, and carrying it would re-emit a legacy value on write. notes: roster.notes, technicians: roster.technicians.map((technician) => ({ id: technician.id, diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 49251a83..072bddb7 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -2,7 +2,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { useWatch } from "react-hook-form"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import LaunchIcon from "@mui/icons-material/Launch"; import { Alert, Box, @@ -22,6 +21,8 @@ import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; import { useVendorRosterForm } from "./use-vendor-roster-form"; import { Text } from "@/components/ui/text"; +import { AddressMapPreview } from "@/components/common/address-map-preview"; +import { mapsSearchUrl } from "@/lib/address/vendor-address"; import type { VendorCompanyRoster, VendorListItem, @@ -118,34 +119,43 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const address = [roster.address, roster.city, roster.state, roster.zip] - .filter(Boolean) - .join(", "); + // SH-271 replaced the separate "Open in Google Maps" row: the Street Address + // itself is the link now, and the location is derived from the structured + // parts rather than a stored URL. + const address = { street: roster.address, city: roster.city, state: roster.state }; + const mapsUrl = mapsSearchUrl(address); return ( - - Google Maps + Street Address - {roster.googleMapsUrl ? ( + {roster.address && mapsUrl ? ( - - Open in Google Maps + {roster.address} ) : ( - — + {roster.address || "—"} )} + + + + + + + + + ); } diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index 1878b417..cf23d357 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -1,5 +1,6 @@ import { Controller, + useController, useFieldArray, useWatch, type Control, @@ -21,6 +22,8 @@ import { Typography, } from "@mui/material"; import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field"; +import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field"; +import { AddressMapPreview } from "@/components/common/address-map-preview"; import { formatNorthAmericanPhone } from "@/lib/format/na-phone"; import { VENDOR_NOTES_MAX_LENGTH, @@ -135,6 +138,9 @@ function CompanyFields({ onClearSelectedCompany, }: VendorRosterFormFieldsProps) { const notes = useWatch({ control, name: "notes" }) ?? ""; + const { field: streetField } = useController({ control, name: "address" }); + const { field: cityField } = useController({ control, name: "city" }); + const { field: stateField } = useController({ control, name: "state" }); return ( @@ -148,50 +154,58 @@ function CompanyFields({ onClearSelectedCompany={onClearSelectedCompany} /> - ( - - )} + { + // One pick fills all three. City and State are written through + // their own controllers so react-hook-form marks them dirty and + // revalidates — a plain assignment would be discarded on save. + streetField.onChange(parts.street); + cityField.onChange(parts.city); + stateField.onChange(parts.state); + }} + label="Street Address (required)" + required + error={Boolean(errors.address)} + helperText={ + errors.address?.message ?? + "Pick a suggestion to auto-fill City/State, or type your own." + } /> - - } + + - ( - - )} - /> - } + ( - - )} + name="zip" + render={({ field }) => } + /> + Open in Google Maps )} + {/* + The card holds one composed address line for both live and frozen + companies, so the preview is given the line directly rather than + structured parts; it is saved data either way. + */} + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx index 8fca16a0..c9bf6b21 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx @@ -11,6 +11,13 @@ import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-ord export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; +/** + * Height bound for the dialog surface. The company card grew with the map + * preview, so the dialog itself — not just the card — must cap its height and + * scroll internally or it outgrows short viewports. + */ +export const VENDOR_DIALOG_SURFACE_MAX_HEIGHT = "55vh"; + type VendorDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -56,12 +63,22 @@ export function VendorDialog({ ...dialogBlurBackdropSlotProps, paper: { className: "w-full", - style: { borderRadius: 12, maxWidth: 560 }, + style: { + borderRadius: 12, + maxWidth: 560, + maxHeight: VENDOR_DIALOG_SURFACE_MAX_HEIGHT, + display: "flex", + flexDirection: "column", + }, onClick: (e: MouseEvent) => e.stopPropagation(), }, }} > - e.stopPropagation()}> + e.stopPropagation()} + style={{ overflowY: "auto", minHeight: 0 }} + > Vendor diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx new file mode 100644 index 00000000..30e344c6 --- /dev/null +++ b/src/components/common/address-autocomplete-field.tsx @@ -0,0 +1,88 @@ +import { Autocomplete, TextField } from "@mui/material"; +import { + suggestAddresses, + type AddressParts, + type AddressSuggestion, +} from "@/lib/address/vendor-address"; + +type AddressAutocompleteFieldProps = { + value: string; + onInputChange: (value: string) => void; + /** Fired only when a candidate row is chosen, never while typing. */ + onSelect: (parts: AddressParts) => void; + label?: string; + placeholder?: string; + required?: boolean; + error?: boolean; + helperText?: string; + disabled?: boolean; +}; + +/** + * Street Address input with mocked City/State completion. + * + * `freeSolo` is the whole point: typing without picking a row is a valid way to + * finish the field. The candidates are a convenience for filling City and State + * in one action, not a constraint on what the address may be. + * + * Shared rather than local to Vendors because Sites needs the identical control + * when it is built (SH-273). + */ +export function AddressAutocompleteField({ + value, + onInputChange, + onSelect, + label = "Street Address", + placeholder = "Start typing the street address…", + required = false, + error = false, + helperText, + disabled = false, +}: AddressAutocompleteFieldProps) { + return ( + + freeSolo + disabled={disabled} + options={suggestAddresses(value)} + // The options are already derived from the input; letting MUI filter them + // again would drop every row whose label does not literally contain the + // typed text. + filterOptions={(options) => options} + inputValue={value} + onInputChange={(_event, next, reason) => { + // Genuine typing and the clear control update the street. MUI also + // fires this callback with reason "reset" after a selection, carrying + // the option's full ", , " label — forwarding + // that would overwrite the just-picked street with the label. + if (reason !== "input" && reason !== "clear") return; + onInputChange(next); + }} + onChange={(_event, selected) => { + if (selected && typeof selected !== "string") { + onSelect({ street: selected.street, city: selected.city, state: selected.state }); + return; + } + + if (selected === null) onSelect({ street: "", city: "", state: "" }); + }} + getOptionLabel={(option) => (typeof option === "string" ? option : option.label)} + renderInput={(params) => ( + + )} + /> + ); +} diff --git a/src/components/common/address-map-preview.tsx b/src/components/common/address-map-preview.tsx new file mode 100644 index 00000000..5d1892ce --- /dev/null +++ b/src/components/common/address-map-preview.tsx @@ -0,0 +1,99 @@ +import { Box } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import { + buildAddressQuery, + hasCompleteAddress, + mapsEmbedUrl, + type AddressParts, +} from "@/lib/address/vendor-address"; + +type AddressMapPreviewProps = { + /** Structured parts, as held by a form or a detail view. */ + address?: Partial; + /** + * An already-composed address line, for callers that hold saved data rather + * than live input — the Work Order vendor dialog gets one string from the + * vendor dropdown payload. Ignored when `address` is given. + */ + query?: string; + height?: number; + /** + * Render a compact accessible placeholder while the structured address is + * incomplete, instead of nothing. Meant for Add/Edit forms, where the empty + * state tells the dispatcher which parts the preview is still waiting on. + */ + placeholderWhenIncomplete?: boolean; +}; + +const INCOMPLETE_PLACEHOLDER_MESSAGE = + "Map preview appears once Street, City, and State are all filled."; + +/** + * Small inline map for a structured address. + * + * Uses Google's keyless `output=embed` form deliberately: SH-271 asks for a + * preview with no API key, so there is no Maps JavaScript API, no Places + * request and no billing account behind this. + * + * Structured input renders nothing until Street, City and State are all + * present — a preview of a half-typed address points somewhere misleading, + * which is worse than no map. Add/Edit forms opt into a compact placeholder + * for that incomplete state; a pre-composed saved line is map-only and renders + * only when non-empty. + */ +export function AddressMapPreview({ + address, + query: rawQuery, + height = 160, + placeholderWhenIncomplete = false, +}: AddressMapPreviewProps) { + // Structured input is held to the completeness rule; a pre-composed line is + // saved data and only has to be non-empty. + const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim(); + const complete = address ? hasCompleteAddress(address) : Boolean(query); + + if (!complete) { + if (!(address != null && placeholderWhenIncomplete)) return null; + return ( + + + {INCOMPLETE_PLACEHOLDER_MESSAGE} + + + ); + } + + return ( + + + + ); +} diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index d3fb7ceb..27672d30 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -123,7 +123,8 @@ export function mapVendorRosterToBackend(values: unknown): Record, , ". */ + label: string; +}; + +/** Minimum characters before any suggestion appears. */ +export const ADDRESS_SUGGESTION_MIN_LENGTH = 3; + +/** How many candidates the field offers at most. */ +export const ADDRESS_SUGGESTION_LIMIT = 4; + +/** + * Candidate cities, ported verbatim from the prototype. + * + * These are plausible distribution-centre metros rather than a geocoder: the + * point is that a dispatcher sees a short, stable list to pick from, not that + * the address is real. + */ +const SUGGESTION_CITIES: ReadonlyArray> = [ + { city: "Dallas", state: "TX" }, + { city: "Indianapolis", state: "IN" }, + { city: "Memphis", state: "TN" }, + { city: "St. Louis", state: "MO" }, + { city: "Orlando", state: "FL" }, + { city: "Austin", state: "TX" }, + { city: "Huntsville", state: "AL" }, + { city: "Rockford", state: "IL" }, + { city: "Columbus", state: "OH" }, + { city: "Tampa", state: "FL" }, + { city: "Cleveland", state: "OH" }, + { city: "Atlanta", state: "GA" }, + { city: "Chicago", state: "IL" }, + { city: "Phoenix", state: "AZ" }, + { city: "Portland", state: "OR" }, + { city: "Jersey City", state: "NJ" }, + { city: "Denver", state: "CO" }, + { city: "Miami", state: "FL" }, +]; + +/** + * Deterministic index from a seed string. + * + * Deterministic on purpose: the same typed text must offer the same candidates + * every keystroke and every render, or the list reshuffles under the pointer + * while someone is trying to click a row. + */ +function seededIndex(seed: string, size: number): number { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return hash % size; +} + +/** + * Up to four candidates for what the dispatcher has typed so far. + * + * The typed text is always kept as the street; only City and State are + * proposed. Selecting a candidate fills all three, and typing without selecting + * leaves plain free text — the field never locks the user out of their own + * input. + */ +export function suggestAddresses(input: string): AddressSuggestion[] { + const street = input.trim(); + if (street.length < ADDRESS_SUGGESTION_MIN_LENGTH) return []; + + const seen = new Set(); + const suggestions: AddressSuggestion[] = []; + for ( + let attempt = 0; + suggestions.length < ADDRESS_SUGGESTION_LIMIT && attempt < SUGGESTION_CITIES.length; + attempt += 1 + ) { + const candidate = + SUGGESTION_CITIES[seededIndex(`${street}#${attempt}`, SUGGESTION_CITIES.length)]; + if (!candidate) continue; + const key = `${candidate.city},${candidate.state}`; + if (seen.has(key)) continue; + seen.add(key); + suggestions.push({ + label: `${street}, ${candidate.city}, ${candidate.state}`, + street, + city: candidate.city, + state: candidate.state, + }); + } + return suggestions; +} + +/** The single-line form Google is asked about. Empty when there is nothing to ask. */ +export function buildAddressQuery(parts: Partial): string { + return [parts.street, parts.city, parts.state] + .map((part) => part?.trim() ?? "") + .filter(Boolean) + .join(" "); +} + +/** Link target for the clickable Street Address. Empty string when unmappable. */ +export function mapsSearchUrl(parts: Partial): string { + const query = buildAddressQuery(parts); + return query + ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}` + : ""; +} + +/** + * Embed URL for the inline preview. + * + * `output=embed` is the keyless form: it renders in an iframe with no API key, + * no billing account and no Places request, which is what SH-271 requires. + */ +export function mapsEmbedUrl(parts: Partial): string { + const query = buildAddressQuery(parts); + return query ? `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed` : ""; +} + +/** A preview is only meaningful once all three parts are present. */ +export function hasCompleteAddress(parts: Partial): boolean { + return Boolean(parts.street?.trim() && parts.city?.trim() && parts.state?.trim()); +} diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index 2fef475b..3202bd40 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; +import type { FormEvent, ReactNode } from "react"; import { useController, useFieldArray } from "react-hook-form"; import { toast } from "react-toastify"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -133,7 +133,6 @@ function submitValues( city: "St. Louis", state: "MO", zip: "63101", - googleMapsUrl: "", notes: "", technicians, }; @@ -300,7 +299,6 @@ describe("useVendorRosterForm prototype defaults", () => { city: "", state: "", zip: "", - googleMapsUrl: "", notes: "", technicians: [ { @@ -322,6 +320,46 @@ describe("useVendorRosterForm prototype defaults", () => { }); }); +describe("useVendorRosterForm legacy stored Maps URL", () => { + beforeEach(() => { + rosterGet.mockReset(); + saveMutate.mockReset(); + }); + + it("saves an edit of a legacy roster whose stored Maps URL is plain HTTP", async () => { + const legacyRoster: VendorCompanyRoster = { + ...roster, + googleMapsUrl: "http://maps.example.test/legacy", + }; + rosterQueryResult = { + data: legacyRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }; + const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), { + wrapper: makeWrapper(createClient()), + }); + + // Exercise the real resolver: while the form schema validated the hidden + // loaded URL, this submit never reached the save path. + await act(async () => { + await result.current.handleSubmit(result.current.submit)({ + preventDefault: () => undefined, + } as unknown as FormEvent); + }); + + expect(saveMutate).toHaveBeenCalledTimes(1); + const input = saveMutate.mock.calls[0]?.[0] as { + mode: string; + values: VendorCompanyRosterFormValues; + }; + expect(input.mode).toBe("update"); + expect(input.values).not.toHaveProperty("googleMapsUrl"); + }); +}); + describe("useVendorRosterForm technician removal", () => { it("keeps a valid roster saveable after an existing technician is removed", async () => { rosterQueryResult = { diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index feb5f59b..e96fd031 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -51,9 +51,25 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), { target: { value: name }, }); + // SH-271 made the structured address required. These tests are about the + // phone-or-email rule, so the address is filled to keep the form's only + // outstanding problem the one under test. + fillRequiredAddress(); await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); } +function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) { + fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), { + target: { value: street }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { + target: { value: city }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { + target: { value: state }, + }); +} + describe("VendorCreateModal validation", () => { it("explains the company phone-or-email requirement after submission", async () => { renderCreateModal(); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index d13a9174..8777567d 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -49,9 +49,11 @@ function rosterWith(technicians: Array>) { name: "Gateway Plumbing", companyPhone: "(314) 555-0100", email: "dispatch@gateway.test", - address: "", - city: "", - state: "", + // SH-271: Street/City/State are required, so a saved roster carries them. + // Tests that need the incomplete case blank them explicitly. + address: "6735 Trippel Rd", + city: "Theodore", + state: "AL", zip: "", googleMapsUrl: "", notes: "", @@ -343,7 +345,9 @@ describe("VendorDetailDrawer design parity", () => { expect(screen.getByText("Preferred after-hours vendor")).toBeInTheDocument(); }); - it("labels the Google Maps field below Address even when no URL is stored", () => { + it("shows Street, City and State as separate rows with no stored-URL Google Maps row", () => { + // SH-271 removed the "Open in Google Maps" row and the stored URL behind + // it; the location is derived from the structured address instead. useVendorCompanyRoster.mockReturnValue( rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]), ); @@ -353,15 +357,37 @@ describe("VendorDetailDrawer design parity", () => { withAuth: false, }); - expect(screen.getByText("Google Maps")).toBeInTheDocument(); + expect(screen.getByText("Street Address")).toBeInTheDocument(); + expect(screen.getByText("City")).toBeInTheDocument(); + expect(screen.getByText("State")).toBeInTheDocument(); + expect(screen.queryByText("Google Maps")).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: /Open in Google Maps/ })).not.toBeInTheDocument(); }); - it("links out to Google Maps when a URL is stored", () => { + it("makes the street address itself the link to Google Maps", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByRole("link", { name: "6735 Trippel Rd" })).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL", + ); + }); + + it("renders no map preview and no link until the address is complete", () => { + // A preview built from half an address points somewhere misleading. const roster = rosterWith([ { id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }, ]); - roster.data.googleMapsUrl = "https://maps.google.com/?q=1+Industrial+Pkwy"; + roster.data.address = ""; + roster.data.city = ""; + roster.data.state = ""; useVendorCompanyRoster.mockReturnValue(roster); renderWithProviders(, { @@ -369,10 +395,8 @@ describe("VendorDetailDrawer design parity", () => { withAuth: false, }); - expect(screen.getByRole("link", { name: /Open in Google Maps/ })).toHaveAttribute( - "href", - "https://maps.google.com/?q=1+Industrial+Pkwy", - ); + expect(screen.queryByRole("link", { name: /Trippel/ })).not.toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); }); it("puts the status opposite Total Jobs and drops the redundant Status label", () => { diff --git a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx new file mode 100644 index 00000000..28a3b106 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx @@ -0,0 +1,142 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { describe, expect, it, vi } from "vitest"; +import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; +import { + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { suggestAddresses } from "@/lib/address/vendor-address"; +import { renderWithProviders } from "@/test/test-utils"; + +const TYPED_STREET = "415 Murphy Rd"; +const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0]; + +if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street"); + +function AddressFormHarness({ + onValid, +}: { + onValid: (values: VendorCompanyRosterFormValues) => void; +}) { + const { control, handleSubmit, formState } = useForm({ + defaultValues: emptyVendorCompanyRosterForm, + resolver: zodResolver(vendorCompanyRosterSchema), + mode: "onChange", + }); + return ( +
+ + + + ); +} + +function fillCompanyBasics(): void { + fireEvent.change(screen.getByRole("textbox", { name: "Company (required)" }), { + target: { value: "Gateway Plumbing" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "Company Phone (optional)" }), { + target: { value: "3145550100" }, + }); +} + +describe("VendorRosterFormFields address autocomplete", () => { + it("keeps the picked street when a suggestion whose label differs is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + + const listbox = await screen.findByRole("listbox"); + const option = within(listbox) + .getAllByRole("option") + .find((candidate) => candidate.textContent === FIRST_SUGGESTION.label); + expect(option).toBeDefined(); + expect(FIRST_SUGGESTION.label).not.toBe(FIRST_SUGGESTION.street); + + await user.click(option as HTMLElement); + + await waitFor(() => { + expect(street).toHaveValue(FIRST_SUGGESTION.street); + }); + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( + FIRST_SUGGESTION.city, + ); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( + FIRST_SUGGESTION.state, + ); + }); + + it("submits the settled structured parts, not the option label", async () => { + const user = userEvent.setup(); + const onValid = vi.fn(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement); + fillCompanyBasics(); + + await user.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + const values = onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues; + expect(values.address).toBe(FIRST_SUGGESTION.street); + expect(values.city).toBe(FIRST_SUGGESTION.city); + expect(values.state).toBe(FIRST_SUGGESTION.state); + }); + + it("clears every structured address part from the autocomplete clear control", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement); + + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( + FIRST_SUGGESTION.city, + ); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( + FIRST_SUGGESTION.state, + ); + await user.click(screen.getByRole("button", { name: "Clear" })); + + expect(street).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(""); + }); +}); + +describe("VendorRosterFormFields map preview empty state", () => { + it("shows the accessible placeholder while incomplete, then the map once complete", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + expect(screen.getByRole("status")).toHaveTextContent(/street, city, and state/i); + expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument(); + + await user.type( + screen.getByRole("combobox", { name: "Street Address (required)" }), + TYPED_STREET, + ); + expect(screen.getByRole("status")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { + target: { value: "Theodore" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { + target: { value: "AL" }, + }); + + expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx new file mode 100644 index 00000000..013dcc2d --- /dev/null +++ b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx @@ -0,0 +1,90 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + VENDOR_DIALOG_SURFACE_MAX_HEIGHT, + VendorDialog, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog"; +import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({ + useCreateVendor: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +const LIVE_VENDOR: VendorDropdownItem = { + id: 10, + companyName: "Gateway Plumbing", + contactName: "Jordan Lee", + tradeSpecialties: "Plumbing", + address: "900 Live St", + distanceMiles: 4.2, + email: "dispatch@gateway.test", +}; + +describe("VendorDialogCompanyCard map preview queries", () => { + it("embeds the live vendor address", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const frame = screen.getByTitle("Map of 900 Live St"); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=900%20Live%20St&output=embed", + ); + }); + + it("embeds the frozen company's composed snapshot address, not the live one", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const frame = screen.getByTitle("Map of 200 Vendor Ave, Chicago, IL, 60602"); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=200%20Vendor%20Ave%2C%20Chicago%2C%20IL%2C%2060602&output=embed", + ); + expect(screen.queryByTitle("Map of 900 Live St")).not.toBeInTheDocument(); + }); +}); + +describe("VendorDialog scroll boundary", () => { + it("bounds the dialog surface and scrolls internally", () => { + renderWithProviders( + , + ); + + const surface = screen.getByRole("dialog"); + expect(VENDOR_DIALOG_SURFACE_MAX_HEIGHT).toBe("55vh"); + expect(surface.style.maxHeight).toBe(VENDOR_DIALOG_SURFACE_MAX_HEIGHT); + expect(surface.style.display).toBe("flex"); + + const content = screen.getByRole("dialog").querySelector(".MuiDialogContent-root"); + expect(content).not.toBeNull(); + expect(content).toHaveStyle({ overflowY: "auto", minHeight: "0px" }); + }); +}); diff --git a/src/test/components/common/address-map-preview.test.tsx b/src/test/components/common/address-map-preview.test.tsx new file mode 100644 index 00000000..c1564424 --- /dev/null +++ b/src/test/components/common/address-map-preview.test.tsx @@ -0,0 +1,68 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AddressMapPreview } from "@/components/common/address-map-preview"; +import { renderWithProviders } from "@/test/test-utils"; + +const complete = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" }; + +function mapFrame() { + return screen.getByTitle("Map of 6735 Trippel Rd Theodore AL"); +} + +describe("AddressMapPreview structured input", () => { + it("renders a compact accessible placeholder while the address is incomplete", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const placeholder = screen.getByRole("status"); + expect(placeholder).toHaveTextContent(/street, city, and state/i); + expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument(); + }); + + it("transitions from the placeholder to the iframe once the address completes", () => { + const { rerender } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("status")).toBeInTheDocument(); + + rerender(); + const frame = mapFrame(); + expect(frame).toBeInTheDocument(); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed", + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("stays blank for incomplete input when the placeholder is not opted into", () => { + const { container } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(container.querySelector("iframe")).toBeNull(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); + +describe("AddressMapPreview pre-composed query", () => { + it("renders the map only when the saved query is non-empty", () => { + const { container, rerender } = renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByTitle("Map of 900 Live St")).toBeInTheDocument(); + + rerender(); + expect(container.querySelector("iframe")).toBeNull(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index 65494d15..4c9a463f 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -66,7 +66,7 @@ describe("vendor roster mapper", () => { name: "Gateway Plumbing", companyPhone: "314-555-0100", email: "dispatch@gateway.test", - googleMapsUrl: "https://maps.google.com/x", + googleMapsUrl: "http://maps.google.com/legacy", tradeSpecialties: "Plumbing", rowVersion: "rv-2", technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }], @@ -82,6 +82,9 @@ describe("vendor roster mapper", () => { expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" }), ]); expect(payload).not.toHaveProperty("tradeSpecialties"); + // The stored Maps URL is deprecated: a legacy value reaching the mapper + // (from stale callers or old records) must not be emitted to the backend. + expect(payload).not.toHaveProperty("googleMapsUrl"); }); it("omits rowVersion from the create payload when absent", () => { diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts index d5772d1a..da7bcff4 100644 --- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts @@ -3,7 +3,6 @@ import { VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, emptyRosterTechnician, emptyVendorCompanyRosterForm, - isAbsoluteHttpsUrl, VENDOR_NOTES_MAX_LENGTH, vendorCompanyRosterSchema, vendorCompanyRosterUpdateSchema, @@ -14,6 +13,11 @@ const baseCompany = { name: "Gateway Plumbing", companyPhone: "(314) 555-0100", email: "dispatch@gateway.test", + // SH-271 made the structured address required, so a company that is meant to + // parse cleanly has to carry one. + address: "6735 Trippel Rd", + city: "Theodore", + state: "AL", }; describe("vendorCompanyRosterSchema", () => { @@ -56,9 +60,11 @@ describe("vendorCompanyRosterSchema", () => { it("accepts a company with zero technicians when phone is present", () => { const result = vendorCompanyRosterSchema.safeParse({ - ...emptyVendorCompanyRosterForm, + ...baseCompany, name: "Solo Co", + email: "", companyPhone: "(314) 555-0100", + technicians: [], }); expect(result.success).toBe(true); if (result.success) expect(result.data).not.toHaveProperty("companyContact"); @@ -98,21 +104,26 @@ describe("vendorCompanyRosterSchema", () => { expect(result.success).toBe(true); }); - it("rejects malformed emails, non-HTTPS map URLs, and partial phones", () => { + it("rejects malformed emails and partial phones", () => { const result = vendorCompanyRosterSchema.safeParse({ ...baseCompany, email: "not-an-email", - googleMapsUrl: "http://maps.google.com/x", technicians: [{ ...emptyRosterTechnician, contactName: "Adam", phone: "(314)" }], }); expect(result.success).toBe(false); }); - it("rejects a maps URL that is a non-absolute https prefix string", () => { - expect(isAbsoluteHttpsUrl("https://")).toBe(false); - expect(isAbsoluteHttpsUrl("maps.google.com/x")).toBe(false); - expect(isAbsoluteHttpsUrl("https://maps.google.com/x")).toBe(true); - expect(isAbsoluteHttpsUrl("")).toBe(true); + it("tolerates a legacy stored Maps URL instead of failing HTTPS validation", () => { + // Legacy records carry a stored googleMapsUrl — often plain HTTP. The form + // no longer holds the field, so leftover values loaded from old data must + // neither fail validation nor re-enter the parsed output. + const result = vendorCompanyRosterSchema.safeParse({ + ...baseCompany, + googleMapsUrl: "http://maps.google.com/legacy", + }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data).not.toHaveProperty("googleMapsUrl"); }); it("rejects an unknown preferred contact", () => { diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx index f03c2a27..aaec2167 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx @@ -74,7 +74,6 @@ function formValues( city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, notes: roster.notes, technicians: [newTechnician], ...overrides, diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts index f0547378..735ef2d7 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts @@ -36,7 +36,6 @@ const values = { city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, notes: roster.notes, technicians: roster.technicians.map((technician) => ({ id: technician.id, diff --git a/src/test/lib/address/vendor-address.test.ts b/src/test/lib/address/vendor-address.test.ts new file mode 100644 index 00000000..4a2736dc --- /dev/null +++ b/src/test/lib/address/vendor-address.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + ADDRESS_SUGGESTION_LIMIT, + ADDRESS_SUGGESTION_MIN_LENGTH, + buildAddressQuery, + hasCompleteAddress, + mapsEmbedUrl, + mapsSearchUrl, + suggestAddresses, +} from "@/lib/address/vendor-address"; + +describe("suggestAddresses", () => { + it("offers nothing until the minimum length is reached", () => { + expect(suggestAddresses("")).toEqual([]); + expect(suggestAddresses("12")).toEqual([]); + expect(suggestAddresses(" 12 ")).toEqual([]); + expect(suggestAddresses("123").length).toBeGreaterThan(0); + }); + + it("caps the list so the dropdown stays scannable", () => { + expect(suggestAddresses("6735 Trippel Rd").length).toBeLessThanOrEqual( + ADDRESS_SUGGESTION_LIMIT, + ); + }); + + it("keeps the typed text as the street and only proposes city and state", () => { + const [first] = suggestAddresses("415 Murphy Rd"); + expect(first?.street).toBe("415 Murphy Rd"); + expect(first?.city).toBeTruthy(); + expect(first?.state).toBeTruthy(); + expect(first?.label).toBe(`415 Murphy Rd, ${first?.city}, ${first?.state}`); + }); + + it("is stable for the same input", () => { + // The list must not reshuffle between renders, or a row moves out from + // under the pointer mid-click. + expect(suggestAddresses("4255 Anson Blvd")).toEqual(suggestAddresses("4255 Anson Blvd")); + }); + + it("never repeats a city and state pair", () => { + const suggestions = suggestAddresses("3050 Gateway Commerce Dr"); + const keys = suggestions.map((s) => `${s.city},${s.state}`); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("trims the typed text before using it", () => { + const [first] = suggestAddresses(" 1715 Wall Triana Hwy "); + expect(first?.street).toBe("1715 Wall Triana Hwy"); + }); + + it("exposes the minimum length it enforces", () => { + expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH))).not.toEqual([]); + expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH - 1))).toEqual([]); + }); +}); + +describe("address queries and links", () => { + const full = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" }; + + it("joins the parts it has and skips the ones it does not", () => { + expect(buildAddressQuery(full)).toBe("6735 Trippel Rd Theodore AL"); + expect(buildAddressQuery({ street: "", city: "Dallas", state: "TX" })).toBe("Dallas TX"); + expect(buildAddressQuery({})).toBe(""); + }); + + it("builds a search link and an embed link for the same address", () => { + expect(mapsSearchUrl(full)).toBe( + "https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL", + ); + expect(mapsEmbedUrl(full)).toBe( + "https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed", + ); + }); + + it("returns empty strings rather than a link to nowhere", () => { + // An href of "" renders as no link; a link to a bare maps search would look + // like an address we do not have. + expect(mapsSearchUrl({})).toBe(""); + expect(mapsEmbedUrl({ street: " " })).toBe(""); + }); + + it("needs all three parts before a preview is meaningful", () => { + expect(hasCompleteAddress(full)).toBe(true); + expect(hasCompleteAddress({ street: "6735 Trippel Rd", city: "Theodore", state: " " })).toBe( + false, + ); + expect(hasCompleteAddress({ street: "6735 Trippel Rd" })).toBe(false); + }); +});