feat(sites): suggest addresses as Street Address is typed

SH-331 requires Street Address suggestions after 3 typed characters,
with a pick filling Street, City and State and unpicked typing kept as
free text. SH-272, which SH-331 consolidates, specifies those
suggestions as mocked, and the shared AddressAutocompleteField that
Vendors already uses (SH-271) was built for Sites to reuse. No Places
key is involved.

The Add Site modal and the detail panel's edit mode now render that
field. A pick writes all three values through their own controllers,
so the form marks them dirty and Save changes enables in edit mode.
The field gains an opt-in requiredMarker prop so Sites keeps its label
asterisk while Vendors stays without one.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 13:10:03 -03:00
parent fe0d1a2571
commit 3f3a3701c3
4 changed files with 107 additions and 11 deletions

View file

@ -1,5 +1,7 @@
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
import { Controller, useController, useWatch, type UseFormReturn } from "react-hook-form";
import { Stack, TextField } from "@mui/material";
import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
import { normalizeStateCode } from "@/data/areas";
import type { AccountListItem } from "@/domain/accounts/types/account";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { ClientSelect } from "./client-select";
@ -26,6 +28,9 @@ export function SiteFormFields({
formState: { errors },
} = form;
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
const { field: addressField } = useController({ control, name: "address" });
const { field: cityField } = useController({ control, name: "city" });
const { field: stateField } = useController({ control, name: "state" });
return (
<Stack spacing={2}>
@ -50,18 +55,27 @@ export function SiteFormFields({
)}
/>
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
<TextField
{...register("address")}
<AddressAutocompleteField
value={addressField.value}
onInputChange={addressField.onChange}
onSelect={(parts) => {
// One pick fills all three, each through its own controller so the
// form marks them dirty and revalidates (edit mode's Save depends on it).
addressField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(normalizeStateCode(parts.state) ?? "");
}}
label="Street Address"
placeholder="Street address"
required
requiredMarker
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
helperText={
errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own."
}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...register("city")}
{...cityField}
label="City"
required
error={Boolean(errors.city)}

View file

@ -13,6 +13,11 @@ type AddressAutocompleteFieldProps = {
label?: string;
placeholder?: string;
required?: boolean;
/**
* Show MUI's required asterisk on the label. Sites marks required fields that
* way; Vendors spells "(required)" in the label and asserts no asterisk.
*/
requiredMarker?: boolean;
error?: boolean;
helperText?: string;
disabled?: boolean;
@ -35,6 +40,7 @@ export function AddressAutocompleteField({
label = "Street Address",
placeholder = "Start typing the street address…",
required = false,
requiredMarker = false,
error = false,
helperText,
disabled = false,
@ -74,9 +80,9 @@ export function AddressAutocompleteField({
error={error}
helperText={helperText}
fullWidth
// Required is set on the input itself, never via MUI's `required`
// prop: this codebase spells the requirement in the label text and
// asserts that no generated asterisk renders anywhere.
required={requiredMarker}
// Required is set on the input itself; MUI's `required` prop only
// adds the label asterisk, which Vendors must not render.
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps?.htmlInput, required },

View file

@ -1,9 +1,12 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HTTPError } from "ky";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AddSiteDialog } from "@/app/(protected)/locations/_components/add-site-dialog";
import { normalizeApiRequestError } from "@/api/api-error";
import { formatStateOptionLabel, type StateCode } from "@/data/areas";
import { suggestAddresses } from "@/lib/address/vendor-address";
import { renderWithProviders } from "@/test/test-utils";
const create = vi.fn();
@ -57,7 +60,7 @@ async function fillRequiredFields(dialog: HTMLElement) {
target: { value: "dal1" },
});
await pickOption("Client", "Acme Logistics", "Acme");
fireEvent.change(within(dialog).getByRole("textbox", { name: "Street Address" }), {
fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), {
target: { value: "3811 Distribution Dr" },
});
fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), {
@ -112,6 +115,46 @@ describe("AddSiteDialog", () => {
expect(within(dialog).getByText("Area: West")).toBeInTheDocument();
});
it("suggests addresses from 3 typed characters and fills Street, City and State on pick", async () => {
const user = userEvent.setup();
const { dialog } = renderDialog();
const street = within(dialog).getByRole("combobox", { name: "Street Address" });
await user.type(street, "38");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await user.type(street, "1");
const picked = suggestAddresses("381")[0];
if (picked === undefined) throw new Error("expected a suggestion for 3 typed characters");
const listbox = await screen.findByRole("listbox");
await user.click(within(listbox).getByRole("option", { name: picked.label }));
await waitFor(() => expect(street).toHaveValue("381"));
expect(within(dialog).getByRole("textbox", { name: "City" })).toHaveValue(picked.city);
expect(within(dialog).getByRole("combobox", { name: "State" })).toHaveValue(
formatStateOptionLabel(picked.state as StateCode),
);
expect(within(dialog).getByTitle("Site location map")).toBeInTheDocument();
});
it("keeps typed Street Address text when no suggestion is picked", async () => {
create.mockResolvedValue({});
const user = userEvent.setup();
const { dialog, onClose } = renderDialog();
await fillRequiredFields(dialog);
const street = within(dialog).getByRole("combobox", { name: "Street Address" });
await user.clear(street);
await user.type(street, "12 Custom Ln");
await user.keyboard("{Escape}");
fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" }));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ address: "12 Custom Ln", city: "Dallas", state: "TX" }),
);
});
it("creates the site with an upper-cased code, optional fields blank, then closes", async () => {
create.mockResolvedValue({});
const { dialog, onClose } = renderDialog();

View file

@ -1,7 +1,9 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LocationsListPage from "@/app/(protected)/locations/index";
import type { Location, LocationListItem } from "@/domain/locations/types/location";
import { suggestAddresses } from "@/lib/address/vendor-address";
import { renderWithProviders } from "@/test/test-utils";
const getList = vi.fn();
@ -162,6 +164,37 @@ describe("Sites page detail panel", () => {
expect(within(region).getByText("Fort Worth")).toBeInTheDocument();
});
it("edit mode: picking an address suggestion fills Street, City and State and enables Save", async () => {
update.mockResolvedValue({});
getById.mockResolvedValue(dallas);
const user = userEvent.setup();
renderWithProviders(<LocationsListPage />, { withAuth: false });
const region = await openSite("DAL1");
fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
const save = within(region).getByRole("button", { name: "Save changes" });
const street = within(region).getByRole("combobox", { name: "Street Address" });
await user.clear(street);
await user.type(street, "415 Murphy Rd");
const picked = suggestAddresses("415 Murphy Rd").find((option) => option.city !== "Dallas");
if (picked === undefined) throw new Error("expected a non-Dallas suggestion");
await user.click(await screen.findByRole("option", { name: picked.label }));
await waitFor(() => expect(save).toBeEnabled());
expect(within(region).getByRole("textbox", { name: "City" })).toHaveValue(picked.city);
fireEvent.click(save);
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
expect(update).toHaveBeenCalledWith(
"1",
expect.objectContaining({
address: "415 Murphy Rd",
city: picked.city,
state: picked.state,
}),
);
});
it("Cancel discards edits and returns to view without an API call", async () => {
renderWithProviders(<LocationsListPage />, { withAuth: false });
const region = await openSite("DAL1");