From 07734d8361aa73b7d5bb28bcb7e336c79fe06caf Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 14:53:58 -0300 Subject: [PATCH] SH-138: manage ordered site contacts (#191) * feat(locations): derive site areas from state * chore(locations): keep ticket keys out of source comments * feat(locations): manage ordered site contacts --------- Co-authored-by: Codex Review Integration --- .../location-contacts-field-array.tsx | 237 ++++++++++++++++++ .../_components/location-form-fields.tsx | 20 +- .../locations/_components/location-form.tsx | 6 +- .../_components/locations-contacts-cell.tsx | 46 ++++ .../locations/_components/locations-table.tsx | 5 +- .../locations/mappers/location-mapper.ts | 77 +++++- .../locations/schemas/location-schema.ts | 17 +- src/domain/locations/types/location.ts | 11 + .../location-contacts-field-array.test.tsx | 178 +++++++++++++ .../locations-contacts-cell.test.tsx | 76 ++++++ .../locations/state-select.test.tsx | 3 +- .../locations/mappers/location-mapper.test.ts | 94 +++++++ .../locations/schemas/location-schema.test.ts | 65 +++++ 13 files changed, 801 insertions(+), 34 deletions(-) create mode 100644 src/app/(protected)/locations/_components/location-contacts-field-array.tsx create mode 100644 src/app/(protected)/locations/_components/locations-contacts-cell.tsx create mode 100644 src/test/app/(protected)/locations/location-contacts-field-array.test.tsx create mode 100644 src/test/app/(protected)/locations/locations-contacts-cell.test.tsx create mode 100644 src/test/domain/locations/mappers/location-mapper.test.ts create mode 100644 src/test/domain/locations/schemas/location-schema.test.ts diff --git a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx new file mode 100644 index 00000000..9053cbe4 --- /dev/null +++ b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx @@ -0,0 +1,237 @@ +import { useCallback, useRef, useState, type KeyboardEvent } from "react"; +import { + useFieldArray, + type Control, + type FieldErrors, + type UseFormRegister, +} from "react-hook-form"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + Box, + Button, + Chip, + IconButton, + Paper, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import type { + LocationContactFormValues, + LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; + +interface ContactRowProps { + canRemove: boolean; + dragIndex: number | null; + errors: FieldErrors; + fieldId: string; + index: number; + isLast: boolean; + register: UseFormRegister; + onDragEnd: () => void; + onDragStart: (index: number) => void; + onDrop: (index: number) => void; + onRemove: (index: number) => void; + onKeyboardMove: (from: number, to: number) => void; +} + +function ContactRow({ + canRemove, + dragIndex, + errors, + fieldId, + index, + isLast, + register, + onDragEnd, + onDragStart, + onDrop, + onRemove, + onKeyboardMove, +}: ContactRowProps) { + const handleRef = useRef(null); + const isDragging = dragIndex === index; + const isDropTarget = dragIndex !== null && dragIndex !== index; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowUp" && index > 0) { + event.preventDefault(); + onKeyboardMove(index, index - 1); + } + if (event.key === "ArrowDown" && !isLast) { + event.preventDefault(); + onKeyboardMove(index, index + 1); + } + }; + + return ( + { + if (dragIndex !== null) { + event.preventDefault(); + } + }} + onDrop={(event) => { + event.preventDefault(); + onDrop(index); + }} + className="transition-opacity" + sx={{ opacity: isDragging ? 0.5 : 1, borderStyle: isDropTarget ? "dashed" : "solid" }} + > + + + { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(index)); + onDragStart(index); + }} + onDragEnd={onDragEnd} + onKeyDown={handleKeyDown} + sx={{ cursor: "grab", display: "inline-flex", color: "text.secondary" }} + > + + + + {index === 0 && } + + + + + onRemove(index)} + > + + + + + + + ); +} + +interface LocationContactsFieldArrayProps { + control: Control; + errors: FieldErrors; + register: UseFormRegister; +} + +export function LocationContactsFieldArray({ + control, + errors, + register, +}: LocationContactsFieldArrayProps) { + const { fields, append, remove, move } = useFieldArray({ + control, + name: "contacts", + keyName: "fieldKey", + }); + const [dragIndex, setDragIndex] = useState(null); + const rootRef = useRef(null); + + const focusHandle = useCallback((fieldId: string) => { + requestAnimationFrame(() => { + const handle = rootRef.current?.querySelector(`[data-field-id="${fieldId}"]`); + handle?.focus(); + }); + }, []); + + const handleKeyboardMove = useCallback( + (from: number, to: number) => { + if (to < 0 || to >= fields.length) { + return; + } + const fieldId = fields[from]?.fieldKey; + move(from, to); + if (fieldId) { + focusHandle(fieldId); + } + }, + [fields, move, focusHandle], + ); + + const handleDrop = useCallback( + (index: number) => { + if (dragIndex !== null && dragIndex !== index) { + move(dragIndex, index); + } + setDragIndex(null); + }, + [dragIndex, move], + ); + + return ( + + + + Contacts + + + + + {fields.map((field, index) => ( + 1} + dragIndex={dragIndex} + errors={errors.contacts?.[index] ?? {}} + fieldId={field.fieldKey} + index={index} + isLast={index === fields.length - 1} + register={register} + onDragEnd={() => setDragIndex(null)} + onDragStart={setDragIndex} + onDrop={handleDrop} + onRemove={remove} + onKeyboardMove={handleKeyboardMove} + /> + ))} + + + ); +} diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx index c1f2fb1c..39f22296 100644 --- a/src/app/(protected)/locations/_components/location-form-fields.tsx +++ b/src/app/(protected)/locations/_components/location-form-fields.tsx @@ -12,6 +12,7 @@ import { 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 { LocationContactsFieldArray } from "./location-contacts-field-array"; import { StateSelect } from "./state-select"; interface SharedFieldsProps { @@ -109,24 +110,7 @@ export function LocationContactFields({ control, errors, register }: SharedField Contact Information - - - - + 0 + ? location.contacts + : [{ name: location.contact ?? "", phone: location.phone ?? "" }], contactEmail: location.contactEmail ?? "", status: (location.status as LocationFormValues["status"]) || "Active", }); diff --git a/src/app/(protected)/locations/_components/locations-contacts-cell.tsx b/src/app/(protected)/locations/_components/locations-contacts-cell.tsx new file mode 100644 index 00000000..60a15f27 --- /dev/null +++ b/src/app/(protected)/locations/_components/locations-contacts-cell.tsx @@ -0,0 +1,46 @@ +import { Chip, Stack, Tooltip, Typography } from "@mui/material"; +import type { LocationListItem } from "@/domain/locations/types/location"; + +/** Main contact name with a `+N` chip; hover/focus lists the remaining contact names. */ +export function LocationsContactsCell({ row }: { row: LocationListItem }) { + const contacts = row.contacts ?? []; + const mainName = row.contact || contacts[0]?.name || ""; + const remainingNames = contacts + .slice(1) + .map((contact) => contact.name.trim()) + .filter((name) => name !== ""); + + if (mainName === "") { + return ( + + — + + ); + } + + if (remainingNames.length === 0) { + return ( + + {mainName} + + ); + } + + const remainingLabel = remainingNames.join(", "); + + return ( + + + {mainName} + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index 030031d7..bfd36118 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -15,6 +15,7 @@ import { Typography, } from "@mui/material"; import type { LocationListItem } from "@/domain/locations/types/location"; +import { LocationsContactsCell } from "./locations-contacts-cell"; import { StateAreaCell } from "./state-area-cell"; interface LocationsTableProps { @@ -74,7 +75,9 @@ export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsT {row.phone || "—"} - {row.contact || "—"} + + + {row.status || "—"} diff --git a/src/domain/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts index 4e672fa5..6713c559 100644 --- a/src/domain/locations/mappers/location-mapper.ts +++ b/src/domain/locations/mappers/location-mapper.ts @@ -1,4 +1,9 @@ -import type { Location, LocationListItem, LocationSite } from "@/domain/locations/types/location"; +import type { + Location, + LocationContact, + LocationListItem, + LocationSite, +} from "@/domain/locations/types/location"; function readString(record: Record, ...keys: string[]): string { for (const key of keys) { @@ -90,9 +95,31 @@ export function mapLocationSites(rawItems: unknown[]): LocationSite[] { return rawItems.map(mapLocationSite); } +/** Reads the additive contacts array; returns [] when absent or malformed. */ +export function mapLocationContacts(raw: unknown): LocationContact[] { + if (!Array.isArray(raw)) { + return []; + } + return raw.map((entry) => { + const item = (typeof entry === "object" && entry !== null ? entry : {}) as Record< + string, + unknown + >; + const id = item.id ?? item.Id; + return { + ...(typeof id === "number" && Number.isInteger(id) && id > 0 ? { id } : {}), + name: readString(item, "name", "Name", "contact", "Contact"), + phone: readString(item, "phone", "Phone"), + }; + }); +} + export function mapLocationListItem(raw: unknown): LocationListItem { const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const contacts = mapLocationContacts(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + return { id: readId(item), name: readString(item, "name", "Name"), @@ -100,11 +127,14 @@ export function mapLocationListItem(raw: unknown): LocationListItem { city: readString(item, "city", "City"), state: readString(item, "state", "State"), zipCode: readString(item, "zipCode", "ZipCode", "Zip"), - phone: readString(item, "phone", "Phone"), - contact: readString(item, "contact", "Contact", "contactPerson", "ContactPerson"), + phone: mainContact ? mainContact.phone : readString(item, "phone", "Phone"), + contact: mainContact + ? mainContact.name + : readString(item, "contact", "Contact", "contactPerson", "ContactPerson"), contactEmail: readString(item, "contactEmail", "ContactEmail"), status: readString(item, "status", "Status"), accountId: readOptionalId(item), + contacts: contacts.length > 0 ? contacts : undefined, }; } @@ -122,6 +152,12 @@ export function mapLocation(raw: unknown): Location { .join(", ") || undefined; + const contacts = mapLocationContacts(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + const legacyPhone = readString(item, "phone", "Phone") || undefined; + const legacyContact = + readString(item, "contact", "Contact", "contactPerson", "ContactPerson") || undefined; + return { id: readId(item) || undefined, name: readString(item, "name", "Name") || undefined, @@ -129,31 +165,58 @@ export function mapLocation(raw: unknown): Location { city: readString(item, "city", "City") || undefined, state: readString(item, "state", "State") || undefined, zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined, - phone: readString(item, "phone", "Phone") || undefined, - contact: readString(item, "contact", "Contact") || undefined, + phone: mainContact ? mainContact.phone : legacyPhone, + contact: mainContact ? mainContact.name : legacyContact, contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined, status: readString(item, "status", "Status") || undefined, accountId: readOptionalId(item) || undefined, + contacts: contacts.length > 0 ? contacts : undefined, }; } +/** Request payload contacts: trimmed, in displayed order. */ +export function mapContactsToBackend(raw: unknown): LocationContact[] { + if (!Array.isArray(raw)) { + return []; + } + return raw + .map((entry) => { + const item = (typeof entry === "object" && entry !== null ? entry : {}) as Record< + string, + unknown + >; + return { + ...(typeof item.id === "number" && Number.isInteger(item.id) && item.id > 0 + ? { id: item.id } + : {}), + name: readString(item, "name", "Name", "contact", "Contact").trim(), + phone: readString(item, "phone", "Phone").trim(), + }; + }) + .filter((contact) => contact.name !== "" || contact.phone !== ""); +} + export function mapLocationToBackend(location: unknown): Record { const item = (typeof location === "object" && location !== null ? location : {}) as Record< string, unknown >; + const contacts = mapContactsToBackend(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + const payload: Record = { name: item.name ?? "", address: item.address ?? "", city: item.city ?? "", state: item.state ?? "", zipCode: item.zipCode ?? "", - phone: item.phone ?? "", - contact: item.contact ?? "", + phone: mainContact ? mainContact.phone : (item.phone ?? ""), + contact: mainContact ? mainContact.name : (item.contact ?? ""), contactEmail: item.contactEmail ?? "", status: item.status ?? "Active", accountId: item.accountId ?? "", + contacts, }; const id = item.id ?? item.Id; diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index 00fbbdbb..7c9e878c 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -2,6 +2,16 @@ import { z } from "zod"; import { US_STATE_CODES } from "@/data/areas"; import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +export const locationContactSchema = z.object({ + id: z.number().int().positive().optional(), + name: z + .string() + .trim() + .min(1, "Contact person is required") + .max(100, "Contact person cannot exceed 100 characters"), + phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"), +}); + export const locationSchema = z.object({ name: z.string().min(1, "Name is required"), accountId: z.string().optional(), @@ -11,13 +21,13 @@ export const locationSchema = z.object({ .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"), + contacts: z.array(locationContactSchema).min(1, "At least one contact is required"), contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"), status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }), }); export type LocationFormValues = z.infer; +export type LocationContactFormValues = z.infer; export const locationDefaultValues: LocationFormValues = { name: "", @@ -26,8 +36,7 @@ export const locationDefaultValues: LocationFormValues = { city: "", state: "", zipCode: "", - phone: "", - contact: "", + contacts: [{ name: "", phone: "" }], contactEmail: "", status: "Active", }; diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 07ecbe2e..7fa99625 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -1,5 +1,12 @@ import type { StateCode } from "@/data/areas"; +/** Site contact row; the first entry in `contacts` is the Main contact. */ +export interface LocationContact { + id?: number; + name: string; + phone: string; +} + export interface LocationListItem { id: string | number; name: string; @@ -12,6 +19,8 @@ export interface LocationListItem { contactEmail: string; status: string; accountId: string | number; + /** Additive contacts payload; `contact`/`phone` mirror the first entry. */ + contacts?: LocationContact[]; } export interface Location { @@ -26,6 +35,8 @@ export interface Location { contactEmail?: string; status?: string; accountId?: string | number; + /** Additive contacts payload; the first entry is the Main contact. */ + contacts?: LocationContact[]; } /** Site option from GET locations/sites (wizard / board create). */ diff --git a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx new file mode 100644 index 00000000..60444e29 --- /dev/null +++ b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx @@ -0,0 +1,178 @@ +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 { LocationContactsFieldArray } from "@/app/(protected)/locations/_components/location-contacts-field-array"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { renderWithProviders } from "@/test/test-utils"; + +const dataTransferStub = { + setData: vi.fn(), + effectAllowed: "none" as DataTransfer["effectAllowed"], +}; + +function ContactsHarness({ + initialContacts, + onSubmit = vi.fn(), +}: { + initialContacts: LocationFormValues["contacts"]; + onSubmit?: (values: LocationFormValues) => void; +}) { + const { + control, + handleSubmit, + register, + formState: { errors }, + } = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: { + ...locationDefaultValues, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", + contacts: initialContacts, + }, + }); + + return ( +
+ + + + ); +} + +function nameInputs(): HTMLInputElement[] { + return screen.getAllByLabelText(/^Contact Name/) as HTMLInputElement[]; +} + +/** 1-based handle position: `handleFor(2)` is the second row's drag handle. */ +function handleFor(position: number): HTMLElement { + return screen.getByRole("button", { + name: `Reorder contact ${position}. Use arrow up or down to move.`, + }); +} + +describe("LocationContactsFieldArray", () => { + it("marks the first row as Main and disables removal when only one remains", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Main")).toBeInTheDocument(); + expect(screen.getByLabelText(/^Contact Name \(Main\)/)).toHaveValue("Jane"); + expect(screen.getByLabelText("Remove contact 1")).toBeDisabled(); + }); + + it("adds and removes contact rows", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /add contact/i })); + + expect(nameInputs()).toHaveLength(2); + expect(screen.getByLabelText("Remove contact 1")).toBeEnabled(); + expect(screen.getByText("Main")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Remove contact 2")); + expect(nameInputs()).toHaveLength(1); + expect(screen.getByLabelText("Remove contact 1")).toBeDisabled(); + }); + + it("reorders with keyboard ArrowUp and keeps focus on the moved handle", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(nameInputs().map((input) => input.value)).toEqual(["Jane", "Bob"]); + + const bobHandle = handleFor(2); + bobHandle.focus(); + fireEvent.keyDown(bobHandle, { key: "ArrowUp" }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Bob", "Jane"]); + await waitFor(() => { + expect(document.activeElement).toBe(handleFor(1)); + }); + }); + + it("ignores ArrowDown on the last row", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.keyDown(handleFor(2), { key: "ArrowDown" }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Jane", "Bob"]); + }); + + it("reorders with native drag events from the handle", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.dragStart(handleFor(1), { dataTransfer: dataTransferStub }); + fireEvent.drop(nameInputs()[1].closest("div") as HTMLElement, { + dataTransfer: dataTransferStub, + }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Bob", "Jane"]); + }); + + it("submits contacts in displayed order", async () => { + const onSubmit = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.keyDown(handleFor(2), { key: "ArrowUp" }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + expect(onSubmit.mock.calls[0][0]).toEqual( + expect.objectContaining({ + contacts: [ + { id: 12, name: "Bob", phone: "(421) 433-0033" }, + { id: 11, name: "Jane", phone: "(421) 433-0032" }, + ], + }), + ); + }); +}); diff --git a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx new file mode 100644 index 00000000..e0258626 --- /dev/null +++ b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { LocationsContactsCell } from "@/app/(protected)/locations/_components/locations-contacts-cell"; +import type { LocationListItem } from "@/domain/locations/types/location"; +import { renderWithProviders } from "@/test/test-utils"; + +function rowWithContacts(overrides: Partial): LocationListItem { + return { + 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, + ...overrides, + }; +} + +describe("LocationsContactsCell", () => { + it("renders only the main contact name when a single contact exists", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Jane")).toBeInTheDocument(); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); + + it("shows a +N chip and lists remaining contact names on hover", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const chip = screen.getByText("+2"); + expect(chip).toBeInTheDocument(); + expect(screen.getByLabelText("2 more contacts: Bob, Carol")).toBeInTheDocument(); + + fireEvent.mouseOver(chip); + expect(await screen.findByText("Bob, Carol")).toBeInTheDocument(); + }); + + it("falls back to the legacy contact field without a chip", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByText("Jane")).toBeInTheDocument(); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); + + it("renders a placeholder when no contact exists", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx index e1bc87d5..68c3fa51 100644 --- a/src/test/app/(protected)/locations/state-select.test.tsx +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -27,8 +27,7 @@ function StateSelectHarness({ address: "3811 Distribution Dr", city: "Dallas", zipCode: "70068", - phone: "(421) 433-0032", - contact: "Jane", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], contactEmail: "jane@example.com", status: "Active", }, diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts new file mode 100644 index 00000000..52d27cc6 --- /dev/null +++ b/src/test/domain/locations/mappers/location-mapper.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + mapLocation, + mapLocationListItem, + mapLocationToBackend, +} from "@/domain/locations/mappers/location-mapper"; + +describe("location contacts hydration", () => { + it("maps the response contacts array and derives the main contact fields", () => { + const item = mapLocationListItem({ + id: 1, + name: "Dallas DC", + contacts: [ + { name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ], + }); + + expect(item.contacts).toEqual([ + { name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ]); + expect(item.contact).toBe("Jane"); + expect(item.phone).toBe("(421) 433-0032"); + }); + + it("falls back to legacy contact/phone fields when no contacts array exists", () => { + const item = mapLocationListItem({ + id: 2, + contact: "Bob", + phone: "(312) 555-0100", + }); + + expect(item.contacts).toBeUndefined(); + expect(item.contact).toBe("Bob"); + expect(item.phone).toBe("(312) 555-0100"); + }); + + it("hydrates detail contacts and mirrors the first row in contact/phone", () => { + const detail = mapLocation({ + id: 3, + contacts: [{ id: 31, name: "Carol", phone: "555-0143" }], + }); + + expect(detail.contacts).toEqual([{ id: 31, name: "Carol", phone: "555-0143" }]); + expect(detail.contact).toBe("Carol"); + expect(detail.phone).toBe("555-0143"); + }); + + it("keeps legacy detail contact/phone for zero-contact data", () => { + const detail = mapLocation({ id: 4, contact: "Legacy Person", phone: "555-0199" }); + + expect(detail.contacts).toBeUndefined(); + expect(detail.contact).toBe("Legacy Person"); + expect(detail.phone).toBe("555-0199"); + }); +}); + +describe("mapLocationToBackend contacts payload", () => { + it("sends trimmed contacts in displayed order and derives legacy fields from the first", () => { + const payload = mapLocationToBackend({ + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", + status: "Active", + contacts: [ + { id: 41, name: " Jane ", phone: " (421) 433-0032 " }, + { name: "Bob", phone: "(421) 433-0033" }, + ], + }); + + expect(payload.contacts).toEqual([ + { id: 41, name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ]); + expect(payload.contact).toBe("Jane"); + expect(payload.phone).toBe("(421) 433-0032"); + }); + + it("keeps legacy contact/phone untouched when no contacts are provided", () => { + const payload = mapLocationToBackend({ + name: "Legacy DC", + contact: "Legacy Person", + phone: "555-0199", + }); + + expect(payload.contact).toBe("Legacy Person"); + expect(payload.phone).toBe("555-0199"); + expect(payload.contacts).toEqual([]); + }); +}); diff --git a/src/test/domain/locations/schemas/location-schema.test.ts b/src/test/domain/locations/schemas/location-schema.test.ts new file mode 100644 index 00000000..ecf04b56 --- /dev/null +++ b/src/test/domain/locations/schemas/location-schema.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; + +const validBase: LocationFormValues = { + ...locationDefaultValues, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", +}; + +describe("locationSchema contacts", () => { + it("accepts one fully populated contact", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }); + + expect(result.success).toBe(true); + }); + + it("requires at least one contact", () => { + const result = locationSchema.safeParse({ ...validBase, contacts: [] }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("At least one contact is required"); + } + }); + + it("requires a name and a phone on every row", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [ + { name: "Jane", phone: "" }, + { name: " ", phone: "(421) 433-0032" }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + const messages = result.error.issues.map((issue) => issue.message); + expect(messages).toContain("Phone is required"); + expect(messages).toContain("Contact person is required"); + } + }); + + it("trims name and phone values on parse", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [{ name: " Jane ", phone: " (421) 433-0032 " }], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.contacts[0]).toEqual({ name: "Jane", phone: "(421) 433-0032" }); + } + }); +});