mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 11:39:00 +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 { 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 { AccountListItem } from "@/domain/accounts/types/account";
|
||||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
import { ClientSelect } from "./client-select";
|
import { ClientSelect } from "./client-select";
|
||||||
|
|
@ -26,6 +28,9 @@ export function SiteFormFields({
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = form;
|
} = form;
|
||||||
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
|
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 (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
|
@ -50,18 +55,27 @@ export function SiteFormFields({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
|
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
|
||||||
<TextField
|
<AddressAutocompleteField
|
||||||
{...register("address")}
|
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"
|
label="Street Address"
|
||||||
placeholder="Street address"
|
|
||||||
required
|
required
|
||||||
|
requiredMarker
|
||||||
error={Boolean(errors.address)}
|
error={Boolean(errors.address)}
|
||||||
helperText={errors.address?.message}
|
helperText={
|
||||||
fullWidth
|
errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
<TextField
|
<TextField
|
||||||
{...register("city")}
|
{...cityField}
|
||||||
label="City"
|
label="City"
|
||||||
required
|
required
|
||||||
error={Boolean(errors.city)}
|
error={Boolean(errors.city)}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ type AddressAutocompleteFieldProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
required?: boolean;
|
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;
|
error?: boolean;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -35,6 +40,7 @@ export function AddressAutocompleteField({
|
||||||
label = "Street Address",
|
label = "Street Address",
|
||||||
placeholder = "Start typing the street address…",
|
placeholder = "Start typing the street address…",
|
||||||
required = false,
|
required = false,
|
||||||
|
requiredMarker = false,
|
||||||
error = false,
|
error = false,
|
||||||
helperText,
|
helperText,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
|
@ -74,9 +80,9 @@ export function AddressAutocompleteField({
|
||||||
error={error}
|
error={error}
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
fullWidth
|
fullWidth
|
||||||
// Required is set on the input itself, never via MUI's `required`
|
required={requiredMarker}
|
||||||
// prop: this codebase spells the requirement in the label text and
|
// Required is set on the input itself; MUI's `required` prop only
|
||||||
// asserts that no generated asterisk renders anywhere.
|
// adds the label asterisk, which Vendors must not render.
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...params.slotProps,
|
...params.slotProps,
|
||||||
htmlInput: { ...params.slotProps?.htmlInput, required },
|
htmlInput: { ...params.slotProps?.htmlInput, required },
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { HTTPError } from "ky";
|
import { HTTPError } from "ky";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { AddSiteDialog } from "@/app/(protected)/locations/_components/add-site-dialog";
|
import { AddSiteDialog } from "@/app/(protected)/locations/_components/add-site-dialog";
|
||||||
import { normalizeApiRequestError } from "@/api/api-error";
|
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";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const create = vi.fn();
|
const create = vi.fn();
|
||||||
|
|
@ -57,7 +60,7 @@ async function fillRequiredFields(dialog: HTMLElement) {
|
||||||
target: { value: "dal1" },
|
target: { value: "dal1" },
|
||||||
});
|
});
|
||||||
await pickOption("Client", "Acme Logistics", "Acme");
|
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" },
|
target: { value: "3811 Distribution Dr" },
|
||||||
});
|
});
|
||||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), {
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), {
|
||||||
|
|
@ -112,6 +115,46 @@ describe("AddSiteDialog", () => {
|
||||||
expect(within(dialog).getByText("Area: West")).toBeInTheDocument();
|
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 () => {
|
it("creates the site with an upper-cased code, optional fields blank, then closes", async () => {
|
||||||
create.mockResolvedValue({});
|
create.mockResolvedValue({});
|
||||||
const { dialog, onClose } = renderDialog();
|
const { dialog, onClose } = renderDialog();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import LocationsListPage from "@/app/(protected)/locations/index";
|
import LocationsListPage from "@/app/(protected)/locations/index";
|
||||||
import type { Location, LocationListItem } from "@/domain/locations/types/location";
|
import type { Location, LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
import { suggestAddresses } from "@/lib/address/vendor-address";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const getList = vi.fn();
|
const getList = vi.fn();
|
||||||
|
|
@ -162,6 +164,37 @@ describe("Sites page detail panel", () => {
|
||||||
expect(within(region).getByText("Fort Worth")).toBeInTheDocument();
|
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 () => {
|
it("Cancel discards edits and returns to view without an API call", async () => {
|
||||||
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
||||||
const region = await openSite("DAL1");
|
const region = await openSite("DAL1");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue