This commit is contained in:
Alexandre Brandizzi 2026-09-28 20:50:33 -03:00 • committed by GitHub
commit 7f023c5a45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 244 additions and 47 deletions

View file

@ -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({
/>
<StateSelect control={control} error={errors.state} />
</Stack>
<SiteMapPreview address={address} city={city} state={state} />
<SiteMapPreview
address={address}
city={city}
state={state}
zipCode={zipCode}
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,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(<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 previews the stored ZIP and 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")).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(<LocationsListPage />, { withAuth: false });

View file

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

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