mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
feat(vendors): structure the address into Street/City/State with autocomplete
The Vendor form carried one free-text "Address (optional)" line, with City, State and Zip already present but hidden behind display:none, and a separate "Google Maps URL" input someone had to paste into by hand. Street Address, City and State are now three required fields. Typing three characters in Street Address offers up to four candidates; picking one fills all three at once, and typing without picking stays plain free text. The Google Maps URL input is gone — the location is derived from the address, the Street Address itself is the link in view mode, and a keyless map preview renders once all three parts are present. Zip stays in the payload but out of the form; the ticket scopes the visible set to three. The suggestion algorithm, candidate cities and copy are ported from the approved prototype rather than invented, so dev and design agree on what a dispatcher sees. Suggestions are deterministic for a given input on purpose: a reshuffling list moves a row out from under the pointer mid-click. Test fixtures that predate the requirement now carry an address, so each test still fails for the reason it is about. The two drawer tests asserting the stored-URL "Open in Google Maps" row are rewritten to the behaviour that replaced it. Delivers SH-271.
This commit is contained in:
parent
73346371e5
commit
aa707cb370
10 changed files with 488 additions and 65 deletions
|
|
@ -2,7 +2,6 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||
import { useWatch } from "react-hook-form";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -22,6 +21,8 @@ import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
|||
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
||||
import { useVendorRosterForm } from "./use-vendor-roster-form";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { AddressMapPreview } from "@/components/common/address-map-preview";
|
||||
import { mapsSearchUrl } from "@/lib/address/vendor-address";
|
||||
import type {
|
||||
VendorCompanyRoster,
|
||||
VendorListItem,
|
||||
|
|
@ -118,34 +119,43 @@ function DrawerHeader({
|
|||
}
|
||||
|
||||
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||
const address = [roster.address, roster.city, roster.state, roster.zip]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
// SH-271 replaced the separate "Open in Google Maps" row: the Street Address
|
||||
// itself is the link now, and the location is derived from the structured
|
||||
// parts rather than a stored URL.
|
||||
const address = { street: roster.address, city: roster.city, state: roster.state };
|
||||
const mapsUrl = mapsSearchUrl(address);
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Field label="Company" value={roster.name} />
|
||||
<Field label="Company Phone" value={roster.companyPhone} />
|
||||
<Field label="Company Email" value={roster.email} />
|
||||
<Field label="Address" value={address} />
|
||||
<Box>
|
||||
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
||||
Google Maps
|
||||
Street Address
|
||||
</Text>
|
||||
{roster.googleMapsUrl ? (
|
||||
{roster.address && mapsUrl ? (
|
||||
<Link
|
||||
href={roster.googleMapsUrl}
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
sx={{ "&:hover": { textDecoration: "underline" } }}
|
||||
>
|
||||
<LaunchIcon fontSize="small" />
|
||||
Open in Google Maps
|
||||
{roster.address}
|
||||
</Link>
|
||||
) : (
|
||||
<Text variant="description">—</Text>
|
||||
<Text variant="description">{roster.address || "—"}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Field label="City" value={roster.city} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Field label="State" value={roster.state} />
|
||||
</Box>
|
||||
</Stack>
|
||||
<AddressMapPreview address={address} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
Controller,
|
||||
useController,
|
||||
useFieldArray,
|
||||
useWatch,
|
||||
type Control,
|
||||
|
|
@ -21,6 +22,8 @@ import {
|
|||
Typography,
|
||||
} from "@mui/material";
|
||||
import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field";
|
||||
import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
|
||||
import { AddressMapPreview } from "@/components/common/address-map-preview";
|
||||
import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
|
||||
import {
|
||||
VENDOR_NOTES_MAX_LENGTH,
|
||||
|
|
@ -135,6 +138,9 @@ function CompanyFields({
|
|||
onClearSelectedCompany,
|
||||
}: VendorRosterFormFieldsProps) {
|
||||
const notes = useWatch({ control, name: "notes" }) ?? "";
|
||||
const { field: streetField } = useController({ control, name: "address" });
|
||||
const { field: cityField } = useController({ control, name: "city" });
|
||||
const { field: stateField } = useController({ control, name: "state" });
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
|
@ -148,50 +154,57 @@ function CompanyFields({
|
|||
onClearSelectedCompany={onClearSelectedCompany}
|
||||
/>
|
||||
<VendorCompanyContactFields control={control} errors={errors} />
|
||||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Address (optional)"
|
||||
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
<AddressAutocompleteField
|
||||
value={streetField.value ?? ""}
|
||||
onInputChange={streetField.onChange}
|
||||
onSelect={(parts) => {
|
||||
// One pick fills all three. City and State are written through
|
||||
// their own controllers so react-hook-form marks them dirty and
|
||||
// revalidates — a plain assignment would be discarded on save.
|
||||
streetField.onChange(parts.street);
|
||||
cityField.onChange(parts.city);
|
||||
stateField.onChange(parts.state);
|
||||
}}
|
||||
label="Street Address (required)"
|
||||
required
|
||||
error={Boolean(errors.address)}
|
||||
helperText={
|
||||
errors.address?.message ??
|
||||
"Pick a suggestion to auto-fill City/State, or type your own."
|
||||
}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="city"
|
||||
render={({ field }) => <TextField {...field} label="City" fullWidth />}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...cityField}
|
||||
value={cityField.value ?? ""}
|
||||
label="City (required)"
|
||||
error={Boolean(errors.city)}
|
||||
helperText={errors.city?.message}
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { required: true } }}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<TextField {...field} label="State" placeholder="e.g., MO" fullWidth />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="zip"
|
||||
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
|
||||
<TextField
|
||||
{...stateField}
|
||||
value={stateField.value ?? ""}
|
||||
label="State (required)"
|
||||
placeholder="e.g., MO"
|
||||
error={Boolean(errors.state)}
|
||||
helperText={errors.state?.message}
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { required: true } }}
|
||||
/>
|
||||
</Stack>
|
||||
<Controller
|
||||
control={control}
|
||||
name="googleMapsUrl"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Google Maps URL (optional)"
|
||||
placeholder="https://maps.google.com/…"
|
||||
error={Boolean(errors.googleMapsUrl)}
|
||||
helperText={errors.googleMapsUrl?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
name="zip"
|
||||
render={({ field }) => <TextField {...field} label="Zip" sx={{ display: "none" }} />}
|
||||
/>
|
||||
<AddressMapPreview
|
||||
address={{
|
||||
street: streetField.value ?? "",
|
||||
city: cityField.value ?? "",
|
||||
state: stateField.value ?? "",
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
|
|
|
|||
78
src/components/common/address-autocomplete-field.tsx
Normal file
78
src/components/common/address-autocomplete-field.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { Autocomplete, TextField } from "@mui/material";
|
||||
import {
|
||||
suggestAddresses,
|
||||
type AddressParts,
|
||||
type AddressSuggestion,
|
||||
} from "@/lib/address/vendor-address";
|
||||
|
||||
type AddressAutocompleteFieldProps = {
|
||||
value: string;
|
||||
onInputChange: (value: string) => void;
|
||||
/** Fired only when a candidate row is chosen, never while typing. */
|
||||
onSelect: (parts: AddressParts) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
error?: boolean;
|
||||
helperText?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Street Address input with mocked City/State completion.
|
||||
*
|
||||
* `freeSolo` is the whole point: typing without picking a row is a valid way to
|
||||
* finish the field. The candidates are a convenience for filling City and State
|
||||
* in one action, not a constraint on what the address may be.
|
||||
*
|
||||
* Shared rather than local to Vendors because Sites needs the identical control
|
||||
* when it is built (SH-273).
|
||||
*/
|
||||
export function AddressAutocompleteField({
|
||||
value,
|
||||
onInputChange,
|
||||
onSelect,
|
||||
label = "Street Address",
|
||||
placeholder = "Start typing the street address…",
|
||||
required = false,
|
||||
error = false,
|
||||
helperText,
|
||||
disabled = false,
|
||||
}: AddressAutocompleteFieldProps) {
|
||||
return (
|
||||
<Autocomplete<AddressSuggestion, false, false, true>
|
||||
freeSolo
|
||||
disabled={disabled}
|
||||
options={suggestAddresses(value)}
|
||||
// The options are already derived from the input; letting MUI filter them
|
||||
// again would drop every row whose label does not literally contain the
|
||||
// typed text.
|
||||
filterOptions={(options) => options}
|
||||
inputValue={value}
|
||||
onInputChange={(_event, next) => onInputChange(next)}
|
||||
onChange={(_event, selected) => {
|
||||
if (selected && typeof selected !== "string") {
|
||||
onSelect({ street: selected.street, city: selected.city, state: selected.state });
|
||||
}
|
||||
}}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
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.
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
htmlInput: { ...params.slotProps?.htmlInput, required },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
src/components/common/address-map-preview.tsx
Normal file
48
src/components/common/address-map-preview.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Box } from "@mui/material";
|
||||
import {
|
||||
buildAddressQuery,
|
||||
hasCompleteAddress,
|
||||
mapsEmbedUrl,
|
||||
type AddressParts,
|
||||
} from "@/lib/address/vendor-address";
|
||||
|
||||
type AddressMapPreviewProps = {
|
||||
address: Partial<AddressParts>;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Small inline map for a structured address.
|
||||
*
|
||||
* Uses Google's keyless `output=embed` form deliberately: SH-271 asks for a
|
||||
* 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.
|
||||
*/
|
||||
export function AddressMapPreview({ address, height = 160 }: AddressMapPreviewProps) {
|
||||
if (!hasCompleteAddress(address)) return null;
|
||||
|
||||
const query = buildAddressQuery(address);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="iframe"
|
||||
title={`Map of ${query}`}
|
||||
src={mapsEmbedUrl(address)}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
sx={{ display: "block", width: "100%", height, border: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,9 +57,12 @@ const baseCompanyFields = {
|
|||
companyContact: z.never().optional(),
|
||||
companyPhone: northAmericanPhone,
|
||||
email: optionalEmail,
|
||||
address: z.string(),
|
||||
city: z.string(),
|
||||
state: z.string(),
|
||||
// SH-271: the single combined Address line became three structured parts,
|
||||
// all required. `zip` stays optional and is not shown in the form — the
|
||||
// ticket scopes the visible set to Street/City/State.
|
||||
address: z.string().trim().min(1, "Street Address is required"),
|
||||
city: z.string().trim().min(1, "City is required"),
|
||||
state: z.string().trim().min(1, "State is required"),
|
||||
zip: z.string(),
|
||||
googleMapsUrl: httpsUrl,
|
||||
notes: z
|
||||
|
|
|
|||
135
src/lib/address/vendor-address.ts
Normal file
135
src/lib/address/vendor-address.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Structured address helpers shared by Vendors and, when it is built, Sites.
|
||||
*
|
||||
* The suggestion list is deliberately MOCKED — SH-271 asks for a real-feeling
|
||||
* autocomplete with no Google Places call and no API key. The algorithm,
|
||||
* candidate pool and copy are ported from the approved prototype so dev and
|
||||
* design agree on what a dispatcher sees.
|
||||
*/
|
||||
|
||||
export type AddressParts = {
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type AddressSuggestion = AddressParts & {
|
||||
/** What the option row reads as: "<typed street>, <city>, <state>". */
|
||||
label: string;
|
||||
};
|
||||
|
||||
/** Minimum characters before any suggestion appears. */
|
||||
export const ADDRESS_SUGGESTION_MIN_LENGTH = 3;
|
||||
|
||||
/** How many candidates the field offers at most. */
|
||||
export const ADDRESS_SUGGESTION_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* Candidate cities, ported verbatim from the prototype.
|
||||
*
|
||||
* These are plausible distribution-centre metros rather than a geocoder: the
|
||||
* point is that a dispatcher sees a short, stable list to pick from, not that
|
||||
* the address is real.
|
||||
*/
|
||||
const SUGGESTION_CITIES: ReadonlyArray<Omit<AddressParts, "street">> = [
|
||||
{ city: "Dallas", state: "TX" },
|
||||
{ city: "Indianapolis", state: "IN" },
|
||||
{ city: "Memphis", state: "TN" },
|
||||
{ city: "St. Louis", state: "MO" },
|
||||
{ city: "Orlando", state: "FL" },
|
||||
{ city: "Austin", state: "TX" },
|
||||
{ city: "Huntsville", state: "AL" },
|
||||
{ city: "Rockford", state: "IL" },
|
||||
{ city: "Columbus", state: "OH" },
|
||||
{ city: "Tampa", state: "FL" },
|
||||
{ city: "Cleveland", state: "OH" },
|
||||
{ city: "Atlanta", state: "GA" },
|
||||
{ city: "Chicago", state: "IL" },
|
||||
{ city: "Phoenix", state: "AZ" },
|
||||
{ city: "Portland", state: "OR" },
|
||||
{ city: "Jersey City", state: "NJ" },
|
||||
{ city: "Denver", state: "CO" },
|
||||
{ city: "Miami", state: "FL" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministic index from a seed string.
|
||||
*
|
||||
* Deterministic on purpose: the same typed text must offer the same candidates
|
||||
* every keystroke and every render, or the list reshuffles under the pointer
|
||||
* while someone is trying to click a row.
|
||||
*/
|
||||
function seededIndex(seed: string, size: number): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash % size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Up to four candidates for what the dispatcher has typed so far.
|
||||
*
|
||||
* The typed text is always kept as the street; only City and State are
|
||||
* proposed. Selecting a candidate fills all three, and typing without selecting
|
||||
* leaves plain free text — the field never locks the user out of their own
|
||||
* input.
|
||||
*/
|
||||
export function suggestAddresses(input: string): AddressSuggestion[] {
|
||||
const street = input.trim();
|
||||
if (street.length < ADDRESS_SUGGESTION_MIN_LENGTH) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const suggestions: AddressSuggestion[] = [];
|
||||
for (
|
||||
let attempt = 0;
|
||||
suggestions.length < ADDRESS_SUGGESTION_LIMIT && attempt < SUGGESTION_CITIES.length;
|
||||
attempt += 1
|
||||
) {
|
||||
const candidate =
|
||||
SUGGESTION_CITIES[seededIndex(`${street}#${attempt}`, SUGGESTION_CITIES.length)];
|
||||
if (!candidate) continue;
|
||||
const key = `${candidate.city},${candidate.state}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
suggestions.push({
|
||||
label: `${street}, ${candidate.city}, ${candidate.state}`,
|
||||
street,
|
||||
city: candidate.city,
|
||||
state: candidate.state,
|
||||
});
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/** The single-line form Google is asked about. Empty when there is nothing to ask. */
|
||||
export function buildAddressQuery(parts: Partial<AddressParts>): string {
|
||||
return [parts.street, parts.city, parts.state]
|
||||
.map((part) => part?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** Link target for the clickable Street Address. Empty string when unmappable. */
|
||||
export function mapsSearchUrl(parts: Partial<AddressParts>): string {
|
||||
const query = buildAddressQuery(parts);
|
||||
return query
|
||||
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed URL for the inline preview.
|
||||
*
|
||||
* `output=embed` is the keyless form: it renders in an iframe with no API key,
|
||||
* no billing account and no Places request, which is what SH-271 requires.
|
||||
*/
|
||||
export function mapsEmbedUrl(parts: Partial<AddressParts>): string {
|
||||
const query = buildAddressQuery(parts);
|
||||
return query ? `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed` : "";
|
||||
}
|
||||
|
||||
/** A preview is only meaningful once all three parts are present. */
|
||||
export function hasCompleteAddress(parts: Partial<AddressParts>): boolean {
|
||||
return Boolean(parts.street?.trim() && parts.city?.trim() && parts.state?.trim());
|
||||
}
|
||||
|
|
@ -51,9 +51,25 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
|
|||
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
|
||||
target: { value: name },
|
||||
});
|
||||
// SH-271 made the structured address required. These tests are about the
|
||||
// phone-or-email rule, so the address is filled to keep the form's only
|
||||
// outstanding problem the one under test.
|
||||
fillRequiredAddress();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
|
||||
}
|
||||
|
||||
function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) {
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), {
|
||||
target: { value: street },
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
|
||||
target: { value: city },
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), {
|
||||
target: { value: state },
|
||||
});
|
||||
}
|
||||
|
||||
describe("VendorCreateModal validation", () => {
|
||||
it("explains the company phone-or-email requirement after submission", async () => {
|
||||
renderCreateModal();
|
||||
|
|
|
|||
|
|
@ -49,9 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
|
|||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "dispatch@gateway.test",
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
// SH-271: Street/City/State are required, so a saved roster carries them.
|
||||
// Tests that need the incomplete case blank them explicitly.
|
||||
address: "6735 Trippel Rd",
|
||||
city: "Theodore",
|
||||
state: "AL",
|
||||
zip: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
|
|
@ -343,7 +345,9 @@ describe("VendorDetailDrawer design parity", () => {
|
|||
expect(screen.getByText("Preferred after-hours vendor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the Google Maps field below Address even when no URL is stored", () => {
|
||||
it("shows Street, City and State as separate rows with no stored-URL Google Maps row", () => {
|
||||
// SH-271 removed the "Open in Google Maps" row and the stored URL behind
|
||||
// it; the location is derived from the structured address instead.
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
|
||||
);
|
||||
|
|
@ -353,15 +357,37 @@ describe("VendorDetailDrawer design parity", () => {
|
|||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Google Maps")).toBeInTheDocument();
|
||||
expect(screen.getByText("Street Address")).toBeInTheDocument();
|
||||
expect(screen.getByText("City")).toBeInTheDocument();
|
||||
expect(screen.getByText("State")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Google Maps")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /Open in Google Maps/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links out to Google Maps when a URL is stored", () => {
|
||||
it("makes the street address itself the link to Google Maps", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||
route: "/vendors",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("link", { name: "6735 Trippel Rd" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no map preview and no link until the address is complete", () => {
|
||||
// A preview built from half an address points somewhere misleading.
|
||||
const roster = rosterWith([
|
||||
{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 },
|
||||
]);
|
||||
roster.data.googleMapsUrl = "https://maps.google.com/?q=1+Industrial+Pkwy";
|
||||
roster.data.address = "";
|
||||
roster.data.city = "";
|
||||
roster.data.state = "";
|
||||
useVendorCompanyRoster.mockReturnValue(roster);
|
||||
|
||||
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||
|
|
@ -369,10 +395,8 @@ describe("VendorDetailDrawer design parity", () => {
|
|||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("link", { name: /Open in Google Maps/ })).toHaveAttribute(
|
||||
"href",
|
||||
"https://maps.google.com/?q=1+Industrial+Pkwy",
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: /Trippel/ })).not.toBeInTheDocument();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts the status opposite Total Jobs and drops the redundant Status label", () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ const baseCompany = {
|
|||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "dispatch@gateway.test",
|
||||
// SH-271 made the structured address required, so a company that is meant to
|
||||
// parse cleanly has to carry one.
|
||||
address: "6735 Trippel Rd",
|
||||
city: "Theodore",
|
||||
state: "AL",
|
||||
};
|
||||
|
||||
describe("vendorCompanyRosterSchema", () => {
|
||||
|
|
@ -56,9 +61,11 @@ describe("vendorCompanyRosterSchema", () => {
|
|||
|
||||
it("accepts a company with zero technicians when phone is present", () => {
|
||||
const result = vendorCompanyRosterSchema.safeParse({
|
||||
...emptyVendorCompanyRosterForm,
|
||||
...baseCompany,
|
||||
name: "Solo Co",
|
||||
email: "",
|
||||
companyPhone: "(314) 555-0100",
|
||||
technicians: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data).not.toHaveProperty("companyContact");
|
||||
|
|
|
|||
89
src/test/lib/address/vendor-address.test.ts
Normal file
89
src/test/lib/address/vendor-address.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADDRESS_SUGGESTION_LIMIT,
|
||||
ADDRESS_SUGGESTION_MIN_LENGTH,
|
||||
buildAddressQuery,
|
||||
hasCompleteAddress,
|
||||
mapsEmbedUrl,
|
||||
mapsSearchUrl,
|
||||
suggestAddresses,
|
||||
} from "@/lib/address/vendor-address";
|
||||
|
||||
describe("suggestAddresses", () => {
|
||||
it("offers nothing until the minimum length is reached", () => {
|
||||
expect(suggestAddresses("")).toEqual([]);
|
||||
expect(suggestAddresses("12")).toEqual([]);
|
||||
expect(suggestAddresses(" 12 ")).toEqual([]);
|
||||
expect(suggestAddresses("123").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("caps the list so the dropdown stays scannable", () => {
|
||||
expect(suggestAddresses("6735 Trippel Rd").length).toBeLessThanOrEqual(
|
||||
ADDRESS_SUGGESTION_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the typed text as the street and only proposes city and state", () => {
|
||||
const [first] = suggestAddresses("415 Murphy Rd");
|
||||
expect(first?.street).toBe("415 Murphy Rd");
|
||||
expect(first?.city).toBeTruthy();
|
||||
expect(first?.state).toBeTruthy();
|
||||
expect(first?.label).toBe(`415 Murphy Rd, ${first?.city}, ${first?.state}`);
|
||||
});
|
||||
|
||||
it("is stable for the same input", () => {
|
||||
// The list must not reshuffle between renders, or a row moves out from
|
||||
// under the pointer mid-click.
|
||||
expect(suggestAddresses("4255 Anson Blvd")).toEqual(suggestAddresses("4255 Anson Blvd"));
|
||||
});
|
||||
|
||||
it("never repeats a city and state pair", () => {
|
||||
const suggestions = suggestAddresses("3050 Gateway Commerce Dr");
|
||||
const keys = suggestions.map((s) => `${s.city},${s.state}`);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("trims the typed text before using it", () => {
|
||||
const [first] = suggestAddresses(" 1715 Wall Triana Hwy ");
|
||||
expect(first?.street).toBe("1715 Wall Triana Hwy");
|
||||
});
|
||||
|
||||
it("exposes the minimum length it enforces", () => {
|
||||
expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH))).not.toEqual([]);
|
||||
expect(suggestAddresses("x".repeat(ADDRESS_SUGGESTION_MIN_LENGTH - 1))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("address queries and links", () => {
|
||||
const full = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" };
|
||||
|
||||
it("joins the parts it has and skips the ones it does not", () => {
|
||||
expect(buildAddressQuery(full)).toBe("6735 Trippel Rd Theodore AL");
|
||||
expect(buildAddressQuery({ street: "", city: "Dallas", state: "TX" })).toBe("Dallas TX");
|
||||
expect(buildAddressQuery({})).toBe("");
|
||||
});
|
||||
|
||||
it("builds a search link and an embed link for the same address", () => {
|
||||
expect(mapsSearchUrl(full)).toBe(
|
||||
"https://www.google.com/maps/search/?api=1&query=6735%20Trippel%20Rd%20Theodore%20AL",
|
||||
);
|
||||
expect(mapsEmbedUrl(full)).toBe(
|
||||
"https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty strings rather than a link to nowhere", () => {
|
||||
// An href of "" renders as no link; a link to a bare maps search would look
|
||||
// like an address we do not have.
|
||||
expect(mapsSearchUrl({})).toBe("");
|
||||
expect(mapsEmbedUrl({ street: " " })).toBe("");
|
||||
});
|
||||
|
||||
it("needs all three parts before a preview is meaningful", () => {
|
||||
expect(hasCompleteAddress(full)).toBe(true);
|
||||
expect(hasCompleteAddress({ street: "6735 Trippel Rd", city: "Theodore", state: " " })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(hasCompleteAddress({ street: "6735 Trippel Rd" })).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue