diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx index 1f94d7fa..29be2a4f 100644 --- a/src/app/(protected)/locations/_components/site-form-fields.tsx +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -27,7 +27,11 @@ export function SiteFormFields({ register, formState: { errors }, } = form; - const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] }); + // ZIP is carried through unedited, so the preview shows the same line as the Street Address link. + const [address, city, state, zipCode] = useWatch({ + control, + name: ["address", "city", "state", "zipCode"], + }); const { field: addressField } = useController({ control, name: "address" }); const { field: cityField } = useController({ control, name: "city" }); const { field: stateField } = useController({ control, name: "state" }); @@ -84,7 +88,13 @@ export function SiteFormFields({ /> - + ; - -/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */ -export function SiteMapPreview(props: SiteMapPreviewProps) { - const complete = hasCompleteSiteAddress(props); +type SiteMapPreviewProps = Pick & { + /** + * Add/Edit forms: hold the map back behind a placeholder until Street, City + * and State are all filled, so a half-typed address never points somewhere + * misleading. Saved sites (view mode, Work Order Site dialog) leave this off + * and always render the map for whatever address they have. + */ + placeholderUntilComplete?: boolean; + /** Hide the "Location preview" caption where the map sits inside an info box. */ + hideLabel?: boolean; + height?: number; +}; +function MapPlaceholder({ height }: { height: number }) { return ( - - - Location preview + + + + Enter an address to preview the location - {complete && ( - - )} - {!complete && ( - - - Enter an address to preview the location - - - )} + + ); +} + +/** Read-only key-less Google Maps embed of a site's address. */ +export function SiteMapPreview({ + placeholderUntilComplete = false, + hideLabel = false, + height = 140, + ...address +}: SiteMapPreviewProps) { + const src = buildSiteMapEmbedUrl(address); + const showMap = placeholderUntilComplete ? hasCompleteSiteAddress(address) : Boolean(src); + + if (!showMap && !placeholderUntilComplete) { + return null; + } + + return ( + + {!hideLabel && ( + + Location preview + + )} + {showMap && ( + + )} + {!showMap && } ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx index da15ab57..253592ce 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx @@ -70,6 +70,8 @@ export function SiteDialogDetailPanel({ city={locationDetail.city} state={locationDetail.state} zipCode={locationDetail.zipCode} + hideLabel + height={120} /> ) : ( diff --git a/src/domain/locations/utils/site-maps.ts b/src/domain/locations/utils/site-maps.ts index 28a75792..bb4c1f18 100644 --- a/src/domain/locations/utils/site-maps.ts +++ b/src/domain/locations/utils/site-maps.ts @@ -1,23 +1,28 @@ import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; import type { Location } from "@/domain/locations/types/location"; +import { googleMapsEmbedUrl, googleMapsSearchUrl } from "@/lib/address/google-maps"; +import { hasCompleteAddress } from "@/lib/address/vendor-address"; type SiteAddress = Pick; /** Google Maps search link for a site address; opens in a new tab. */ export function buildSiteMapsUrl(location: SiteAddress): string { - const query = formatLocationAddressPreview(location); - return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; + return googleMapsSearchUrl(formatLocationAddressPreview(location)); } /** True once Street, City and State are all filled, so a map preview can render. */ export function hasCompleteSiteAddress(location: SiteAddress): boolean { - return [location.address, location.city, location.state].every( - (part) => (part?.trim() ?? "") !== "", - ); + return hasCompleteAddress({ + street: location.address, + city: location.city, + state: location.state, + }); } -/** Key-less Google Maps embed URL for the read-only preview. */ +/** + * Key-less Google Maps embed URL for the read-only preview, built from the same + * address line as the Street Address link. Empty when the site has no address. + */ export function buildSiteMapEmbedUrl(location: SiteAddress): string { - const query = formatLocationAddressPreview(location); - return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`; + return googleMapsEmbedUrl(formatLocationAddressPreview(location)); } diff --git a/src/lib/address/google-maps.ts b/src/lib/address/google-maps.ts new file mode 100644 index 00000000..3022c356 --- /dev/null +++ b/src/lib/address/google-maps.ts @@ -0,0 +1,28 @@ +/** + * Google Maps URLs shared by every address preview and address link. + * + * Each caller composes its own one-line address (Vendors join the parts with + * spaces, Sites use the same line as their Street Address link); these helpers + * only encode it into the URL shape Google expects. Both return an empty string + * for a blank query, so callers can skip rendering instead of pointing at an + * empty search. + */ + +/** Link target that opens full Google Maps in a new tab. */ +export function googleMapsSearchUrl(query: string): string { + const trimmed = query.trim(); + return trimmed + ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(trimmed)}` + : ""; +} + +/** + * Iframe source for the read-only inline preview. + * + * `output=embed` is Google's keyless form: no API key, no billing account and + * no Places request, and the embed brings its own "View larger map" link. + */ +export function googleMapsEmbedUrl(query: string): string { + const trimmed = query.trim(); + return trimmed ? `https://www.google.com/maps?q=${encodeURIComponent(trimmed)}&output=embed` : ""; +} diff --git a/src/lib/address/vendor-address.ts b/src/lib/address/vendor-address.ts index f98dbcbd..29834cdd 100644 --- a/src/lib/address/vendor-address.ts +++ b/src/lib/address/vendor-address.ts @@ -7,6 +7,8 @@ * design agree on what a dispatcher sees. */ +import { googleMapsEmbedUrl, googleMapsSearchUrl } from "./google-maps"; + export type AddressParts = { street: string; city: string; @@ -112,10 +114,7 @@ export function buildAddressQuery(parts: Partial): string { /** 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)}` - : ""; + return googleMapsSearchUrl(buildAddressQuery(parts)); } /** @@ -125,8 +124,7 @@ export function mapsSearchUrl(parts: Partial): string { * 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` : ""; + return googleMapsEmbedUrl(buildAddressQuery(parts)); } /** A preview is only meaningful once all three parts are present. */ diff --git a/src/test/app/(protected)/locations/add-site-dialog.test.tsx b/src/test/app/(protected)/locations/add-site-dialog.test.tsx index a9b6c328..d416b5e6 100644 --- a/src/test/app/(protected)/locations/add-site-dialog.test.tsx +++ b/src/test/app/(protected)/locations/add-site-dialog.test.tsx @@ -115,6 +115,22 @@ describe("AddSiteDialog", () => { expect(within(dialog).getByText("Area: West")).toBeInTheDocument(); }); + it("keeps the map placeholder while State is still empty", () => { + const { dialog } = renderDialog(); + + fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), { + target: { value: "3811 Distribution Dr" }, + }); + fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), { + target: { value: "Dallas" }, + }); + + expect( + within(dialog).getByText("Enter an address to preview the location"), + ).toBeInTheDocument(); + expect(within(dialog).queryByTitle("Site location map")).not.toBeInTheDocument(); + }); + it("suggests addresses from 3 typed characters and fills Street, City and State on pick", async () => { const user = userEvent.setup(); const { dialog } = renderDialog(); diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx index 8b5c2885..fd5cf055 100644 --- a/src/test/app/(protected)/locations/site-detail-panel.test.tsx +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -117,7 +117,11 @@ describe("Sites page detail panel", () => { "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", ); expect(address).toHaveAttribute("target", "_blank"); - expect(within(region).getByTitle("Site location map")).toBeInTheDocument(); + expect(within(region).getByTitle("Site location map")).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed", + ); + expect(within(region).getByTitle("Site location map")).toHaveAttribute("loading", "lazy"); expect(within(region).getByText("TX ยท Area: West")).toBeInTheDocument(); expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument(); expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument(); @@ -126,6 +130,45 @@ describe("Sites page detail panel", () => { expect(within(region).queryByText(/status/i)).not.toBeInTheDocument(); }); + it("view mode always renders the map for a saved site, even one missing City and State", async () => { + getById.mockResolvedValue({ ...chicago, city: "", state: "" }); + renderWithProviders(, { withAuth: false }); + + const region = await openSite("CHI2"); + + expect(within(region).getByTitle("Site location map")).toHaveAttribute( + "src", + "https://www.google.com/maps?q=100%20Main%20St&output=embed", + ); + expect( + within(region).queryByText("Enter an address to preview the location"), + ).not.toBeInTheDocument(); + }); + + it("edit mode previews the stored ZIP and swaps the map for the placeholder while City is cleared", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + expect(within(region).getByTitle("Site location map")).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed", + ); + + const city = within(region).getByRole("textbox", { name: "City" }); + fireEvent.change(city, { target: { value: "" } }); + + expect( + await within(region).findByText("Enter an address to preview the location"), + ).toBeInTheDocument(); + expect(within(region).queryByTitle("Site location map")).not.toBeInTheDocument(); + + fireEvent.change(city, { target: { value: "Fort Worth" } }); + expect(await within(region).findByTitle("Site location map")).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Fort%20Worth%2C%20TX%2075201&output=embed", + ); + }); + it("switches to edit in place, saves, and returns to view showing the saved values", async () => { update.mockResolvedValue({}); renderWithProviders(, { withAuth: false }); diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx index b4a67ced..69dfd489 100644 --- a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx +++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx @@ -102,7 +102,13 @@ describe("Work order Site dialog edits the site record", () => { "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", ); expect(address).toHaveAttribute("target", "_blank"); - expect(screen.getByTitle("Site location map")).toBeInTheDocument(); + const map = screen.getByTitle("Site location map"); + expect(map).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed", + ); + expect(map).toHaveAttribute("loading", "lazy"); + expect(screen.queryByText("Enter an address to preview the location")).not.toBeInTheDocument(); expect(screen.getByText("(214) 555-0100")).toBeInTheDocument(); expect(screen.getByText("dal1@acme.test")).toBeInTheDocument(); expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument(); diff --git a/src/test/domain/locations/utils/site-maps.test.ts b/src/test/domain/locations/utils/site-maps.test.ts new file mode 100644 index 00000000..c6dc040e --- /dev/null +++ b/src/test/domain/locations/utils/site-maps.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + buildSiteMapEmbedUrl, + buildSiteMapsUrl, + hasCompleteSiteAddress, +} from "@/domain/locations/utils/site-maps"; + +const site = { address: "12 Dock Rd #4", city: "Smith & Sons", state: "TX", zipCode: "75201" }; + +describe("site map URLs", () => { + it("embeds the same address line the Street Address link opens", () => { + const line = "12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX%2075201"; + expect(buildSiteMapsUrl(site)).toBe(`https://www.google.com/maps/search/?api=1&query=${line}`); + expect(buildSiteMapEmbedUrl(site)).toBe(`https://www.google.com/maps?q=${line}&output=embed`); + }); + + it("has no embed URL for a site without any address", () => { + expect(buildSiteMapEmbedUrl({})).toBe(""); + }); + + it("counts an address complete only when Street, City and State are all filled", () => { + expect(hasCompleteSiteAddress(site)).toBe(true); + expect(hasCompleteSiteAddress({ ...site, state: " " })).toBe(false); + expect(hasCompleteSiteAddress({ ...site, city: undefined })).toBe(false); + }); +}); diff --git a/src/test/lib/address/google-maps.test.ts b/src/test/lib/address/google-maps.test.ts new file mode 100644 index 00000000..5bdc4fd9 --- /dev/null +++ b/src/test/lib/address/google-maps.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { googleMapsEmbedUrl, googleMapsSearchUrl } from "@/lib/address/google-maps"; + +describe("googleMapsEmbedUrl", () => { + it("builds the keyless embed URL for an address", () => { + expect(googleMapsEmbedUrl("3811 Distribution Dr, Dallas, TX 75201")).toBe( + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed", + ); + }); + + it("encodes commas, # and & so they stay inside the query", () => { + expect(googleMapsEmbedUrl("12 Dock Rd #4, Smith & Sons, TX")).toBe( + "https://www.google.com/maps?q=12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX&output=embed", + ); + }); + + it("returns an empty string for a blank address", () => { + expect(googleMapsEmbedUrl("")).toBe(""); + expect(googleMapsEmbedUrl(" ")).toBe(""); + }); +}); + +describe("googleMapsSearchUrl", () => { + it("encodes commas, # and & in the search link", () => { + expect(googleMapsSearchUrl("12 Dock Rd #4, Smith & Sons, TX")).toBe( + "https://www.google.com/maps/search/?api=1&query=12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX", + ); + }); + + it("returns an empty string for a blank address", () => { + expect(googleMapsSearchUrl(" ")).toBe(""); + }); +});