mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(vendors): complete structured address map flows
This commit is contained in:
parent
57fe7aa08a
commit
86c0748f21
18 changed files with 441 additions and 44 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
|
@ -27,7 +27,6 @@ const EDITABLE_COMPANY_FIELDS = [
|
|||
"city",
|
||||
"state",
|
||||
"zip",
|
||||
"googleMapsUrl",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ function CompanyFields({
|
|||
city: cityField.value ?? "",
|
||||
state: stateField.value ?? "",
|
||||
}}
|
||||
placeholderWhenIncomplete
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-ord
|
|||
|
||||
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
/**
|
||||
* Height bound for the dialog surface. The company card grew with the map
|
||||
* preview, so the dialog itself — not just the card — must cap its height and
|
||||
* scroll internally or it outgrows short viewports.
|
||||
*/
|
||||
export const VENDOR_DIALOG_SURFACE_MAX_HEIGHT = "55vh";
|
||||
|
||||
type VendorDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -56,12 +63,22 @@ export function VendorDialog({
|
|||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 560 },
|
||||
style: {
|
||||
borderRadius: 12,
|
||||
maxWidth: 560,
|
||||
maxHeight: VENDOR_DIALOG_SURFACE_MAX_HEIGHT,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogContent
|
||||
className="p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ overflowY: "auto", minHeight: 0 }}
|
||||
>
|
||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||
Vendor
|
||||
</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -49,11 +49,21 @@ export function AddressAutocompleteField({
|
|||
// typed text.
|
||||
filterOptions={(options) => options}
|
||||
inputValue={value}
|
||||
onInputChange={(_event, next) => onInputChange(next)}
|
||||
onInputChange={(_event, next, reason) => {
|
||||
// Genuine typing and the clear control update the street. MUI also
|
||||
// fires this callback with reason "reset" after a selection, carrying
|
||||
// the option's full "<street>, <city>, <state>" label — forwarding
|
||||
// that would overwrite the just-picked street with the label.
|
||||
if (reason !== "input" && reason !== "clear") return;
|
||||
onInputChange(next);
|
||||
}}
|
||||
onChange={(_event, selected) => {
|
||||
if (selected && typeof selected !== "string") {
|
||||
onSelect({ street: selected.street, city: selected.city, state: selected.state });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected === null) onSelect({ street: "", city: "", state: "" });
|
||||
}}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
renderInput={(params) => (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
buildAddressQuery,
|
||||
hasCompleteAddress,
|
||||
|
|
@ -16,8 +17,17 @@ type AddressMapPreviewProps = {
|
|||
*/
|
||||
query?: string;
|
||||
height?: number;
|
||||
/**
|
||||
* Render a compact accessible placeholder while the structured address is
|
||||
* incomplete, instead of nothing. Meant for Add/Edit forms, where the empty
|
||||
* state tells the dispatcher which parts the preview is still waiting on.
|
||||
*/
|
||||
placeholderWhenIncomplete?: boolean;
|
||||
};
|
||||
|
||||
const INCOMPLETE_PLACEHOLDER_MESSAGE =
|
||||
"Map preview appears once Street, City, and State are all filled.";
|
||||
|
||||
/**
|
||||
* Small inline map for a structured address.
|
||||
*
|
||||
|
|
@ -25,19 +35,47 @@ type AddressMapPreviewProps = {
|
|||
* preview with no API key, so there is no Maps JavaScript API, no Places
|
||||
* request and no billing account behind this.
|
||||
*
|
||||
* Renders nothing until Street, City and State are all present — a preview of a
|
||||
* half-typed address points somewhere misleading, which is worse than no map.
|
||||
* Structured input renders nothing until Street, City and State are all
|
||||
* present — a preview of a half-typed address points somewhere misleading,
|
||||
* which is worse than no map. Add/Edit forms opt into a compact placeholder
|
||||
* for that incomplete state; a pre-composed saved line is map-only and renders
|
||||
* only when non-empty.
|
||||
*/
|
||||
export function AddressMapPreview({
|
||||
address,
|
||||
query: rawQuery,
|
||||
height = 160,
|
||||
placeholderWhenIncomplete = false,
|
||||
}: AddressMapPreviewProps) {
|
||||
// Structured input is held to the completeness rule; a pre-composed line is
|
||||
// saved data and only has to be non-empty.
|
||||
const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim();
|
||||
const renderable = address ? hasCompleteAddress(address) : Boolean(query);
|
||||
if (!renderable) return null;
|
||||
const complete = address ? hasCompleteAddress(address) : Boolean(query);
|
||||
|
||||
if (!complete) {
|
||||
if (!(address != null && placeholderWhenIncomplete)) return null;
|
||||
return (
|
||||
<Box
|
||||
role="status"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: "1px dashed",
|
||||
borderColor: "divider",
|
||||
minHeight: 56,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Text variant="caption" tone="muted" sx={{ textAlign: "center" }}>
|
||||
{INCOMPLETE_PLACEHOLDER_MESSAGE}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
|
|||
city: readString(item, "city", "City"),
|
||||
state: readString(item, "state", "State"),
|
||||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||
// The stored Maps URL is deprecated: reads keep it for legacy records, but
|
||||
// roster writes must not carry it back to the backend.
|
||||
notes: readString(item, "notes", "Notes"),
|
||||
technicians: Array.isArray(techniciansRaw)
|
||||
? techniciansRaw.map(mapRosterTechnicianToBackend)
|
||||
|
|
|
|||
|
|
@ -8,21 +8,6 @@ const preferredContactEnum = z.enum(
|
|||
VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]],
|
||||
);
|
||||
|
||||
export function isAbsoluteHttpsUrl(value: string): boolean {
|
||||
if (value.trim() === "") return true;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const httpsUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(isAbsoluteHttpsUrl, "Google Maps URL must be a valid HTTPS link");
|
||||
|
||||
export function isValidNorthAmericanPhone(value: string): boolean {
|
||||
if (value === "") return true;
|
||||
const digits = value.replace(/\D+/g, "");
|
||||
|
|
@ -58,13 +43,14 @@ const baseCompanyFields = {
|
|||
companyPhone: northAmericanPhone,
|
||||
email: optionalEmail,
|
||||
// SH-271: the single combined Address line became three structured parts,
|
||||
// all required. `zip` stays optional and is not shown in the form — the
|
||||
// ticket scopes the visible set to Street/City/State.
|
||||
// all required, and the stored Google Maps URL was retired from the form.
|
||||
// `googleMapsUrl` stays on the read models for legacy records but is not a
|
||||
// form value: loading it would validate and re-emit a deprecated field the
|
||||
// user can no longer see or edit.
|
||||
address: z.string().trim().min(1, "Street Address is required"),
|
||||
city: z.string().trim().min(1, "City is required"),
|
||||
state: z.string().trim().min(1, "State is required"),
|
||||
zip: z.string(),
|
||||
googleMapsUrl: httpsUrl,
|
||||
notes: z
|
||||
.string()
|
||||
.max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
|
||||
|
|
@ -120,7 +106,6 @@ export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = {
|
|||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export interface SaveVendorCompanyRosterContext {
|
|||
conflict?: unknown;
|
||||
}
|
||||
|
||||
// `googleMapsUrl` is deliberately absent: it is a deprecated read-only field,
|
||||
// so neither the additive companyFields patch nor the status-only diff may
|
||||
// ever transmit it.
|
||||
const COMPANY_FIELDS = [
|
||||
"name",
|
||||
"companyPhone",
|
||||
|
|
@ -34,7 +37,6 @@ const COMPANY_FIELDS = [
|
|||
"city",
|
||||
"state",
|
||||
"zip",
|
||||
"googleMapsUrl",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { useController, useFieldArray } from "react-hook-form";
|
||||
import { toast } from "react-toastify";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -133,7 +133,6 @@ function submitValues(
|
|||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "63101",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians,
|
||||
};
|
||||
|
|
@ -300,7 +299,6 @@ describe("useVendorRosterForm prototype defaults", () => {
|
|||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians: [
|
||||
{
|
||||
|
|
@ -322,6 +320,46 @@ describe("useVendorRosterForm prototype defaults", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("useVendorRosterForm legacy stored Maps URL", () => {
|
||||
beforeEach(() => {
|
||||
rosterGet.mockReset();
|
||||
saveMutate.mockReset();
|
||||
});
|
||||
|
||||
it("saves an edit of a legacy roster whose stored Maps URL is plain HTTP", async () => {
|
||||
const legacyRoster: VendorCompanyRoster = {
|
||||
...roster,
|
||||
googleMapsUrl: "http://maps.example.test/legacy",
|
||||
};
|
||||
rosterQueryResult = {
|
||||
data: legacyRoster,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), {
|
||||
wrapper: makeWrapper(createClient()),
|
||||
});
|
||||
|
||||
// Exercise the real resolver: while the form schema validated the hidden
|
||||
// loaded URL, this submit never reached the save path.
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit(result.current.submit)({
|
||||
preventDefault: () => undefined,
|
||||
} as unknown as FormEvent<HTMLFormElement>);
|
||||
});
|
||||
|
||||
expect(saveMutate).toHaveBeenCalledTimes(1);
|
||||
const input = saveMutate.mock.calls[0]?.[0] as {
|
||||
mode: string;
|
||||
values: VendorCompanyRosterFormValues;
|
||||
};
|
||||
expect(input.mode).toBe("update");
|
||||
expect(input.values).not.toHaveProperty("googleMapsUrl");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useVendorRosterForm technician removal", () => {
|
||||
it("keeps a valid roster saveable after an existing technician is removed", async () => {
|
||||
rosterQueryResult = {
|
||||
|
|
|
|||
142
src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx
vendored
Normal file
142
src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
|
||||
import {
|
||||
emptyVendorCompanyRosterForm,
|
||||
vendorCompanyRosterSchema,
|
||||
type VendorCompanyRosterFormValues,
|
||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import { suggestAddresses } from "@/lib/address/vendor-address";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const TYPED_STREET = "415 Murphy Rd";
|
||||
const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0];
|
||||
|
||||
if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street");
|
||||
|
||||
function AddressFormHarness({
|
||||
onValid,
|
||||
}: {
|
||||
onValid: (values: VendorCompanyRosterFormValues) => void;
|
||||
}) {
|
||||
const { control, handleSubmit, formState } = useForm<VendorCompanyRosterFormValues>({
|
||||
defaultValues: emptyVendorCompanyRosterForm,
|
||||
resolver: zodResolver(vendorCompanyRosterSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onValid)}>
|
||||
<VendorRosterFormFields control={control} errors={formState.errors} />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function fillCompanyBasics(): void {
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Company (required)" }), {
|
||||
target: { value: "Gateway Plumbing" },
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Company Phone (optional)" }), {
|
||||
target: { value: "3145550100" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("VendorRosterFormFields address autocomplete", () => {
|
||||
it("keeps the picked street when a suggestion whose label differs is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AddressFormHarness onValid={vi.fn()} />, { withAuth: false });
|
||||
|
||||
const street = screen.getByRole("combobox", { name: "Street Address (required)" });
|
||||
await user.type(street, TYPED_STREET);
|
||||
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const option = within(listbox)
|
||||
.getAllByRole("option")
|
||||
.find((candidate) => candidate.textContent === FIRST_SUGGESTION.label);
|
||||
expect(option).toBeDefined();
|
||||
expect(FIRST_SUGGESTION.label).not.toBe(FIRST_SUGGESTION.street);
|
||||
|
||||
await user.click(option as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(street).toHaveValue(FIRST_SUGGESTION.street);
|
||||
});
|
||||
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(
|
||||
FIRST_SUGGESTION.city,
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(
|
||||
FIRST_SUGGESTION.state,
|
||||
);
|
||||
});
|
||||
|
||||
it("submits the settled structured parts, not the option label", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValid = vi.fn();
|
||||
renderWithProviders(<AddressFormHarness onValid={onValid} />, { withAuth: false });
|
||||
|
||||
const street = screen.getByRole("combobox", { name: "Street Address (required)" });
|
||||
await user.type(street, TYPED_STREET);
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement);
|
||||
fillCompanyBasics();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1));
|
||||
const values = onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues;
|
||||
expect(values.address).toBe(FIRST_SUGGESTION.street);
|
||||
expect(values.city).toBe(FIRST_SUGGESTION.city);
|
||||
expect(values.state).toBe(FIRST_SUGGESTION.state);
|
||||
});
|
||||
|
||||
it("clears every structured address part from the autocomplete clear control", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AddressFormHarness onValid={vi.fn()} />, { withAuth: false });
|
||||
|
||||
const street = screen.getByRole("combobox", { name: "Street Address (required)" });
|
||||
await user.type(street, TYPED_STREET);
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
await user.click(within(listbox).getAllByRole("option")[0] as HTMLElement);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(
|
||||
FIRST_SUGGESTION.city,
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(
|
||||
FIRST_SUGGESTION.state,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
|
||||
expect(street).toHaveValue("");
|
||||
expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue("");
|
||||
expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VendorRosterFormFields map preview empty state", () => {
|
||||
it("shows the accessible placeholder while incomplete, then the map once complete", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AddressFormHarness onValid={vi.fn()} />, { withAuth: false });
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/street, city, and state/i);
|
||||
expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument();
|
||||
|
||||
await user.type(
|
||||
screen.getByRole("combobox", { name: "Street Address (required)" }),
|
||||
TYPED_STREET,
|
||||
);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
|
||||
target: { value: "Theodore" },
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), {
|
||||
target: { value: "AL" },
|
||||
});
|
||||
|
||||
expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
VENDOR_DIALOG_SURFACE_MAX_HEIGHT,
|
||||
VendorDialog,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
|
||||
useCreateVendor: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const LIVE_VENDOR: VendorDropdownItem = {
|
||||
id: 10,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Jordan Lee",
|
||||
tradeSpecialties: "Plumbing",
|
||||
address: "900 Live St",
|
||||
distanceMiles: 4.2,
|
||||
email: "dispatch@gateway.test",
|
||||
};
|
||||
|
||||
describe("VendorDialogCompanyCard map preview queries", () => {
|
||||
it("embeds the live vendor address", () => {
|
||||
renderWithProviders(
|
||||
<VendorDialogCompanyCard company="Gateway Plumbing" vendor={LIVE_VENDOR} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
const frame = screen.getByTitle("Map of 900 Live St");
|
||||
expect(frame).toHaveAttribute(
|
||||
"src",
|
||||
"https://www.google.com/maps?q=900%20Live%20St&output=embed",
|
||||
);
|
||||
});
|
||||
|
||||
it("embeds the frozen company's composed snapshot address, not the live one", () => {
|
||||
renderWithProviders(
|
||||
<VendorDialogCompanyCard
|
||||
company="Frozen Vendor"
|
||||
vendor={LIVE_VENDOR}
|
||||
frozenCompany={{
|
||||
name: "Frozen Vendor",
|
||||
address: "200 Vendor Ave",
|
||||
city: "Chicago",
|
||||
state: "IL",
|
||||
zip: "60602",
|
||||
}}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
const frame = screen.getByTitle("Map of 200 Vendor Ave, Chicago, IL, 60602");
|
||||
expect(frame).toHaveAttribute(
|
||||
"src",
|
||||
"https://www.google.com/maps?q=200%20Vendor%20Ave%2C%20Chicago%2C%20IL%2C%2060602&output=embed",
|
||||
);
|
||||
expect(screen.queryByTitle("Map of 900 Live St")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VendorDialog scroll boundary", () => {
|
||||
it("bounds the dialog surface and scrolls internally", () => {
|
||||
renderWithProviders(
|
||||
<VendorDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
vendorId={10}
|
||||
company="Gateway Plumbing"
|
||||
tech="Jordan Lee"
|
||||
vendors={[LIVE_VENDOR]}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const surface = screen.getByRole("dialog");
|
||||
expect(VENDOR_DIALOG_SURFACE_MAX_HEIGHT).toBe("55vh");
|
||||
expect(surface.style.maxHeight).toBe(VENDOR_DIALOG_SURFACE_MAX_HEIGHT);
|
||||
expect(surface.style.display).toBe("flex");
|
||||
|
||||
const content = screen.getByRole("dialog").querySelector(".MuiDialogContent-root");
|
||||
expect(content).not.toBeNull();
|
||||
expect(content).toHaveStyle({ overflowY: "auto", minHeight: "0px" });
|
||||
});
|
||||
});
|
||||
68
src/test/components/common/address-map-preview.test.tsx
Normal file
68
src/test/components/common/address-map-preview.test.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AddressMapPreview } from "@/components/common/address-map-preview";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const complete = { street: "6735 Trippel Rd", city: "Theodore", state: "AL" };
|
||||
|
||||
function mapFrame() {
|
||||
return screen.getByTitle("Map of 6735 Trippel Rd Theodore AL");
|
||||
}
|
||||
|
||||
describe("AddressMapPreview structured input", () => {
|
||||
it("renders a compact accessible placeholder while the address is incomplete", () => {
|
||||
renderWithProviders(
|
||||
<AddressMapPreview address={{ street: "6735 Trippel Rd" }} placeholderWhenIncomplete />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
const placeholder = screen.getByRole("status");
|
||||
expect(placeholder).toHaveTextContent(/street, city, and state/i);
|
||||
expect(screen.queryByTitle(/^Map of /)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("transitions from the placeholder to the iframe once the address completes", () => {
|
||||
const { rerender } = renderWithProviders(
|
||||
<AddressMapPreview
|
||||
address={{ street: "6735 Trippel Rd", city: "Theodore" }}
|
||||
placeholderWhenIncomplete
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
|
||||
rerender(<AddressMapPreview address={complete} placeholderWhenIncomplete />);
|
||||
const frame = mapFrame();
|
||||
expect(frame).toBeInTheDocument();
|
||||
expect(frame).toHaveAttribute(
|
||||
"src",
|
||||
"https://www.google.com/maps?q=6735%20Trippel%20Rd%20Theodore%20AL&output=embed",
|
||||
);
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays blank for incomplete input when the placeholder is not opted into", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AddressMapPreview address={{ street: "6735 Trippel Rd" }} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddressMapPreview pre-composed query", () => {
|
||||
it("renders the map only when the saved query is non-empty", () => {
|
||||
const { container, rerender } = renderWithProviders(<AddressMapPreview query="900 Live St" />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByTitle("Map of 900 Live St")).toBeInTheDocument();
|
||||
|
||||
rerender(<AddressMapPreview query=" " />);
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
|
||||
emptyRosterTechnician,
|
||||
emptyVendorCompanyRosterForm,
|
||||
isAbsoluteHttpsUrl,
|
||||
VENDOR_NOTES_MAX_LENGTH,
|
||||
vendorCompanyRosterSchema,
|
||||
vendorCompanyRosterUpdateSchema,
|
||||
|
|
@ -105,21 +104,26 @@ describe("vendorCompanyRosterSchema", () => {
|
|||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed emails, non-HTTPS map URLs, and partial phones", () => {
|
||||
it("rejects malformed emails and partial phones", () => {
|
||||
const result = vendorCompanyRosterSchema.safeParse({
|
||||
...baseCompany,
|
||||
email: "not-an-email",
|
||||
googleMapsUrl: "http://maps.google.com/x",
|
||||
technicians: [{ ...emptyRosterTechnician, contactName: "Adam", phone: "(314)" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a maps URL that is a non-absolute https prefix string", () => {
|
||||
expect(isAbsoluteHttpsUrl("https://")).toBe(false);
|
||||
expect(isAbsoluteHttpsUrl("maps.google.com/x")).toBe(false);
|
||||
expect(isAbsoluteHttpsUrl("https://maps.google.com/x")).toBe(true);
|
||||
expect(isAbsoluteHttpsUrl("")).toBe(true);
|
||||
it("tolerates a legacy stored Maps URL instead of failing HTTPS validation", () => {
|
||||
// Legacy records carry a stored googleMapsUrl — often plain HTTP. The form
|
||||
// no longer holds the field, so leftover values loaded from old data must
|
||||
// neither fail validation nor re-enter the parsed output.
|
||||
const result = vendorCompanyRosterSchema.safeParse({
|
||||
...baseCompany,
|
||||
googleMapsUrl: "http://maps.google.com/legacy",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data).not.toHaveProperty("googleMapsUrl");
|
||||
});
|
||||
|
||||
it("rejects an unknown preferred contact", () => {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ function formValues(
|
|||
city: roster.city,
|
||||
state: roster.state,
|
||||
zip: roster.zip,
|
||||
googleMapsUrl: roster.googleMapsUrl,
|
||||
notes: roster.notes,
|
||||
technicians: [newTechnician],
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue