Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/components/NewInspectionWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,12 @@ describe('NewInspectionWizard — guard test for client + buyer-agent payload',
const addressInput = getByPlaceholderText(/123 Main|St.*City/i) as HTMLInputElement;
fireEvent.change(addressInput, { target: { value: '123 Main Street' } });

const selects = getAllByRole('combobox') as HTMLSelectElement[];
fireEvent.change(selects[0], { target: { value: 'tpl-1' } });
// Two comboboxes now exist on the property step: the address
// autocomplete <input role="combobox"> (#198) and the template <select>.
// Target the native <select> specifically.
const combos = getAllByRole('combobox') as HTMLElement[];
const templateSelect = combos.find((el) => el.tagName === 'SELECT') as HTMLSelectElement;
fireEvent.change(templateSelect, { target: { value: 'tpl-1' } });

let buttons = getAllByRole('button') as HTMLButtonElement[];
let nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'));
Expand Down
33 changes: 32 additions & 1 deletion app/components/NewInspectionWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ServicesStep } from "./new-inspection/ServicesStep";
import { ScheduleStep } from "./new-inspection/ScheduleStep";
import { TeamStep } from "./new-inspection/TeamStep";
import { QuotaExceededPanel } from "./new-inspection/QuotaExceededPanel";
import type { AddressSelection } from "~/routes/resources/places";
import { m } from "~/paraglide/messages";

function stepLabel(id: WizardStepId): string {
Expand Down Expand Up @@ -94,6 +95,10 @@ export function NewInspectionWizard({
const [stepIdx, setStepIdx] = useState(0);
const [propertyType, setPropertyType] = useState("single_family");
const [address, setAddress] = useState("");
// #198 — structured, geocoded address captured when the inspector picks a
// Places suggestion. Cleared when they edit the text back to free-form, so we
// never persist stale coordinates against a hand-typed address.
const [addressSel, setAddressSel] = useState<AddressSelection | null>(null);
const [templateId, setTemplateId] = useState("");
// Stores selected service IDs (matched against the tenant's services table).
const [services, setServices] = useState<Set<string>>(new Set());
Expand Down Expand Up @@ -172,6 +177,7 @@ export function NewInspectionWizard({
setStepIdx(0);
setPropertyType("single_family");
setAddress("");
setAddressSel(null);
setTemplateId("");
setTemplateQuery("");
setServices(new Set());
Expand Down Expand Up @@ -292,6 +298,17 @@ export function NewInspectionWizard({
setAgentDropdownOpen(false);
}

// #198 — editing the address text by hand invalidates any previously picked
// Places suggestion (its coordinates no longer describe what's typed).
function handleAddressChange(v: string) {
setAddress(v);
if (addressSel) setAddressSel(null);
}
function handleAddressSelect(sel: AddressSelection) {
setAddressSel(sel);
setAddress(sel.formatted);
}

if (!open) return null;

const toggleService = (id: string) => {
Expand Down Expand Up @@ -368,6 +385,17 @@ export function NewInspectionWizard({
intent: "create",
propertyType,
address,
// #198 — structured geocoded address (empty strings when the inspector
// typed a free-form address the API couldn't match; the server stamps
// addressGeocodedAt itself).
addressPlaceId: addressSel?.placeId ?? "",
addressStreet: addressSel?.street ?? "",
addressCity: addressSel?.city ?? "",
addressState: addressSel?.state ?? "",
addressZip: addressSel?.zip ?? "",
addressCounty: addressSel?.county ?? "",
addressLat: addressSel?.lat != null ? String(addressSel.lat) : "",
addressLng: addressSel?.lng != null ? String(addressSel.lng) : "",
templateId,
serviceIds: [...services].join(","),
serviceSelectionsJson,
Expand Down Expand Up @@ -420,7 +448,10 @@ export function NewInspectionWizard({
propertyType={propertyType}
setPropertyType={setPropertyType}
address={address}
setAddress={setAddress}
setAddress={handleAddressChange}
onAddressSelect={handleAddressSelect}
addressLat={addressSel?.lat}
addressLng={addressSel?.lng}
templates={templates}
templateId={templateId}
setTemplateId={setTemplateId}
Expand Down
73 changes: 73 additions & 0 deletions app/components/address/AddressAutocomplete.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { AddressAutocomplete } from "./AddressAutocomplete";
import type { AddressSelection } from "~/routes/resources/places";

const SAMPLE: AddressSelection = {
placeId: "p1",
formatted: "123 Main St, Austin, TX 78701",
street: "123 Main St",
city: "Austin",
state: "TX",
zip: "78701",
county: "Travis",
lat: 30.26,
lng: -97.74,
};

function mountWith(onSelect: (s: AddressSelection) => void) {
const Stub = createRoutesStub([
{
path: "/",
Component: () => {
const [val, setVal] = useState("");
return <AddressAutocomplete value={val} onValueChange={setVal} onSelect={onSelect} />;
},
},
{
path: "/resources/places",
loader: ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get("placeId")) {
return { suggestions: [], address: SAMPLE };
}
return {
suggestions: [
{ placeId: "p1", description: "123 Main St, Austin, TX", mainText: "123 Main St", secondaryText: "Austin, TX" },
],
address: null,
};
},
},
]);
return render(<Stub />);
}

describe("AddressAutocomplete", () => {
it("shows suggestions while typing and emits a structured selection on click", async () => {
const onSelect = vi.fn();
mountWith(onSelect);

fireEvent.change(screen.getByRole("combobox"), { target: { value: "123 Main" } });

const option = await waitFor(() => screen.getByText("123 Main St"));
fireEvent.mouseDown(option);

await waitFor(() =>
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ placeId: "p1", lat: expect.any(Number), lng: expect.any(Number) }),
),
);
});

it("does not query for inputs shorter than 2 characters", async () => {
const onSelect = vi.fn();
mountWith(onSelect);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "1" } });
// Give any (incorrectly scheduled) debounce time to fire.
await new Promise((r) => setTimeout(r, 300));
expect(screen.queryByRole("listbox")).toBeNull();
});
});
158 changes: 158 additions & 0 deletions app/components/address/AddressAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import type { AddressSelection, PlaceSuggestion } from "~/routes/resources/places";
import { m } from "~/paraglide/messages";

/**
* Address autocomplete input (Spec 5D B4, #198). Debounced suggestions from the
* `/resources/places` BFF, keyboard-navigable listbox, and a per-typing-session
* token so Google bills the whole autocomplete→details sequence once.
*
* Controlled: `value`/`onValueChange` own the free-text address (so a user can
* still type a free-form address the API can't match and submit it). `onSelect`
* fires only when a suggestion is resolved to a structured `AddressSelection`.
*
* Fail-soft: when GOOGLE_PLACES_API_KEY is unset the BFF returns no suggestions,
* so the dropdown simply never opens and this behaves as a plain text input.
*/
export function AddressAutocomplete({
value,
onValueChange,
onSelect,
id = "property-address",
placeholder,
}: {
value: string;
onValueChange: (v: string) => void;
onSelect: (sel: AddressSelection) => void;
id?: string;
placeholder?: string;
}) {
const suggestFetcher = useFetcher<{ suggestions: PlaceSuggestion[] }>();
const detailsFetcher = useFetcher<{ address: AddressSelection | null }>();

const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const sessionRef = useRef<string>("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set the moment a suggestion is clicked; the details effect below consumes it
// so onSelect fires exactly once per resolved place (not on every re-render).
const pendingSelectRef = useRef(false);

const suggestions = suggestFetcher.data?.suggestions ?? [];

function ensureSession(): string {
if (!sessionRef.current) sessionRef.current = crypto.randomUUID();
return sessionRef.current;
}

function handleChange(next: string) {
onValueChange(next);
setActive(-1);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (next.trim().length < 2) {
setOpen(false);
return;
}
const session = ensureSession();
debounceRef.current = setTimeout(() => {
suggestFetcher.load(
`/resources/places?q=${encodeURIComponent(next.trim())}&session=${encodeURIComponent(session)}`,
);
setOpen(true);
}, 250);
}

function choose(s: PlaceSuggestion) {
onValueChange(s.description);
setOpen(false);
setActive(-1);
pendingSelectRef.current = true;
const session = ensureSession();
detailsFetcher.load(
`/resources/places?placeId=${encodeURIComponent(s.placeId)}&session=${encodeURIComponent(session)}`,
);
}

// When the details load settles, emit the structured selection once and start
// a fresh billing session for the next lookup.
useEffect(() => {
if (detailsFetcher.state !== "idle") return;
if (!pendingSelectRef.current) return;
const address = detailsFetcher.data?.address;
if (address) {
pendingSelectRef.current = false;
sessionRef.current = ""; // terminate the Google session token
onSelect(address);
}
// onSelect is a stable-enough callback from the caller; excluding it keeps
// this from re-firing on unrelated parent renders (RR fetcher convention).
}, [detailsFetcher.state, detailsFetcher.data]);

function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!open || suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => (i + 1) % suggestions.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
} else if (e.key === "Enter" && active >= 0) {
e.preventDefault();
choose(suggestions[active]);
} else if (e.key === "Escape") {
setOpen(false);
setActive(-1);
}
}

const listboxId = `${id}-listbox`;

return (
<div className="relative">
<input
id={id}
role="combobox"
aria-expanded={open && suggestions.length > 0}
aria-controls={listboxId}
aria-autocomplete="list"
autoComplete="off"
value={value}
placeholder={placeholder}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => value.trim().length >= 2 && suggestions.length > 0 && setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-card text-[13px] focus:shadow-ih-focus outline-none"
/>
{open && suggestions.length > 0 && (
<ul
id={listboxId}
role="listbox"
className="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto rounded-md border border-ih-border bg-ih-bg-card shadow-ih-popover py-1"
>
{suggestions.map((s, i) => (
<li
key={s.placeId}
role="option"
aria-selected={i === active}
// onMouseDown (not onClick) so it fires before the input's onBlur.
onMouseDown={(e) => {
e.preventDefault();
choose(s);
}}
onMouseEnter={() => setActive(i)}
className={`px-3 py-2 cursor-pointer text-[13px] ${i === active ? "bg-ih-primary-tint text-ih-primary" : "text-ih-fg-2"}`}
>
<span className="font-medium">{s.mainText}</span>
{s.secondaryText && <span className="text-ih-fg-4"> {s.secondaryText}</span>}
</li>
))}
</ul>
)}
{detailsFetcher.state === "loading" && (
<p className="mt-1 text-[11px] text-ih-fg-4">{m.common_loading()}</p>
)}
</div>
);
}
42 changes: 42 additions & 0 deletions app/components/address/GoogleMap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";

// Prevent the real SDK loader from injecting a <script> / touching the network:
// importLibrary never resolves, so the component only ever renders its
// placeholder div (which is all these tests assert).
vi.mock("@googlemaps/js-api-loader", () => ({
Loader: class {
importLibrary() {
return new Promise(() => {});
}
},
}));

vi.mock("react-router", () => ({ useRouteLoaderData: vi.fn() }));

import { GoogleMap } from "./GoogleMap";
import { useRouteLoaderData } from "react-router";

const mockRootData = vi.mocked(useRouteLoaderData);

describe("GoogleMap (fail-closed)", () => {
beforeEach(() => vi.clearAllMocks());

it("renders nothing when the Maps key is unset", () => {
mockRootData.mockReturnValue({ mapsApiKey: null });
const { container } = render(<GoogleMap lat={30.26} lng={-97.74} />);
expect(container.querySelector("[data-map]")).toBeNull();
});

it("renders nothing when coordinates are missing even with a key", () => {
mockRootData.mockReturnValue({ mapsApiKey: "k" });
const { container } = render(<GoogleMap />);
expect(container.querySelector("[data-map]")).toBeNull();
});

it("renders the map container when a key and coordinates are present", () => {
mockRootData.mockReturnValue({ mapsApiKey: "k" });
const { container } = render(<GoogleMap lat={30.26} lng={-97.74} />);
expect(container.querySelector("[data-map]")).not.toBeNull();
});
});
Loading
Loading