Merge origin/dev into SH-138 site contacts

This commit is contained in:
Codex Review Integration 2026-09-15 19:07:46 -03:00
commit a6524b2785
40 changed files with 1268 additions and 208 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View file

@ -409,7 +409,16 @@ test.describe("Vendor directory prototype parity", () => {
);
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
const tradeBadge = page.getByTestId("vendor-trade-badge").first();
await expect(tradeBadge).toHaveText("+1");
await tradeBadge.hover();
await expect(page.getByRole("tooltip")).toHaveText("Backflow Preventers");
await tradeBadge.focus();
await expect(tradeBadge).toBeFocused();
await tradeBadge.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await tradeBadge.press("Enter");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("2 active technicians")).toBeVisible();
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
@ -587,10 +596,16 @@ test.describe("Vendor directory prototype parity", () => {
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
"https://maps.google.com/gateway",
// SH-271: one combined "Address (optional)" line became three required
// fields, and the hand-pasted Google Maps URL input is gone — the location
// is derived from the address now.
await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue(
"1 Market St",
);
await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis");
await expect(page.getByLabel("State (required)")).toHaveValue("MO");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0);
await expect(page.getByLabel("Address (optional)")).toHaveCount(0);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1);
await expect(page.getByLabel("Technician name (optional)")).toHaveValue("");
@ -643,9 +658,15 @@ test.describe("Vendor directory prototype parity", () => {
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
await expect(
detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(),
).toHaveAttribute("target", "_blank");
// SH-271: the separate "Open in Google Maps" row is gone — the Street
// Address itself is the link, built from the structured parts.
const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first();
await expect(streetLink).toHaveAttribute("target", "_blank");
await expect(streetLink).toHaveAttribute(
"href",
"https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO",
);
await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
@ -693,6 +714,12 @@ test.describe("Vendor directory prototype parity", () => {
).toBeEnabled();
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
// SH-271 made the structured address required. This test is about keyboard
// dismissal and free-text company creation, so the address is filled to
// leave those as the only things it is exercising.
await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St");
await page.getByLabel("City (required)").fill("St. Louis");
await page.getByLabel("State (required)").fill("MO");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");

View file

@ -70,6 +70,13 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
// The vendor address map preview embeds maps.google.com in an iframe. Its
// content depends on the network, on timing, and on whatever Google serves
// that minute, none of which belongs in a test with "deterministic pixel
// regression" in its name. Aborting the request pins the frame to a blank
// box, so the shot measures our layout and nothing else.
await page.route(/https:\/\/(www\.)?google\.com\/maps/, (route) => route.abort());
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());

106
package-lock.json generated
View file

@ -3137,14 +3137,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@ -3158,8 +3158,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.10",
"vitest": "4.1.10"
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -3168,16 +3168,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -3186,13 +3186,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -3213,9 +3213,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3226,13 +3226,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@ -3240,14 +3240,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -3256,9 +3256,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@ -3266,13 +3266,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -4378,9 +4378,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"dev": true,
"funding": [
{
@ -6942,19 +6942,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -6982,12 +6982,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [
"city",
"state",
"zip",
"googleMapsUrl",
"notes",
] as const;

View file

@ -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,

View file

@ -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>
);
}

View file

@ -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,58 @@ 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 ?? "",
}}
placeholderWhenIncomplete
/>
<Controller
control={control}

View file

@ -51,6 +51,12 @@ function stopPropagation(event: MouseEvent): void {
event.stopPropagation();
}
function stopTradeBadgeKeyDown(event: KeyboardEvent): void {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
}
interface TruncatedTextProps {
value: string;
testId: string;
@ -129,10 +135,54 @@ interface VendorTableRowProps {
onOpenEdit: (row: VendorListItem) => void;
}
function VendorTradeCell({
primaryTrade,
omittedTrades,
}: {
primaryTrade: string;
omittedTrades: string[];
}) {
const extraTrades = omittedTrades.length;
return (
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && (
<Tooltip title={omittedTrades.join(", ")} placement="top" arrow>
<Box
component="span"
tabIndex={0}
aria-label={`+${extraTrades} more ${extraTrades === 1 ? "trade" : "trades"}: ${omittedTrades.join(", ")}`}
data-testid="vendor-trade-badge"
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
onClick={stopPropagation}
onKeyDown={stopTradeBadgeKeyDown}
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
</Tooltip>
)}
</Stack>
)}
</TableCell>
);
}
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
const trades = splitTrades(row.tradeSpecialties);
const primaryTrade = trades[0] ?? "—";
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
const omittedTrades = trades.slice(1);
const { locationText, phoneText } = getVendorDisplayValues(row);
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
@ -197,30 +247,7 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
color="var(--color-muted-foreground)"
/>
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && (
<Box
component="span"
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
)}
</Stack>
)}
</TableCell>
<VendorTradeCell primaryTrade={primaryTrade} omittedTrades={omittedTrades} />
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
<TruncatedText
value={phoneText || "—"}

View file

@ -13,11 +13,7 @@ import {
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
import { AdvDateRangeSection } from "./adv-date-range-section";
import { AdvMultiSelect } from "./adv-multi-select";
import {
AdvAvetaSection,
AdvFlagInternalUpliftSections,
AdvIndicatorsSection,
} from "./adv-parity-filter-sections";
import { AdvFlagInternalUpliftSections, AdvIndicatorsSection } from "./adv-parity-filter-sections";
import { AdvSection } from "./adv-section";
import { AdvSeverityList } from "./adv-severity-list";
import { AdvTypeGrid } from "./adv-type-grid";
@ -131,8 +127,6 @@ export function AdvancedSearchFilterSections({
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
<AdvAvetaSection draft={draft} setDraft={setDraft} />
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
<AdvMultiSelect
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}

View file

@ -125,6 +125,12 @@ export function AdvIndicatorsSection({ draft, setDraft }: DraftProps) {
);
}
const FILTER_TOGGLE_CHECKBOX_SX = {
padding: 0,
color: "var(--color-border-hover)",
"&.Mui-checked": { color: "var(--color-primary)" },
} as const;
export function AdvAvetaSection({ draft, setDraft }: DraftProps) {
return (
<AdvSection icon={ShieldCheck} label="Aveta Compliance" count={draft.avetaOnly ? 1 : 0}>
@ -133,11 +139,7 @@ export function AdvAvetaSection({ draft, setDraft }: DraftProps) {
size="small"
checked={draft.avetaOnly}
onChange={() => setDraft((prev) => ({ ...prev, avetaOnly: !prev.avetaOnly }))}
sx={{
padding: 0,
color: "var(--color-border-hover)",
"&.Mui-checked": { color: "var(--warning)" },
}}
sx={FILTER_TOGGLE_CHECKBOX_SX}
/>
<span className="text-sm text-muted-foreground">Aveta required only</span>
</label>
@ -195,6 +197,8 @@ export function AdvFlagInternalUpliftSections({
</button>
</AdvSection>
<AdvAvetaSection draft={draft} setDraft={setDraft} />
<AdvSection icon={DollarSign} label="Uplift" count={draft.hasUplift ? 1 : 0}>
<label className="flex cursor-pointer items-center gap-1.5 select-none">
<Checkbox
@ -207,11 +211,7 @@ export function AdvFlagInternalUpliftSections({
upliftStatuses: prev.hasUplift ? new Set() : prev.upliftStatuses,
}))
}
sx={{
padding: 0,
color: "var(--color-border-hover)",
"&.Mui-checked": { color: "var(--color-primary)" },
}}
sx={FILTER_TOGGLE_CHECKBOX_SX}
/>
<span className="text-sm text-muted-foreground">Has uplift</span>
</label>

View file

@ -55,11 +55,17 @@ export function useAdvancedSearchDraft({
};
const handleClearAll = () => {
if (activeCount === 0) {
return;
}
setDraft(cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS));
onClearAll();
};
const handleApply = () => {
if (activeCount === 0) {
return;
}
onApply(cloneAdvancedFilters(draft));
onOpenChange(false);
};

View file

@ -64,7 +64,12 @@ export function WorkOrdersAdvancedSearchSheet({
/>
<div className="flex shrink-0 items-center gap-2 border-t border-border px-4 py-3">
<Button variant="ghost" className="text-xs" onClick={handleClearAll}>
<Button
variant="ghost"
className="text-xs"
disabled={activeCount === 0}
onClick={handleClearAll}
>
Clear all
</Button>
<div className="flex flex-1 justify-center">
@ -74,7 +79,7 @@ export function WorkOrdersAdvancedSearchSheet({
</span>
)}
</div>
<Button className="px-5 text-xs" onClick={handleApply}>
<Button className="px-5 text-xs" disabled={activeCount === 0} onClick={handleApply}>
Apply filters
</Button>
</div>

View file

@ -1,4 +1,5 @@
import { ExternalLink, Mail, MapPin, Phone, User } from "lucide-react";
import { AddressMapPreview } from "@/components/common/address-map-preview";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
@ -122,6 +123,12 @@ export function VendorDialogCompanyCard({
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
{/*
The card holds one composed address line for both live and frozen
companies, so the preview is given the line directly rather than
structured parts; it is saved data either way.
*/}
<AddressMapPreview query={model.address} height={140} />
</div>
);
}

View file

@ -11,6 +11,13 @@ import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-ord
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
/**
* Height bound for the dialog surface. The company card grew with the map
* preview, so the dialog itself — not just the card — must cap its height and
* scroll internally or it outgrows short viewports.
*/
export const VENDOR_DIALOG_SURFACE_MAX_HEIGHT = "55vh";
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => 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<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogContent
className="p-6"
onClick={(e) => e.stopPropagation()}
style={{ overflowY: "auto", minHeight: 0 }}
>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Vendor
</DialogTitle>

View file

@ -0,0 +1,88 @@
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, 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 "<street>, <city>, <state>" 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) => (
<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 },
}}
/>
)}
/>
);
}

View file

@ -0,0 +1,99 @@
import { Box } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
buildAddressQuery,
hasCompleteAddress,
mapsEmbedUrl,
type AddressParts,
} from "@/lib/address/vendor-address";
type AddressMapPreviewProps = {
/** Structured parts, as held by a form or a detail view. */
address?: Partial<AddressParts>;
/**
* An already-composed address line, for callers that hold saved data rather
* than live input — the Work Order vendor dialog gets one string from the
* vendor dropdown payload. Ignored when `address` is given.
*/
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.
*
* 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.
*
* 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 complete = address ? hasCompleteAddress(address) : Boolean(query);
if (!complete) {
if (!(address != null && placeholderWhenIncomplete)) return null;
return (
<Box
role="status"
sx={{
borderRadius: 1,
border: "1px dashed",
borderColor: "divider",
minHeight: 56,
display: "flex",
alignItems: "center",
justifyContent: "center",
px: 1.5,
py: 1,
}}
>
<Text variant="caption" tone="muted" sx={{ textAlign: "center" }}>
{INCOMPLETE_PLACEHOLDER_MESSAGE}
</Text>
</Box>
);
}
return (
<Box
sx={{
borderRadius: 1,
overflow: "hidden",
border: 1,
borderColor: "divider",
lineHeight: 0,
}}
>
<Box
component="iframe"
title={`Map of ${query}`}
src={mapsEmbedUrl({ street: query })}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
sx={{ display: "block", width: "100%", height, border: 0 }}
/>
</Box>
);
}

View file

@ -33,7 +33,9 @@ export function CollapsibleSection({
>
<div className="flex items-center gap-2">
{Icon != null && <Icon size={16} className="text-primary" />}
<span className="text-sm font-semibold text-foreground">{label}</span>
<span className="text-[length:var(--text-base)] font-semibold text-foreground">
{label}
</span>
</div>
<div className="flex items-center gap-2">
<CountBadge count={count} />

View file

@ -123,7 +123,8 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
// The stored Maps URL is deprecated: reads keep it for legacy records, but
// roster writes must not carry it back to the backend.
notes: readString(item, "notes", "Notes"),
technicians: Array.isArray(techniciansRaw)
? techniciansRaw.map(mapRosterTechnicianToBackend)

View file

@ -8,21 +8,6 @@ const preferredContactEnum = z.enum(
VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]],
);
export function isAbsoluteHttpsUrl(value: string): boolean {
if (value.trim() === "") return true;
try {
const parsed = new URL(value);
return parsed.protocol === "https:";
} catch {
return false;
}
}
const httpsUrl = z
.string()
.trim()
.refine(isAbsoluteHttpsUrl, "Google Maps URL must be a valid HTTPS link");
export function isValidNorthAmericanPhone(value: string): boolean {
if (value === "") return true;
const digits = value.replace(/\D+/g, "");
@ -57,11 +42,15 @@ 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, and the stored Google Maps URL was retired from the form.
// `googleMapsUrl` stays on the read models for legacy records but is not a
// form value: loading it would validate and re-emit a deprecated field the
// user can no longer see or edit.
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
.string()
.max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
@ -117,7 +106,6 @@ export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = {
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [],
};

View file

@ -26,6 +26,9 @@ export interface SaveVendorCompanyRosterContext {
conflict?: unknown;
}
// `googleMapsUrl` is deliberately absent: it is a deprecated read-only field,
// so neither the additive companyFields patch nor the status-only diff may
// ever transmit it.
const COMPANY_FIELDS = [
"name",
"companyPhone",
@ -34,7 +37,6 @@ const COMPANY_FIELDS = [
"city",
"state",
"zip",
"googleMapsUrl",
"notes",
] as const;

View 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());
}

View file

@ -1,6 +1,6 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { FormEvent, ReactNode } from "react";
import { useController, useFieldArray } from "react-hook-form";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -133,7 +133,6 @@ function submitValues(
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "",
notes: "",
technicians,
};
@ -300,7 +299,6 @@ describe("useVendorRosterForm prototype defaults", () => {
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<HTMLFormElement>);
});
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 = {

View file

@ -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();

View file

@ -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", () => {

View file

@ -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<VendorCompanyRosterFormValues>({
defaultValues: emptyVendorCompanyRosterForm,
resolver: zodResolver(vendorCompanyRosterSchema),
mode: "onChange",
});
return (
<form onSubmit={handleSubmit(onValid)}>
<VendorRosterFormFields control={control} errors={formState.errors} />
<button type="submit">Submit</button>
</form>
);
}
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(<AddressFormHarness onValid={vi.fn()} />, { 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(<AddressFormHarness onValid={onValid} />, { 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(<AddressFormHarness onValid={vi.fn()} />, { 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(<AddressFormHarness onValid={vi.fn()} />, { 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();
});
});

View file

@ -244,6 +244,99 @@ describe("VendorsListPage", () => {
);
});
it("shows omitted trade names on the +N badge tooltip on hover and keyboard focus", async () => {
setupDefaults();
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
expect(badge).toHaveTextContent("+2");
expect(screen.getByText("Backflow Preventers")).toBeInTheDocument();
expect(badge).toHaveAttribute("aria-label", "+2 more trades: Water Heaters, Drain Cleaning");
await userEvent.hover(badge);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("Water Heaters, Drain Cleaning");
expect(tooltip).not.toHaveTextContent(/Backflow/);
await userEvent.unhover(badge);
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
fireEvent.keyDown(document, { key: "Tab" });
badge.focus();
expect(badge).toHaveFocus();
expect(await screen.findByRole("tooltip")).toHaveTextContent("Water Heaters, Drain Cleaning");
});
it("does not show the +N badge when the vendor has a single trade", () => {
setupDefaults();
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.queryByTestId("vendor-trade-badge")).not.toBeInTheDocument();
expect(screen.getByText("Backflow Preventers")).toBeInTheDocument();
});
it("does not open the vendor detail when the +N badge is clicked or activated by keyboard", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
await userEvent.click(badge);
expect(screen.queryByRole("heading", { level: 2, name: "Adam Whyte" })).not.toBeInTheDocument();
badge.focus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{Space}");
expect(screen.queryByRole("heading", { level: 2, name: "Adam Whyte" })).not.toBeInTheDocument();
});
it("relies on the MUI tooltip instead of a native title attribute on the +N badge", () => {
setupDefaults();
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
expect(badge).not.toHaveAttribute("title");
expect(badge).toHaveAttribute("tabIndex", "0");
});
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
setupDefaults();
const pageVendors = Array.from({ length: 3 }, (_, index) => ({

View file

@ -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(
<VendorDialogCompanyCard company="Gateway Plumbing" vendor={LIVE_VENDOR} />,
{ 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(
<VendorDialogCompanyCard
company="Frozen Vendor"
vendor={LIVE_VENDOR}
frozenCompany={{
name: "Frozen Vendor",
address: "200 Vendor Ave",
city: "Chicago",
state: "IL",
zip: "60602",
}}
/>,
{ 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(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId={10}
company="Gateway Plumbing"
tech="Jordan Lee"
vendors={[LIVE_VENDOR]}
onSave={vi.fn()}
/>,
);
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" });
});
});

View file

@ -152,6 +152,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(within(sheet).getByText("HVAC")).toBeInTheDocument();
expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument();
fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
expect(applyAdvancedFilters).toHaveBeenCalled();
expect(setAdvOpen).toHaveBeenCalledWith(false);
@ -341,6 +343,45 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
});
it("places Aveta Compliance immediately above Uplift", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
const aveta = within(sheet).getByRole("button", { name: /Aveta Compliance/i });
const uplift = within(sheet).getByRole("button", { name: /^Uplift/i });
expect(aveta.closest(".border-b")?.nextElementSibling).toBe(uplift.closest(".border-b"));
});
it("keeps Clear all and Apply filters disabled until a filter is selected", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
const clearAll = within(sheet).getByRole("button", { name: /clear all/i });
const apply = within(sheet).getByRole("button", { name: /apply filters/i });
expect(clearAll).toBeDisabled();
expect(apply).toBeDisabled();
fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i }));
expect(clearAll).toBeEnabled();
expect(apply).toBeEnabled();
});
it("applies Aveta required only through Advanced Filters", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",

View file

@ -211,7 +211,9 @@ describe("WorkOrdersListHeader slice affordances", () => {
);
expect(screen.getByRole("button", { name: /new wo/i })).toBeInTheDocument();
expect(screen.getByLabelText("Advanced Filters")).toBeInTheDocument();
const filters = screen.getByRole("button", { name: "Advanced Filters" });
expect(filters).toBeInTheDocument();
expect(filters).toHaveTextContent("Filters");
});
});

View file

@ -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(
<AddressMapPreview address={{ street: "6735 Trippel Rd" }} placeholderWhenIncomplete />,
{ 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(
<AddressMapPreview
address={{ street: "6735 Trippel Rd", city: "Theodore" }}
placeholderWhenIncomplete
/>,
{ withAuth: false },
);
expect(screen.getByRole("status")).toBeInTheDocument();
rerender(<AddressMapPreview address={complete} placeholderWhenIncomplete />);
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(
<AddressMapPreview address={{ street: "6735 Trippel Rd" }} />,
{ 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(<AddressMapPreview query="900 Live St" />, {
withAuth: false,
});
expect(screen.getByTitle("Map of 900 Live St")).toBeInTheDocument();
rerender(<AddressMapPreview query=" " />);
expect(container.querySelector("iframe")).toBeNull();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,16 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CollapsibleSection } from "@/components/ui/collapsible-section";
import { renderWithProviders } from "@/test/test-utils";
describe("CollapsibleSection", () => {
it("renders the section title at the 14px body size token", () => {
renderWithProviders(<CollapsibleSection label="Aveta Compliance">child</CollapsibleSection>, {
withAuth: false,
});
const title = screen.getByRole("button", { name: /Aveta Compliance/i }).querySelector("span");
expect(title).toHaveClass("text-[length:var(--text-base)]");
});
});

View file

@ -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", () => {

View file

@ -3,7 +3,6 @@ import {
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
emptyRosterTechnician,
emptyVendorCompanyRosterForm,
isAbsoluteHttpsUrl,
VENDOR_NOTES_MAX_LENGTH,
vendorCompanyRosterSchema,
vendorCompanyRosterUpdateSchema,
@ -14,6 +13,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 +60,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");
@ -98,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", () => {

View file

@ -74,7 +74,6 @@ function formValues(
city: roster.city,
state: roster.state,
zip: roster.zip,
googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
technicians: [newTechnician],
...overrides,

View file

@ -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,

View 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);
});
});