diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index 7f701af7..c328af9a 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index 3b0e6506..c05f2378 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 7291783a..54c553c4 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e8f54bef..7c838f06 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -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"); diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts index fea343d4..73ddbc30 100644 --- a/e2e/vendors/vendors.visual.spec.ts +++ b/e2e/vendors/vendors.visual.spec.ts @@ -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()); diff --git a/package-lock.json b/package-lock.json index f6fe736b..1fe6bc3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/src/app/(protected)/locations/_components/area-facet.tsx b/src/app/(protected)/locations/_components/area-facet.tsx new file mode 100644 index 00000000..2e41da3e --- /dev/null +++ b/src/app/(protected)/locations/_components/area-facet.tsx @@ -0,0 +1,22 @@ +import { Autocomplete, TextField } from "@mui/material"; +import { AREA_ZONES, type AreaZone } from "@/data/areas"; + +interface AreaFacetProps { + value: AreaZone[]; + onChange: (zones: AreaZone[]) => void; +} + +/** Multi-select area facet; an empty selection means no area filtering. */ +export function AreaFacet({ value, onChange }: AreaFacetProps) { + return ( + + multiple + size="small" + className="min-w-[200px]" + options={AREA_ZONES} + value={value} + onChange={(_event, zones) => onChange(zones)} + renderInput={(params) => } + /> + ); +} diff --git a/src/app/(protected)/locations/_components/location-delete-dialog.tsx b/src/app/(protected)/locations/_components/location-delete-dialog.tsx new file mode 100644 index 00000000..0b02c348 --- /dev/null +++ b/src/app/(protected)/locations/_components/location-delete-dialog.tsx @@ -0,0 +1,42 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import type { LocationListItem } from "@/domain/locations/types/location"; + +interface LocationDeleteDialogProps { + isPending: boolean; + onClose: () => void; + onConfirm: () => void; + target: LocationListItem | null; +} + +export function LocationDeleteDialog({ + isPending, + onClose, + onConfirm, + target, +}: LocationDeleteDialogProps) { + return ( + + Delete Location + + + Are you sure you want to delete "{target?.name}"? This action cannot be undone. + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx new file mode 100644 index 00000000..c1f2fb1c --- /dev/null +++ b/src/app/(protected)/locations/_components/location-form-fields.tsx @@ -0,0 +1,159 @@ +import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form"; +import { + Autocomplete, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +import { StateSelect } from "./state-select"; + +interface SharedFieldsProps { + control: Control; + errors: FieldErrors; + register: UseFormRegister; +} + +interface LocationDetailsFieldsProps extends SharedFieldsProps { + accountOptions: AccountListItem[]; + isLoadingAccounts: boolean; + onAccountSearchChange: (value: string) => void; +} + +export function LocationDetailsFields({ + accountOptions, + control, + errors, + isLoadingAccounts, + onAccountSearchChange, + register, +}: LocationDetailsFieldsProps) { + return ( + + + Location Information + + + + ( + option.name || String(option.id)} + isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} + value={accountOptions.find((account) => String(account.id) === field.value) ?? null} + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + onAccountSearchChange(value); + } + }} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + + + + + + ); +} + +export function LocationContactFields({ control, errors, register }: SharedFieldsProps) { + return ( + + + Contact Information + + + + + + + + ( + + Status + + + )} + /> + + + ); +} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx index 990fc75d..2e51731c 100644 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ b/src/app/(protected)/locations/_components/location-form.tsx @@ -1,34 +1,20 @@ import { useEffect, useMemo, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate, useParams } from "react-router"; -import { - Alert, - Autocomplete, - Box, - Button, - CircularProgress, - FormControl, - InputLabel, - MenuItem, - Paper, - Select, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { AccountListItem } from "@/domain/accounts/types/account"; +import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; +import { normalizeStateCode } from "@/data/areas"; import { locationDefaultValues, locationSchema, type LocationFormValues, } from "@/domain/locations/schemas/location-schema"; -import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; import { useDebounce } from "@/hooks/use-debounce"; +import { LocationContactFields, LocationDetailsFields } from "./location-form-fields"; export default function LocationFormPage() { const { id } = useParams(); @@ -67,7 +53,7 @@ export default function LocationFormPage() { accountId: location.accountId != null ? String(location.accountId) : "", address: location.address ?? "", city: location.city ?? "", - state: (location.state as LocationFormValues["state"]) || "IL", + state: normalizeStateCode(location.state) ?? "", zipCode: location.zipCode ?? "", phone: location.phone ?? "", contact: location.contact ?? "", @@ -121,141 +107,15 @@ export default function LocationFormPage() { )} - - - Location Information - - - - ( - option.name || String(option.id)} - isOptionEqualToValue={(option, value) => - String(option.id) === String(value.id) - } - value={ - accountOptions.find((account) => String(account.id) === field.value) ?? null - } - onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} - onInputChange={(_event, value, reason) => { - if (reason === "input") { - setAccountSearch(value); - } - }} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - - - ( - - State - - - )} - /> - - - - - - - Contact Information - - - - - - - - ( - - Status - - - )} - /> - - + + void; + onEdit: (row: LocationListItem) => void; + rows: LocationListItem[]; +} + +export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) { + return ( + + + + + Location Name + Address + City + State + Phone + Contact + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No locations found + + + ) : ( + rows.map((row) => ( + + + + + {row.address || "—"} + {row.city || "—"} + + + + {row.phone || "—"} + {row.contact || "—"} + {row.status || "—"} + + + onEdit(row)}> + + + + + onDelete(row)} + > + + + + + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/locations/_components/state-area-cell.tsx b/src/app/(protected)/locations/_components/state-area-cell.tsx new file mode 100644 index 00000000..cf5d5e4f --- /dev/null +++ b/src/app/(protected)/locations/_components/state-area-cell.tsx @@ -0,0 +1,35 @@ +import { Stack, Typography } from "@mui/material"; +import { getAreaForState, normalizeStateCode } from "@/data/areas"; + +/** State abbreviation with the derived area as secondary text. */ +export function StateAreaCell({ state }: { state?: string | null }) { + const code = normalizeStateCode(state); + const area = code === null ? null : getAreaForState(code); + + if (code === null) { + return ( + + {state?.trim() || "—"} + + ); + } + + return ( + + + {code} + + {area !== null && ( + + {area} + + )} + + ); +} diff --git a/src/app/(protected)/locations/_components/state-select.tsx b/src/app/(protected)/locations/_components/state-select.tsx new file mode 100644 index 00000000..3348019a --- /dev/null +++ b/src/app/(protected)/locations/_components/state-select.tsx @@ -0,0 +1,49 @@ +import { Controller, type Control, type FieldError } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import { + US_STATE_CODES, + formatStateOptionLabel, + getAreaForState, + type StateCode, +} from "@/data/areas"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; + +interface StateSelectProps { + control: Control; + error?: FieldError; +} + +/** Searchable, non-free-text state picker with derived area helper text. */ +export function StateSelect({ control, error }: StateSelectProps) { + return ( + { + const selected: StateCode | null = + US_STATE_CODES.find((code) => code === field.value) ?? null; + const area = selected === null ? null : getAreaForState(selected); + const helperText = error?.message ?? (area === null ? undefined : `Area: ${area}`); + + return ( + + options={US_STATE_CODES} + value={selected} + onChange={(_event, option) => field.onChange(option ?? "")} + onBlur={field.onBlur} + getOptionLabel={formatStateOptionLabel} + renderInput={(params) => ( + + )} + /> + ); + }} + /> + ); +} diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index 097db2c8..79674137 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -1,53 +1,45 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { Alert, Box, Button, CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - IconButton, - Paper, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, TablePagination, - TableRow, TextField, - Tooltip, Typography, } from "@mui/material"; import type { LocationListItem } from "@/domain/locations/types/location"; import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location"; import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list"; import { useDebounce } from "@/hooks/use-debounce"; +import { expandAreasToStateCodes, type AreaZone } from "@/data/areas"; +import { AreaFacet } from "./_components/area-facet"; +import { LocationDeleteDialog } from "./_components/location-delete-dialog"; +import { LocationsTable } from "./_components/locations-table"; export default function LocationsListPage() { const navigate = useNavigate(); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(12); + const [selectedAreas, setSelectedAreas] = useState([]); const [deleteTarget, setDeleteTarget] = useState(null); const debouncedSearch = useDebounce(search, 300); const deleteLocation = useDeleteLocation(); + const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]); + const listParams = useMemo( () => ({ search: debouncedSearch, page: page + 1, pageSize, + states: stateCodes.length > 0 ? stateCodes : undefined, }), - [debouncedSearch, page, pageSize], + [debouncedSearch, page, pageSize, stateCodes], ); const { data, isLoading, isFetching, error } = useLocationsList(listParams); @@ -103,6 +95,13 @@ export default function LocationsListPage() { alignItems: "center", }} > + { + setSelectedAreas(zones); + setPage(0); + }} + /> )} - - - - - Location Name - Address - City - State - Phone - Contact - Status - - Actions - - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No locations found - - - - ) : ( - rows.map((row) => ( - - - - - {row.address || "—"} - {row.city || "—"} - {row.state || "—"} - {row.phone || "—"} - {row.contact || "—"} - {row.status || "—"} - - - navigate(`/locations/edit/${row.id}`)} - > - - - - - setDeleteTarget(row)} - > - - - - - - )) - )} - -
-
+ navigate(`/locations/edit/${row.id}`)} + onDelete={setDeleteTarget} + /> - setDeleteTarget(null)}> - Delete Location - - - Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be - undone. - - - - - - - + setDeleteTarget(null)} + onConfirm={handleDeleteConfirm} + />
); } diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 8aea10f2..aaf1b606 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [ "city", "state", "zip", - "googleMapsUrl", "notes", ] as const; diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index 24d51ce8..a472546e 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -40,7 +40,8 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, + // The deprecated stored Maps URL is intentionally not seeded: the form has + // no input for it, and carrying it would re-emit a legacy value on write. notes: roster.notes, technicians: roster.technicians.map((technician) => ({ id: technician.id, diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 49251a83..072bddb7 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -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 ( - - Google Maps + Street Address - {roster.googleMapsUrl ? ( + {roster.address && mapsUrl ? ( - - Open in Google Maps + {roster.address} ) : ( - — + {roster.address || "—"} )} + + + + + + + + + ); } diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index 1878b417..cf23d357 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -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 ( @@ -148,50 +154,58 @@ function CompanyFields({ onClearSelectedCompany={onClearSelectedCompany} /> - ( - - )} + { + // 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." + } /> - - } + + - ( - - )} - /> - } + ( - - )} + name="zip" + render={({ field }) => } + /> + void; } +function VendorTradeCell({ + primaryTrade, + omittedTrades, +}: { + primaryTrade: string; + omittedTrades: string[]; +}) { + const extraTrades = omittedTrades.length; + return ( + + {primaryTrade === "—" ? ( + "—" + ) : ( + + {primaryTrade} + {extraTrades > 0 && ( + + + +{extraTrades} + + + )} + + )} + + ); +} + 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)" /> - - {primaryTrade === "—" ? ( - "—" - ) : ( - - {primaryTrade} - {extraTrades > 0 && ( - - +{extraTrades} - - )} - - )} - + Open in Google Maps )} + {/* + 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. + */} + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx index 8fca16a0..c9bf6b21 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx @@ -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) => e.stopPropagation(), }, }} > - e.stopPropagation()}> + e.stopPropagation()} + style={{ overflowY: "auto", minHeight: 0 }} + > Vendor diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx new file mode 100644 index 00000000..30e344c6 --- /dev/null +++ b/src/components/common/address-autocomplete-field.tsx @@ -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 ( + + 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 ", , " label — forwarding + // that would overwrite the just-picked street with the label. + if (reason !== "input" && reason !== "clear") return; + onInputChange(next); + }} + onChange={(_event, selected) => { + if (selected && typeof selected !== "string") { + onSelect({ street: selected.street, city: selected.city, state: selected.state }); + return; + } + + if (selected === null) onSelect({ street: "", city: "", state: "" }); + }} + getOptionLabel={(option) => (typeof option === "string" ? option : option.label)} + renderInput={(params) => ( + + )} + /> + ); +} diff --git a/src/components/common/address-map-preview.tsx b/src/components/common/address-map-preview.tsx new file mode 100644 index 00000000..5d1892ce --- /dev/null +++ b/src/components/common/address-map-preview.tsx @@ -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; + /** + * 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 ( + + + {INCOMPLETE_PLACEHOLDER_MESSAGE} + + + ); + } + + return ( + + + + ); +} diff --git a/src/data/areas.ts b/src/data/areas.ts new file mode 100644 index 00000000..9fbda1b0 --- /dev/null +++ b/src/data/areas.ts @@ -0,0 +1,170 @@ +/** + * Canonical source of truth for US states and their Seahaven area zone + * mapping. Exactly the 50 states — no DC and no territories. + * Area is always derived from the state code and never persisted. + */ +export const AREA_ZONES = ["East", "Central", "West", "California"] as const; + +export type AreaZone = (typeof AREA_ZONES)[number]; + +export const AREA_ZONE_STATE_CODES = { + East: [ + "CT", + "DE", + "GA", + "MA", + "MD", + "ME", + "NC", + "NH", + "NJ", + "NY", + "PA", + "RI", + "SC", + "VA", + "VT", + "WV", + ], + Central: [ + "AL", + "AR", + "FL", + "IA", + "IL", + "IN", + "KS", + "KY", + "LA", + "MI", + "MN", + "MO", + "MS", + "ND", + "NE", + "OH", + "OK", + "SD", + "TN", + "WI", + ], + West: ["AK", "AZ", "CO", "HI", "ID", "MT", "NM", "NV", "OR", "TX", "UT", "WA", "WY"], + California: ["CA"], +} as const satisfies Record; + +export type StateCode = (typeof AREA_ZONE_STATE_CODES)[AreaZone][number]; + +/** All 50 state codes in canonical order: East, Central, West, California. */ +export const US_STATE_CODES = [ + ...AREA_ZONE_STATE_CODES.East, + ...AREA_ZONE_STATE_CODES.Central, + ...AREA_ZONE_STATE_CODES.West, + ...AREA_ZONE_STATE_CODES.California, +] as [StateCode, ...StateCode[]]; + +const STATE_NAMES = { + AL: "Alabama", + AK: "Alaska", + AZ: "Arizona", + AR: "Arkansas", + CA: "California", + CO: "Colorado", + CT: "Connecticut", + DE: "Delaware", + FL: "Florida", + GA: "Georgia", + HI: "Hawaii", + ID: "Idaho", + IL: "Illinois", + IN: "Indiana", + IA: "Iowa", + KS: "Kansas", + KY: "Kentucky", + LA: "Louisiana", + ME: "Maine", + MD: "Maryland", + MA: "Massachusetts", + MI: "Michigan", + MN: "Minnesota", + MS: "Mississippi", + MO: "Missouri", + MT: "Montana", + NE: "Nebraska", + NV: "Nevada", + NH: "New Hampshire", + NJ: "New Jersey", + NM: "New Mexico", + NY: "New York", + NC: "North Carolina", + ND: "North Dakota", + OH: "Ohio", + OK: "Oklahoma", + OR: "Oregon", + PA: "Pennsylvania", + RI: "Rhode Island", + SC: "South Carolina", + SD: "South Dakota", + TN: "Tennessee", + TX: "Texas", + UT: "Utah", + VT: "Vermont", + VA: "Virginia", + WA: "Washington", + WV: "West Virginia", + WI: "Wisconsin", + WY: "Wyoming", +} as const satisfies Record; + +export interface StateAreaEntry { + code: StateCode; + name: string; + zone: AreaZone; +} + +/** All 50 states with postal code, full name, and their single area zone. */ +export const STATE_AREA_ENTRIES: readonly StateAreaEntry[] = AREA_ZONES.flatMap((zone) => + AREA_ZONE_STATE_CODES[zone].map((code) => ({ code, name: STATE_NAMES[code], zone })), +); + +const STATE_CODE_SET = new Set(US_STATE_CODES); + +const STATE_NAME_TO_CODE = new Map( + STATE_AREA_ENTRIES.map((entry) => [entry.name.toLowerCase(), entry.code]), +); + +const STATE_ZONE_BY_CODE = Object.fromEntries( + STATE_AREA_ENTRIES.map((entry) => [entry.code, entry.zone]), +) as Record; + +/** Autocomplete option label: `Texas (TX)`. */ +export function formatStateOptionLabel(code: StateCode): string { + return `${STATE_NAMES[code]} (${code})`; +} + +/** + * Normalizes legacy API state values to the canonical two-letter code. + * Accepts codes in any case and full state names (any case); otherwise null. + */ +export function normalizeStateCode(value: string | null | undefined): StateCode | null { + const trimmed = value?.trim() ?? ""; + if (trimmed === "") { + return null; + } + const upper = trimmed.toUpperCase(); + if (STATE_CODE_SET.has(upper)) { + return upper as StateCode; + } + return STATE_NAME_TO_CODE.get(trimmed.toLowerCase()) ?? null; +} + +/** Derives the area zone for a state code or legacy full-name value. */ +export function getAreaForState(value: string | null | undefined): AreaZone | null { + const code = normalizeStateCode(value); + return code === null ? null : STATE_ZONE_BY_CODE[code]; +} + +/** Expands selected area zones to their state codes in canonical order. */ +export function expandAreasToStateCodes(areas: readonly AreaZone[]): StateCode[] { + const selected = new Set(areas); + return US_STATE_CODES.filter((code) => selected.has(STATE_ZONE_BY_CODE[code])); +} diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts index 891bab5e..2f9f1376 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -15,11 +15,17 @@ import type { } from "@/domain/locations/types/location"; function buildSearchParams(params: LocationsListParams): Record { - return { + const searchParams: Record = { search: params.search ?? "", page: params.page ?? 1, pageSize: params.pageSize ?? 12, }; + + if (params.states && params.states.length > 0) { + searchParams.states = params.states.join(","); + } + + return searchParams; } export const locationsApi = { diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index b516ff8a..00fbbdbb 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -1,12 +1,15 @@ import { z } from "zod"; -import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +import { US_STATE_CODES } from "@/data/areas"; +import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; export const locationSchema = z.object({ name: z.string().min(1, "Name is required"), accountId: z.string().optional(), address: z.string().min(1, "Address is required"), city: z.string().min(1, "City is required"), - state: z.enum(LOCATION_STATE_OPTIONS, { message: "State is required" }), + state: z + .union([z.enum(US_STATE_CODES), z.literal("")]) + .refine((state): boolean => state !== "", { message: "State is required" }), zipCode: z.string().min(1, "Zip code is required"), phone: z.string().min(1, "Phone is required"), contact: z.string().min(1, "Contact person is required"), @@ -21,7 +24,7 @@ export const locationDefaultValues: LocationFormValues = { accountId: "", address: "", city: "", - state: "IL", + state: "", zipCode: "", phone: "", contact: "", diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 8c9e3ac5..07ecbe2e 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -1,3 +1,5 @@ +import type { StateCode } from "@/data/areas"; + export interface LocationListItem { id: string | number; name: string; @@ -38,6 +40,8 @@ export interface LocationsListParams { search?: string; page?: number; pageSize?: number; + /** State codes to filter by (area expansion); omitted from the query when empty. */ + states?: StateCode[]; } export interface LocationsListResult { @@ -47,6 +51,4 @@ export interface LocationsListResult { pageSize: number; } -export const LOCATION_STATE_OPTIONS = ["IL", "IN", "MI", "OH", "WI"] as const; - export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const; diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index d3fb7ceb..27672d30 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -123,7 +123,8 @@ export function mapVendorRosterToBackend(values: unknown): Record, , ". */ + 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> = [ + { 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(); + 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): 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): 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): 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): boolean { + return Boolean(parts.street?.trim() && parts.city?.trim() && parts.state?.trim()); +} diff --git a/src/test/app/(protected)/locations/location-form-state.test.tsx b/src/test/app/(protected)/locations/location-form-state.test.tsx new file mode 100644 index 00000000..23b716a3 --- /dev/null +++ b/src/test/app/(protected)/locations/location-form-state.test.tsx @@ -0,0 +1,103 @@ +import { screen } from "@testing-library/react"; +import { Route, Routes } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import LocationFormPage from "@/app/(protected)/locations/_components/location-form"; +import { renderWithProviders } from "@/test/test-utils"; + +const useLocationDetail = vi.fn(); +const useAccountsList = vi.fn(); +const useCreateLocation = vi.fn(); +const useUpdateLocation = vi.fn(); + +vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ + useLocationDetail: (...args: unknown[]) => useLocationDetail(...args), +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: (...args: unknown[]) => useAccountsList(...args), +})); + +vi.mock("@/domain/locations/use-cases/use-create-location", () => ({ + useCreateLocation: (...args: unknown[]) => useCreateLocation(...args), +})); + +vi.mock("@/domain/locations/use-cases/use-update-location", () => ({ + useUpdateLocation: (...args: unknown[]) => useUpdateLocation(...args), +})); + +function mockHooks(location: Record | null) { + useLocationDetail.mockReturnValue({ + data: location, + isLoading: false, + error: null, + }); + useAccountsList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, + isLoading: false, + }); + useCreateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); + useUpdateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); +} + +function renderEditPage(locationId: string) { + return renderWithProviders( + + } /> + , + { + route: `/locations/edit/${locationId}`, + routerProps: { initialEntries: [`/locations/edit/${locationId}`] }, + withAuth: false, + }, + ); +} + +describe("LocationFormPage state field", () => { + it("normalizes legacy API state values for edit", () => { + mockHooks({ + id: "1", + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "texas", + zipCode: "70068", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "jane@example.com", + status: "Active", + accountId: "7", + }); + + renderEditPage("1"); + + const stateInput = screen.getByLabelText(/^State/); + expect(stateInput).toHaveValue("Texas (TX)"); + expect(screen.getByText("Area: West")).toBeInTheDocument(); + }); + + it("normalizes code values and keeps their derived area", () => { + mockHooks({ + id: "2", + state: "IL", + status: "Active", + }); + + renderEditPage("2"); + + expect(screen.getByLabelText(/^State/)).toHaveValue("Illinois (IL)"); + expect(screen.getByText("Area: Central")).toBeInTheDocument(); + }); + + it("starts empty in create mode with no implicit IL default", () => { + mockHooks(null); + + renderWithProviders(, { + route: "/locations/new", + routerProps: { initialEntries: ["/locations/new"] }, + withAuth: false, + }); + + expect(screen.getByLabelText(/^State/)).toHaveValue(""); + expect(screen.queryByText(/Area: /)).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/locations/locations-list-area.test.tsx b/src/test/app/(protected)/locations/locations-list-area.test.tsx new file mode 100644 index 00000000..fb45e28c --- /dev/null +++ b/src/test/app/(protected)/locations/locations-list-area.test.tsx @@ -0,0 +1,139 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import LocationsListPage from "@/app/(protected)/locations/index"; +import { expandAreasToStateCodes } from "@/data/areas"; +import { renderWithProviders } from "@/test/test-utils"; + +const useLocationsList = vi.fn(); +const useDeleteLocation = vi.fn(); + +vi.mock("@/domain/locations/use-cases/use-locations-list", () => ({ + useLocationsList: (...args: unknown[]) => useLocationsList(...args), +})); + +vi.mock("@/domain/locations/use-cases/use-delete-location", () => ({ + useDeleteLocation: (...args: unknown[]) => useDeleteLocation(...args), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const rows = [ + { + id: 1, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "jane@example.com", + status: "Active", + accountId: 7, + }, + { + id: 2, + name: "Chicago HQ", + address: "100 Main St", + city: "Chicago", + state: "Illinois", + zipCode: "60601", + phone: "(312) 555-0100", + contact: "Bob", + contactEmail: "bob@example.com", + status: "Active", + accountId: 8, + }, +]; + +function lastListParams() { + const calls = useLocationsList.mock.calls; + return calls[calls.length - 1][0] as Record; +} + +function renderListPage() { + useDeleteLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); + useLocationsList.mockReturnValue({ + data: { items: rows, totalCount: 30, page: 1, pageSize: 12 }, + isLoading: false, + isFetching: false, + error: null, + }); + + return renderWithProviders(, { route: "/locations", withAuth: false }); +} + +describe("LocationsListPage area wiring", () => { + it("shows the state abbreviation with the area as secondary text", () => { + renderListPage(); + + const table = screen.getByRole("table"); + expect(within(table).getByText("TX")).toBeInTheDocument(); + expect(within(table).getByText("West")).toBeInTheDocument(); + expect(within(table).getByText("IL")).toBeInTheDocument(); + expect(within(table).getByText("Central")).toBeInTheDocument(); + expect(within(table).queryByText("Illinois")).not.toBeInTheDocument(); + }); + + it("sends no states filter while the area facet is empty", () => { + renderListPage(); + + expect(lastListParams()).toEqual({ search: "", page: 1, pageSize: 12 }); + }); + + it("offers the four zones in canonical order", () => { + renderListPage(); + + fireEvent.mouseDown(screen.getByLabelText("Area")); + + const options = screen.getAllByRole("option").map((option) => option.textContent ?? ""); + expect(options).toEqual(["East", "Central", "West", "California"]); + }); + + it("expands selected areas to state codes and resets the page", () => { + renderListPage(); + + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); + expect(lastListParams()).toMatchObject({ page: 2 }); + + fireEvent.mouseDown(screen.getByLabelText("Area")); + fireEvent.click(screen.getByRole("option", { name: "East" })); + + expect(lastListParams()).toEqual({ + search: "", + page: 1, + pageSize: 12, + states: expandAreasToStateCodes(["East"]), + }); + expect(lastListParams().states).toHaveLength(16); + + fireEvent.mouseDown(screen.getByLabelText("Area")); + fireEvent.click(screen.getByRole("option", { name: "California" })); + + expect(lastListParams()).toEqual({ + search: "", + page: 1, + pageSize: 12, + states: expandAreasToStateCodes(["East", "California"]), + }); + }); + + it("preserves search filtering alongside the area facet", () => { + renderListPage(); + + fireEvent.mouseDown(screen.getByLabelText("Area")); + fireEvent.click(screen.getByRole("option", { name: "West" })); + fireEvent.change(screen.getByPlaceholderText("Search locations..."), { + target: { value: "dal" }, + }); + + expect(lastListParams()).toEqual({ + search: "dal", + page: 1, + pageSize: 12, + states: expandAreasToStateCodes(["West"]), + }); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx new file mode 100644 index 00000000..e1bc87d5 --- /dev/null +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -0,0 +1,137 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { StateSelect } from "@/app/(protected)/locations/_components/state-select"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { renderWithProviders } from "@/test/test-utils"; + +function StateSelectHarness({ + onSubmit = vi.fn(), +}: { + onSubmit?: (values: LocationFormValues) => void; +}) { + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: { + ...locationDefaultValues, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + zipCode: "70068", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "jane@example.com", + status: "Active", + }, + }); + + return ( +
+ + + + ); +} + +function openOptions() { + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.mouseDown(input); + return input; +} + +describe("StateSelect", () => { + it("starts empty with no implicit default state", () => { + renderWithProviders(, { withAuth: false }); + + expect(screen.getByRole("combobox")).toHaveValue(""); + expect(screen.queryByText("Area: ")).not.toBeInTheDocument(); + }); + + it("shows searchable Name (CODE) options and no free-text entries", async () => { + renderWithProviders(, { withAuth: false }); + const input = openOptions(); + + const options = await screen.findAllByRole("option"); + expect(options).toHaveLength(50); + expect(screen.getByRole("option", { name: "Texas (TX)" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "California (CA)" })).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "mass" } }); + await screen.findAllByRole("option"); + expect(screen.getByRole("option", { name: "Massachusetts (MA)" })).toBeInTheDocument(); + expect(screen.getAllByRole("option")).toHaveLength(1); + + fireEvent.change(input, { target: { value: "Republic of Texas" } }); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("selects by search and shows the Area helper immediately", async () => { + const onSubmit = vi.fn(); + renderWithProviders(, { withAuth: false }); + const input = openOptions(); + + fireEvent.change(input, { target: { value: "texas" } }); + fireEvent.click(await screen.findByRole("option", { name: "Texas (TX)" })); + + expect(input).toHaveValue("Texas (TX)"); + expect(screen.getByText("Area: West")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ state: "TX" }), + expect.anything(), + ); + }); + }); + + it("is keyboard accessible (arrow + enter selection)", async () => { + const onSubmit = vi.fn(); + renderWithProviders(, { withAuth: false }); + const input = screen.getByRole("combobox"); + + input.focus(); + fireEvent.change(input, { target: { value: "ohio" } }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(input).toHaveValue("Ohio (OH)"); + expect(screen.getByText("Area: Central")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ state: "OH" }), + expect.anything(), + ); + }); + }); + + it("requires a state and rejects free text on submit", async () => { + const onSubmit = vi.fn(); + renderWithProviders(, { withAuth: false }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("State is required")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.change(input, { target: { value: "NotAState" } }); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await screen.findByText("State is required"); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index 2fef475b..3202bd40 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -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); + }); + + expect(saveMutate).toHaveBeenCalledTimes(1); + const input = saveMutate.mock.calls[0]?.[0] as { + mode: string; + values: VendorCompanyRosterFormValues; + }; + expect(input.mode).toBe("update"); + expect(input.values).not.toHaveProperty("googleMapsUrl"); + }); +}); + describe("useVendorRosterForm technician removal", () => { it("keeps a valid roster saveable after an existing technician is removed", async () => { rosterQueryResult = { diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index feb5f59b..e96fd031 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -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(); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index d13a9174..8777567d 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -49,9 +49,11 @@ function rosterWith(technicians: Array>) { 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(, { + 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(, { @@ -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", () => { diff --git a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx new file mode 100644 index 00000000..28a3b106 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx @@ -0,0 +1,142 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { describe, expect, it, vi } from "vitest"; +import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; +import { + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { suggestAddresses } from "@/lib/address/vendor-address"; +import { renderWithProviders } from "@/test/test-utils"; + +const TYPED_STREET = "415 Murphy Rd"; +const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0]; + +if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street"); + +function AddressFormHarness({ + onValid, +}: { + onValid: (values: VendorCompanyRosterFormValues) => void; +}) { + const { control, handleSubmit, formState } = useForm({ + defaultValues: emptyVendorCompanyRosterForm, + resolver: zodResolver(vendorCompanyRosterSchema), + mode: "onChange", + }); + return ( +
+ + + + ); +} + +function fillCompanyBasics(): void { + fireEvent.change(screen.getByRole("textbox", { name: "Company (required)" }), { + target: { value: "Gateway Plumbing" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "Company Phone (optional)" }), { + target: { value: "3145550100" }, + }); +} + +describe("VendorRosterFormFields address autocomplete", () => { + it("keeps the picked street when a suggestion whose label differs is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + + const listbox = await screen.findByRole("listbox"); + const option = within(listbox) + .getAllByRole("option") + .find((candidate) => candidate.textContent === FIRST_SUGGESTION.label); + expect(option).toBeDefined(); + expect(FIRST_SUGGESTION.label).not.toBe(FIRST_SUGGESTION.street); + + await user.click(option as HTMLElement); + + await waitFor(() => { + expect(street).toHaveValue(FIRST_SUGGESTION.street); + }); + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( + FIRST_SUGGESTION.city, + ); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( + FIRST_SUGGESTION.state, + ); + }); + + it("submits the settled structured parts, not the option label", async () => { + const user = userEvent.setup(); + const onValid = vi.fn(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement); + fillCompanyBasics(); + + await user.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + const values = onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues; + expect(values.address).toBe(FIRST_SUGGESTION.street); + expect(values.city).toBe(FIRST_SUGGESTION.city); + expect(values.state).toBe(FIRST_SUGGESTION.state); + }); + + it("clears every structured address part from the autocomplete clear control", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const street = screen.getByRole("combobox", { name: "Street Address (required)" }); + await user.type(street, TYPED_STREET); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement); + + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( + FIRST_SUGGESTION.city, + ); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( + FIRST_SUGGESTION.state, + ); + await user.click(screen.getByRole("button", { name: "Clear" })); + + expect(street).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(""); + }); +}); + +describe("VendorRosterFormFields map preview empty state", () => { + it("shows the accessible placeholder while incomplete, then the map once complete", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + expect(screen.getByRole("status")).toHaveTextContent(/street, city, and state/i); + expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument(); + + await user.type( + screen.getByRole("combobox", { name: "Street Address (required)" }), + TYPED_STREET, + ); + expect(screen.getByRole("status")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { + target: { value: "Theodore" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { + target: { value: "AL" }, + }); + + expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index b3572218..dd0070b8 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -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(, { 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(, { 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(, { 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(, { 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) => ({ diff --git a/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx new file mode 100644 index 00000000..013dcc2d --- /dev/null +++ b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx @@ -0,0 +1,90 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + VENDOR_DIALOG_SURFACE_MAX_HEIGHT, + VendorDialog, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog"; +import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({ + useCreateVendor: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +const LIVE_VENDOR: VendorDropdownItem = { + id: 10, + companyName: "Gateway Plumbing", + contactName: "Jordan Lee", + tradeSpecialties: "Plumbing", + address: "900 Live St", + distanceMiles: 4.2, + email: "dispatch@gateway.test", +}; + +describe("VendorDialogCompanyCard map preview queries", () => { + it("embeds the live vendor address", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const frame = screen.getByTitle("Map of 900 Live St"); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=900%20Live%20St&output=embed", + ); + }); + + it("embeds the frozen company's composed snapshot address, not the live one", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const frame = screen.getByTitle("Map of 200 Vendor Ave, Chicago, IL, 60602"); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=200%20Vendor%20Ave%2C%20Chicago%2C%20IL%2C%2060602&output=embed", + ); + expect(screen.queryByTitle("Map of 900 Live St")).not.toBeInTheDocument(); + }); +}); + +describe("VendorDialog scroll boundary", () => { + it("bounds the dialog surface and scrolls internally", () => { + renderWithProviders( + , + ); + + const surface = screen.getByRole("dialog"); + expect(VENDOR_DIALOG_SURFACE_MAX_HEIGHT).toBe("55vh"); + expect(surface.style.maxHeight).toBe(VENDOR_DIALOG_SURFACE_MAX_HEIGHT); + expect(surface.style.display).toBe("flex"); + + const content = screen.getByRole("dialog").querySelector(".MuiDialogContent-root"); + expect(content).not.toBeNull(); + expect(content).toHaveStyle({ overflowY: "auto", minHeight: "0px" }); + }); +}); diff --git a/src/test/components/common/address-map-preview.test.tsx b/src/test/components/common/address-map-preview.test.tsx new file mode 100644 index 00000000..c1564424 --- /dev/null +++ b/src/test/components/common/address-map-preview.test.tsx @@ -0,0 +1,68 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AddressMapPreview } from "@/components/common/address-map-preview"; +import { renderWithProviders } from "@/test/test-utils"; + +const complete = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" }; + +function mapFrame() { + return screen.getByTitle("Map of 6735 Trippel Rd Theodore AL"); +} + +describe("AddressMapPreview structured input", () => { + it("renders a compact accessible placeholder while the address is incomplete", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const placeholder = screen.getByRole("status"); + expect(placeholder).toHaveTextContent(/street, city, and state/i); + expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument(); + }); + + it("transitions from the placeholder to the iframe once the address completes", () => { + const { rerender } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("status")).toBeInTheDocument(); + + rerender(); + const frame = mapFrame(); + expect(frame).toBeInTheDocument(); + expect(frame).toHaveAttribute( + "src", + "https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed", + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("stays blank for incomplete input when the placeholder is not opted into", () => { + const { container } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(container.querySelector("iframe")).toBeNull(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); + +describe("AddressMapPreview pre-composed query", () => { + it("renders the map only when the saved query is non-empty", () => { + const { container, rerender } = renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByTitle("Map of 900 Live St")).toBeInTheDocument(); + + rerender(); + expect(container.querySelector("iframe")).toBeNull(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/data/areas.test.ts b/src/test/data/areas.test.ts new file mode 100644 index 00000000..1c37f6bc --- /dev/null +++ b/src/test/data/areas.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { + AREA_ZONE_STATE_CODES, + AREA_ZONES, + STATE_AREA_ENTRIES, + US_STATE_CODES, + expandAreasToStateCodes, + formatStateOptionLabel, + getAreaForState, + normalizeStateCode, +} from "@/data/areas"; + +const EAST = [ + "CT", + "DE", + "GA", + "MA", + "MD", + "ME", + "NC", + "NH", + "NJ", + "NY", + "PA", + "RI", + "SC", + "VA", + "VT", + "WV", +]; +const CENTRAL = [ + "AL", + "AR", + "FL", + "IA", + "IL", + "IN", + "KS", + "KY", + "LA", + "MI", + "MN", + "MO", + "MS", + "ND", + "NE", + "OH", + "OK", + "SD", + "TN", + "WI", +]; +const WEST = ["AK", "AZ", "CO", "HI", "ID", "MT", "NM", "NV", "OR", "TX", "UT", "WA", "WY"]; + +describe("state area catalog", () => { + it("covers exactly the 50 states with unique codes and names", () => { + expect(STATE_AREA_ENTRIES).toHaveLength(50); + expect(US_STATE_CODES).toHaveLength(50); + expect(new Set(US_STATE_CODES).size).toBe(50); + expect(new Set(STATE_AREA_ENTRIES.map((entry) => entry.name)).size).toBe(50); + }); + + it("excludes DC and territories", () => { + expect(US_STATE_CODES).not.toContain("DC"); + expect(US_STATE_CODES).not.toContain("PR"); + expect(US_STATE_CODES).not.toContain("GU"); + expect(US_STATE_CODES).not.toContain("VI"); + expect(US_STATE_CODES).not.toContain("AS"); + expect(US_STATE_CODES).not.toContain("MP"); + }); + + it("maps every state to exactly one confirmed zone with counts", () => { + expect([...AREA_ZONE_STATE_CODES.East]).toEqual(EAST); + expect([...AREA_ZONE_STATE_CODES.Central]).toEqual(CENTRAL); + expect([...AREA_ZONE_STATE_CODES.West]).toEqual(WEST); + expect([...AREA_ZONE_STATE_CODES.California]).toEqual(["CA"]); + + expect(EAST).toHaveLength(16); + expect(CENTRAL).toHaveLength(20); + expect(WEST).toHaveLength(13); + + for (const entry of STATE_AREA_ENTRIES) { + expect(AREA_ZONES).toContain(entry.zone); + expect(entry.code).toMatch(/^[A-Z]{2}$/); + expect(entry.name.length).toBeGreaterThan(0); + } + }); + + it("keeps the canonical zone order", () => { + expect([...AREA_ZONES]).toEqual(["East", "Central", "West", "California"]); + expect(US_STATE_CODES[0]).toBe("CT"); + expect(US_STATE_CODES[49]).toBe("CA"); + }); +}); + +describe("formatStateOptionLabel", () => { + it("renders Name (CODE)", () => { + expect(formatStateOptionLabel("TX")).toBe("Texas (TX)"); + expect(formatStateOptionLabel("CA")).toBe("California (CA)"); + }); +}); + +describe("normalizeStateCode", () => { + it("accepts canonical codes in any case", () => { + expect(normalizeStateCode("TX")).toBe("TX"); + expect(normalizeStateCode("tx")).toBe("TX"); + expect(normalizeStateCode("Mo")).toBe("MO"); + }); + + it("normalizes legacy full-name and lowercase values", () => { + expect(normalizeStateCode("Texas")).toBe("TX"); + expect(normalizeStateCode("texas")).toBe("TX"); + expect(normalizeStateCode(" Illinois ")).toBe("IL"); + expect(normalizeStateCode("NORTH CAROLINA")).toBe("NC"); + expect(normalizeStateCode("California")).toBe("CA"); + }); + + it("returns null for empty, unknown, and non-state values", () => { + expect(normalizeStateCode("")).toBeNull(); + expect(normalizeStateCode(" ")).toBeNull(); + expect(normalizeStateCode(null)).toBeNull(); + expect(normalizeStateCode(undefined)).toBeNull(); + expect(normalizeStateCode("ZZ")).toBeNull(); + expect(normalizeStateCode("DC")).toBeNull(); + expect(normalizeStateCode("Puerto Rico")).toBeNull(); + }); +}); + +describe("getAreaForState", () => { + it("derives the confirmed zone per state", () => { + expect(getAreaForState("CT")).toBe("East"); + expect(getAreaForState("IL")).toBe("Central"); + expect(getAreaForState("TX")).toBe("West"); + expect(getAreaForState("CA")).toBe("California"); + }); + + it("derives the zone from legacy full-name values", () => { + expect(getAreaForState("illinois")).toBe("Central"); + expect(getAreaForState("Texas")).toBe("West"); + }); + + it("returns null for unknown states", () => { + expect(getAreaForState("XX")).toBeNull(); + expect(getAreaForState("")).toBeNull(); + expect(getAreaForState(null)).toBeNull(); + }); +}); + +describe("expandAreasToStateCodes", () => { + it("returns an empty list for an empty selection", () => { + expect(expandAreasToStateCodes([])).toEqual([]); + }); + + it("expands a single zone in canonical code order", () => { + expect(expandAreasToStateCodes(["East"])).toEqual(EAST); + expect(expandAreasToStateCodes(["California"])).toEqual(["CA"]); + expect(expandAreasToStateCodes(["West"])).toEqual(WEST); + }); + + it("expands multiple zones in canonical zone order and dedupes", () => { + expect(expandAreasToStateCodes(["West", "California"])).toEqual([...WEST, "CA"]); + expect(expandAreasToStateCodes(["California", "West", "West"])).toEqual([...WEST, "CA"]); + expect(expandAreasToStateCodes([...AREA_ZONES])).toEqual(US_STATE_CODES); + }); +}); diff --git a/src/test/domain/locations/api/locations-list-api.test.ts b/src/test/domain/locations/api/locations-list-api.test.ts new file mode 100644 index 00000000..d14bffb9 --- /dev/null +++ b/src/test/domain/locations/api/locations-list-api.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API_PATHS } from "@/api/api-paths"; + +const apiGet = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiDelete: vi.fn(), +})); + +import { expandAreasToStateCodes } from "@/data/areas"; +import { locationsApi } from "@/domain/locations/api/locations-api"; + +describe("locationsApi.getList states query param", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue([]); + }); + + it("joins state codes into a comma-separated param", async () => { + await locationsApi.getList({ states: ["TX", "MO"] }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.getList, { + searchParams: { search: "", page: 1, pageSize: 12, states: "TX,MO" }, + }); + }); + + it("omits the states param when no states are provided", async () => { + await locationsApi.getList({}); + + const [, config] = apiGet.mock.calls[0]; + expect(config.searchParams).toEqual({ search: "", page: 1, pageSize: 12 }); + expect("states" in config.searchParams).toBe(false); + }); + + it("omits the states param when the states list is empty", async () => { + await locationsApi.getList({ states: [] }); + + const [, config] = apiGet.mock.calls[0]; + expect(config.searchParams).toEqual({ search: "", page: 1, pageSize: 12 }); + expect("states" in config.searchParams).toBe(false); + }); + + it("preserves the canonical area expansion order in the joined value", async () => { + await locationsApi.getList({ states: expandAreasToStateCodes(["West", "California"]) }); + + const [, config] = apiGet.mock.calls[0]; + expect(config.searchParams.states).toBe("AK,AZ,CO,HI,ID,MT,NM,NV,OR,TX,UT,WA,WY,CA"); + }); + + it("keeps existing search and pagination params", async () => { + await locationsApi.getList({ search: "dallas", page: 2, pageSize: 24, states: ["TX"] }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.getList, { + searchParams: { search: "dallas", page: 2, pageSize: 24, states: "TX" }, + }); + }); +}); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index 65494d15..4c9a463f 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -66,7 +66,7 @@ describe("vendor roster mapper", () => { name: "Gateway Plumbing", companyPhone: "314-555-0100", email: "dispatch@gateway.test", - googleMapsUrl: "https://maps.google.com/x", + googleMapsUrl: "http://maps.google.com/legacy", tradeSpecialties: "Plumbing", rowVersion: "rv-2", technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }], @@ -82,6 +82,9 @@ describe("vendor roster mapper", () => { expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" }), ]); expect(payload).not.toHaveProperty("tradeSpecialties"); + // The stored Maps URL is deprecated: a legacy value reaching the mapper + // (from stale callers or old records) must not be emitted to the backend. + expect(payload).not.toHaveProperty("googleMapsUrl"); }); it("omits rowVersion from the create payload when absent", () => { diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts index d5772d1a..da7bcff4 100644 --- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts @@ -3,7 +3,6 @@ import { VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, emptyRosterTechnician, emptyVendorCompanyRosterForm, - isAbsoluteHttpsUrl, VENDOR_NOTES_MAX_LENGTH, vendorCompanyRosterSchema, vendorCompanyRosterUpdateSchema, @@ -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", () => { diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx index f03c2a27..aaec2167 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx @@ -74,7 +74,6 @@ function formValues( city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, notes: roster.notes, technicians: [newTechnician], ...overrides, diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts index f0547378..735ef2d7 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts @@ -36,7 +36,6 @@ const values = { city: roster.city, state: roster.state, zip: roster.zip, - googleMapsUrl: roster.googleMapsUrl, notes: roster.notes, technicians: roster.technicians.map((technician) => ({ id: technician.id, diff --git a/src/test/lib/address/vendor-address.test.ts b/src/test/lib/address/vendor-address.test.ts new file mode 100644 index 00000000..4a2736dc --- /dev/null +++ b/src/test/lib/address/vendor-address.test.ts @@ -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); + }); +});