diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index 236a33d9..4ced48a3 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 8aea10f2..aaf1b606 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [ "city", "state", "zip", - "googleMapsUrl", "notes", ] as const; diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index 24d51ce8..a472546e 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -40,7 +40,8 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, + // The deprecated stored Maps URL is intentionally not seeded: the form has + // no input for it, and carrying it would re-emit a legacy value on write. notes: roster.notes, technicians: roster.technicians.map((technician) => ({ id: technician.id, diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index 3d69562a..cf23d357 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -205,6 +205,7 @@ function CompanyFields({ city: cityField.value ?? "", state: stateField.value ?? "", }} + placeholderWhenIncomplete /> void; @@ -56,12 +63,22 @@ export function VendorDialog({ ...dialogBlurBackdropSlotProps, paper: { className: "w-full", - style: { borderRadius: 12, maxWidth: 560 }, + style: { + borderRadius: 12, + maxWidth: 560, + maxHeight: VENDOR_DIALOG_SURFACE_MAX_HEIGHT, + display: "flex", + flexDirection: "column", + }, onClick: (e: MouseEvent) => e.stopPropagation(), }, }} > - e.stopPropagation()}> + e.stopPropagation()} + style={{ overflowY: "auto", minHeight: 0 }} + > Vendor diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx index a1f31405..30e344c6 100644 --- a/src/components/common/address-autocomplete-field.tsx +++ b/src/components/common/address-autocomplete-field.tsx @@ -49,11 +49,21 @@ export function AddressAutocompleteField({ // typed text. filterOptions={(options) => options} inputValue={value} - onInputChange={(_event, next) => onInputChange(next)} + onInputChange={(_event, next, reason) => { + // Genuine typing and the clear control update the street. MUI also + // fires this callback with reason "reset" after a selection, carrying + // the option's full ", , " label — forwarding + // that would overwrite the just-picked street with the label. + if (reason !== "input" && reason !== "clear") return; + onInputChange(next); + }} onChange={(_event, selected) => { if (selected && typeof selected !== "string") { onSelect({ street: selected.street, city: selected.city, state: selected.state }); + return; } + + if (selected === null) onSelect({ street: "", city: "", state: "" }); }} getOptionLabel={(option) => (typeof option === "string" ? option : option.label)} renderInput={(params) => ( diff --git a/src/components/common/address-map-preview.tsx b/src/components/common/address-map-preview.tsx index 91eee654..5d1892ce 100644 --- a/src/components/common/address-map-preview.tsx +++ b/src/components/common/address-map-preview.tsx @@ -1,4 +1,5 @@ import { Box } from "@mui/material"; +import { Text } from "@/components/ui/text"; import { buildAddressQuery, hasCompleteAddress, @@ -16,8 +17,17 @@ type AddressMapPreviewProps = { */ query?: string; height?: number; + /** + * Render a compact accessible placeholder while the structured address is + * incomplete, instead of nothing. Meant for Add/Edit forms, where the empty + * state tells the dispatcher which parts the preview is still waiting on. + */ + placeholderWhenIncomplete?: boolean; }; +const INCOMPLETE_PLACEHOLDER_MESSAGE = + "Map preview appears once Street, City, and State are all filled."; + /** * Small inline map for a structured address. * @@ -25,19 +35,47 @@ type AddressMapPreviewProps = { * preview with no API key, so there is no Maps JavaScript API, no Places * request and no billing account behind this. * - * Renders nothing until Street, City and State are all present — a preview of a - * half-typed address points somewhere misleading, which is worse than no map. + * Structured input renders nothing until Street, City and State are all + * present — a preview of a half-typed address points somewhere misleading, + * which is worse than no map. Add/Edit forms opt into a compact placeholder + * for that incomplete state; a pre-composed saved line is map-only and renders + * only when non-empty. */ export function AddressMapPreview({ address, query: rawQuery, height = 160, + placeholderWhenIncomplete = false, }: AddressMapPreviewProps) { // Structured input is held to the completeness rule; a pre-composed line is // saved data and only has to be non-empty. const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim(); - const renderable = address ? hasCompleteAddress(address) : Boolean(query); - if (!renderable) return null; + const complete = address ? hasCompleteAddress(address) : Boolean(query); + + if (!complete) { + if (!(address != null && placeholderWhenIncomplete)) return null; + return ( + + + {INCOMPLETE_PLACEHOLDER_MESSAGE} + + + ); + } + return ( { city: "", state: "", zip: "", - googleMapsUrl: "", notes: "", technicians: [ { @@ -322,6 +320,46 @@ describe("useVendorRosterForm prototype defaults", () => { }); }); +describe("useVendorRosterForm legacy stored Maps URL", () => { + beforeEach(() => { + rosterGet.mockReset(); + saveMutate.mockReset(); + }); + + it("saves an edit of a legacy roster whose stored Maps URL is plain HTTP", async () => { + const legacyRoster: VendorCompanyRoster = { + ...roster, + googleMapsUrl: "http://maps.example.test/legacy", + }; + rosterQueryResult = { + data: legacyRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }; + const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), { + wrapper: makeWrapper(createClient()), + }); + + // Exercise the real resolver: while the form schema validated the hidden + // loaded URL, this submit never reached the save path. + await act(async () => { + await result.current.handleSubmit(result.current.submit)({ + preventDefault: () => undefined, + } as unknown as FormEvent); + }); + + expect(saveMutate).toHaveBeenCalledTimes(1); + const input = saveMutate.mock.calls[0]?.[0] as { + mode: string; + values: VendorCompanyRosterFormValues; + }; + expect(input.mode).toBe("update"); + expect(input.values).not.toHaveProperty("googleMapsUrl"); + }); +}); + describe("useVendorRosterForm technician removal", () => { it("keeps a valid roster saveable after an existing technician is removed", async () => { rosterQueryResult = { diff --git a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx new file mode 100644 index 00000000..28a3b106 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx @@ -0,0 +1,142 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { describe, expect, it, vi } from "vitest"; +import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; +import { + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { suggestAddresses } from "@/lib/address/vendor-address"; +import { renderWithProviders } from "@/test/test-utils"; + +const TYPED_STREET = "415 Murphy Rd"; +const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0]; + +if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street"); + +function AddressFormHarness({ + onValid, +}: { + onValid: (values: VendorCompanyRosterFormValues) => void; +}) { + const { control, handleSubmit, formState } = useForm({ + defaultValues: emptyVendorCompanyRosterForm, + resolver: zodResolver(vendorCompanyRosterSchema), + mode: "onChange", + }); + return ( +
+ + + + ); +} + +function fillCompanyBasics(): void { + fireEvent.change(screen.getByRole("textbox", { name: "Company (required)" }), { + target: { value: "Gateway Plumbing" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "Company Phone (optional)" }), { + target: { value: "3145550100" }, + }); +} + +describe("VendorRosterFormFields address autocomplete", () => { + it("keeps the picked street when a suggestion whose label differs is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + + const listbox = await screen.findByRole("listbox"); + const option = within(listbox) + .getAllByRole("option") + .find((candidate) => candidate.textContent === FIRST_SUGGESTION.label); + expect(option).toBeDefined(); + expect(FIRST_SUGGESTION.label).not.toBe(FIRST_SUGGESTION.street); + + await user.click(option as HTMLElement); + + await waitFor(() => { + expect(street).toHaveValue(FIRST_SUGGESTION.street); + }); + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( + FIRST_SUGGESTION.city, + ); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( + FIRST_SUGGESTION.state, + ); + }); + + it("submits the settled structured parts, not the option label", async () => { + const user = userEvent.setup(); + const onValid = vi.fn(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement); + fillCompanyBasics(); + + await user.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + const values = onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues; + expect(values.address).toBe(FIRST_SUGGESTION.street); + expect(values.city).toBe(FIRST_SUGGESTION.city); + expect(values.state).toBe(FIRST_SUGGESTION.state); + }); + + it("clears every structured address part from the autocomplete clear control", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement); + + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( + FIRST_SUGGESTION.city, + ); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( + FIRST_SUGGESTION.state, + ); + await user.click(screen.getByRole("button", { name: "Clear" })); + + expect(street).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(""); + }); +}); + +describe("VendorRosterFormFields map preview empty state", () => { + it("shows the accessible placeholder while incomplete, then the map once complete", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + expect(screen.getByRole("status")).toHaveTextContent(/street, city, and state/i); + expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument(); + + await user.type( + screen.getByRole("combobox", { name: "Street Address (required)" }), + TYPED_STREET, + ); + expect(screen.getByRole("status")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { + target: { value: "Theodore" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { + target: { value: "AL" }, + }); + + expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx new file mode 100644 index 00000000..013dcc2d --- /dev/null +++ b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx @@ -0,0 +1,90 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + VENDOR_DIALOG_SURFACE_MAX_HEIGHT, + VendorDialog, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog"; +import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({ + useCreateVendor: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +const LIVE_VENDOR: VendorDropdownItem = { + id: 10, + companyName: "Gateway Plumbing", + contactName: "Jordan Lee", + tradeSpecialties: "Plumbing", + address: "900 Live St", + distanceMiles: 4.2, + email: "dispatch@gateway.test", +}; + +describe("VendorDialogCompanyCard map preview queries", () => { + it("embeds the live vendor address", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const frame = screen.getByTitle("Map of 900 Live St"); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=900%20Live%20St&output=embed", + ); + }); + + it("embeds the frozen company's composed snapshot address, not the live one", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const frame = screen.getByTitle("Map of 200 Vendor Ave, Chicago, IL, 60602"); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=200%20Vendor%20Ave%2C%20Chicago%2C%20IL%2C%2060602&output=embed", + ); + expect(screen.queryByTitle("Map of 900 Live St")).not.toBeInTheDocument(); + }); +}); + +describe("VendorDialog scroll boundary", () => { + it("bounds the dialog surface and scrolls internally", () => { + renderWithProviders( + , + ); + + const surface = screen.getByRole("dialog"); + expect(VENDOR_DIALOG_SURFACE_MAX_HEIGHT).toBe("55vh"); + expect(surface.style.maxHeight).toBe(VENDOR_DIALOG_SURFACE_MAX_HEIGHT); + expect(surface.style.display).toBe("flex"); + + const content = screen.getByRole("dialog").querySelector(".MuiDialogContent-root"); + expect(content).not.toBeNull(); + expect(content).toHaveStyle({ overflowY: "auto", minHeight: "0px" }); + }); +}); diff --git a/src/test/components/common/address-map-preview.test.tsx b/src/test/components/common/address-map-preview.test.tsx new file mode 100644 index 00000000..c1564424 --- /dev/null +++ b/src/test/components/common/address-map-preview.test.tsx @@ -0,0 +1,68 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AddressMapPreview } from "@/components/common/address-map-preview"; +import { renderWithProviders } from "@/test/test-utils"; + +const complete = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" }; + +function mapFrame() { + return screen.getByTitle("Map of 6735 Trippel Rd Theodore AL"); +} + +describe("AddressMapPreview structured input", () => { + it("renders a compact accessible placeholder while the address is incomplete", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const placeholder = screen.getByRole("status"); + expect(placeholder).toHaveTextContent(/street, city, and state/i); + expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument(); + }); + + it("transitions from the placeholder to the iframe once the address completes", () => { + const { rerender } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("status")).toBeInTheDocument(); + + rerender(); + const frame = mapFrame(); + expect(frame).toBeInTheDocument(); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed", + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("stays blank for incomplete input when the placeholder is not opted into", () => { + const { container } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(container.querySelector("iframe")).toBeNull(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); + +describe("AddressMapPreview pre-composed query", () => { + it("renders the map only when the saved query is non-empty", () => { + const { container, rerender } = renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByTitle("Map of 900 Live St")).toBeInTheDocument(); + + rerender(); + expect(container.querySelector("iframe")).toBeNull(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index 65494d15..4c9a463f 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -66,7 +66,7 @@ describe("vendor roster mapper", () => { name: "Gateway Plumbing", companyPhone: "314-555-0100", email: "dispatch@gateway.test", - googleMapsUrl: "https://maps.google.com/x", + googleMapsUrl: "http://maps.google.com/legacy", tradeSpecialties: "Plumbing", rowVersion: "rv-2", technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }], @@ -82,6 +82,9 @@ describe("vendor roster mapper", () => { expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" }), ]); expect(payload).not.toHaveProperty("tradeSpecialties"); + // The stored Maps URL is deprecated: a legacy value reaching the mapper + // (from stale callers or old records) must not be emitted to the backend. + expect(payload).not.toHaveProperty("googleMapsUrl"); }); it("omits rowVersion from the create payload when absent", () => { diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts index 37dde861..da7bcff4 100644 --- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts @@ -3,7 +3,6 @@ import { VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, emptyRosterTechnician, emptyVendorCompanyRosterForm, - isAbsoluteHttpsUrl, VENDOR_NOTES_MAX_LENGTH, vendorCompanyRosterSchema, vendorCompanyRosterUpdateSchema, @@ -105,21 +104,26 @@ describe("vendorCompanyRosterSchema", () => { expect(result.success).toBe(true); }); - it("rejects malformed emails, non-HTTPS map URLs, and partial phones", () => { + it("rejects malformed emails and partial phones", () => { const result = vendorCompanyRosterSchema.safeParse({ ...baseCompany, email: "not-an-email", - googleMapsUrl: "http://maps.google.com/x", technicians: [{ ...emptyRosterTechnician, contactName: "Adam", phone: "(314)" }], }); expect(result.success).toBe(false); }); - it("rejects a maps URL that is a non-absolute https prefix string", () => { - expect(isAbsoluteHttpsUrl("https://")).toBe(false); - expect(isAbsoluteHttpsUrl("maps.google.com/x")).toBe(false); - expect(isAbsoluteHttpsUrl("https://maps.google.com/x")).toBe(true); - expect(isAbsoluteHttpsUrl("")).toBe(true); + it("tolerates a legacy stored Maps URL instead of failing HTTPS validation", () => { + // Legacy records carry a stored googleMapsUrl — often plain HTTP. The form + // no longer holds the field, so leftover values loaded from old data must + // neither fail validation nor re-enter the parsed output. + const result = vendorCompanyRosterSchema.safeParse({ + ...baseCompany, + googleMapsUrl: "http://maps.google.com/legacy", + }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data).not.toHaveProperty("googleMapsUrl"); }); it("rejects an unknown preferred contact", () => { diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx index f03c2a27..aaec2167 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx @@ -74,7 +74,6 @@ function formValues( city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, notes: roster.notes, technicians: [newTechnician], ...overrides, diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts index f0547378..735ef2d7 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts @@ -36,7 +36,6 @@ const values = { city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, notes: roster.notes, technicians: roster.technicians.map((technician) => ({ id: technician.id,