feat(sites): show the site address map preview through one shared Maps helper

Vendors and Sites now build the keyless embed URL and the Open in Google
Maps link through one helper. The Site map shows the placeholder only in
the Add/Edit forms; view mode and the Work Order Site dialog always render
it for the saved address, the dialog in its compact form.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 13:39:29 -03:00
parent f6849ed9d0
commit f0b4ced660
11 changed files with 230 additions and 46 deletions

View file

@ -84,7 +84,7 @@ export function SiteFormFields({
/>
<StateSelect control={control} error={errors.state} />
</Stack>
<SiteMapPreview address={address} city={city} state={state} />
<SiteMapPreview address={address} city={city} state={state} placeholderUntilComplete />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...register("sitePhone")}

View file

@ -1,39 +1,69 @@
import { Box, Typography } from "@mui/material";
import { MapPin } from "lucide-react";
import type { Location } from "@/domain/locations/types/location";
import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps";
type SiteMapPreviewProps = Pick<Location, "address" | "city" | "state" | "zipCode">;
/** 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<Location, "address" | "city" | "state" | "zipCode"> & {
/**
* 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 (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Location preview
<Box
role="status"
className="mt-1 flex flex-col items-center justify-center gap-1 rounded-lg"
sx={{ height, bgcolor: "var(--color-bg-muted)", border: 1, borderColor: "divider" }}
>
<MapPin size={18} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Enter an address to preview the location
</Typography>
{complete && (
<Box
component="iframe"
title="Site location map"
src={buildSiteMapEmbedUrl(props)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="mt-1 block w-full rounded border-0"
sx={{ height: 140 }}
/>
)}
{!complete && (
<Box
className="mt-1 flex items-center justify-center rounded border border-dashed"
sx={{ height: 140, borderColor: "divider" }}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Enter an address to preview the location
</Typography>
</Box>
)}
</Box>
);
}
/** 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 (
<Box>
{!hideLabel && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Location preview
</Typography>
)}
{showMap && (
<Box
component="iframe"
title="Site location map"
src={src}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="mt-1 block w-full rounded-lg border-0"
sx={{ height }}
/>
)}
{!showMap && <MapPlaceholder height={height} />}
</Box>
);
}

View file

@ -70,6 +70,8 @@ export function SiteDialogDetailPanel({
city={locationDetail.city}
state={locationDetail.state}
zipCode={locationDetail.zipCode}
hideLabel
height={120}
/>
</>
) : (

View file

@ -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<Location, "address" | "city" | "state" | "zipCode">;
/** 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));
}

View file

@ -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` : "";
}

View file

@ -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<AddressParts>): string {
/** 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)}`
: "";
return googleMapsSearchUrl(buildAddressQuery(parts));
}
/**
@ -125,8 +124,7 @@ export function mapsSearchUrl(parts: Partial<AddressParts>): string {
* 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` : "";
return googleMapsEmbedUrl(buildAddressQuery(parts));
}
/** A preview is only meaningful once all three parts are present. */

View file

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

View file

@ -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,42 @@ 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(<LocationsListPage />, { 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 swaps the map for the placeholder while City is cleared", async () => {
renderWithProviders(<LocationsListPage />, { withAuth: false });
const region = await openSite("DAL1");
fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
expect(within(region).getByTitle("Site location map")).toBeInTheDocument();
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&output=embed",
);
});
it("switches to edit in place, saves, and returns to view showing the saved values", async () => {
update.mockResolvedValue({});
renderWithProviders(<LocationsListPage />, { withAuth: false });

View file

@ -84,7 +84,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();
});

View file

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

View file

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