mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
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:
parent
fe0d1a2571
commit
3f3a3701c3
4 changed files with 107 additions and 11 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue