From 3f3a3701c3832d8d952929f1e891e27f1d9f0699 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:10:03 -0300 Subject: [PATCH] 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. --- .../_components/site-form-fields.tsx | 28 +++++++++--- .../common/address-autocomplete-field.tsx | 12 +++-- .../locations/add-site-dialog.test.tsx | 45 ++++++++++++++++++- .../locations/site-detail-panel.test.tsx | 33 ++++++++++++++ 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx index cbff09cf..1f94d7fa 100644 --- a/src/app/(protected)/locations/_components/site-form-fields.tsx +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -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 ( @@ -50,18 +55,27 @@ export function SiteFormFields({ )} /> - { + // 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." + } /> { 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(); 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 5eaace69..27e0edc4 100644 --- a/src/test/app/(protected)/locations/site-detail-panel.test.tsx +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -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(, { 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(, { withAuth: false }); const region = await openSite("DAL1");