diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md new file mode 100644 index 00000000..9b125ecc --- /dev/null +++ b/docs/FRONTEND_MAINTAINABILITY.md @@ -0,0 +1,115 @@ +# Frontend maintainability conventions + +## Conditional rendering + +Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty +state. Use a ternary only when both branches render meaningful alternatives. + +```tsx +{ + error && {error.message}; +} + + + {description} +; +``` + +ESLint rejects `condition ? : null`. This keeps one-sided conditions visually +distinct from real either-or UI decisions. + +The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy +and non-boolean (notably `{count && }` renders `0`), so a type-aware ESLint rule, +`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the +TypeScript checker for the type of the left operand and reports unless every union constituent is +boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule +fails closed: if type services are unavailable in a governed TSX file it reports rather than +silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the +same constraint at that component boundary. + +Approved guard forms (choose by semantics, not as a blind codemod): + +- Presence-only values where falsy means "absent" — errors, optional strings shown only when set + (descriptions, notes, reasons), and optional objects (locations, detail records): coerce with + `Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`. + `{Boolean(error) && }` and `{Boolean(description) && }` are the canonical forms. +- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or + helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and + TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and + `{isEdit && id != null && }` keep `0`/`""` semantics and preserve + narrowing. +- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the + slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not + widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`. + +Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition +that needs a non-boolean operand must be rewritten into one of the approved forms above. + +## Typography and feedback + +Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions, +code, and asynchronous feedback. It owns: + +- semantic HTML for each visual variant; +- the display, body, and monospace font families; +- default, muted, error, success, and warning tones; +- accessible `alert` and `status` live regions for error and feedback text; +- one-sided conditional text through `when`. + +The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from +`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned +attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to +choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or +`aria-live` directly. + +Live-region behavior: + +- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so + the polite region exists before its content changes and is announced reliably. Mounting an + already-populated status node on demand is not announceable on most screen-reader/browser pairs. +- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the + expected error pattern, so the shipped `when={Boolean(error)}` usages are correct. + +ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid, +but new shared UI should prefer `Text` so semantics and design tokens do not drift. + +The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear +on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static +string literal containing `vp-error` (e.g. `
`, `
`); pair the error message with `variant="error"`. The rule enforces the +static surface only. It cannot resolve dynamic or composed class values +(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped +strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`. + +## Forms and mutations + +Use the libraries already established in the application: + +- React Hook Form owns field registration, touched/dirty state, and client form lifecycle. +- Zod owns form validation and inferred form value types. +- TanStack Query owns server reads and mutations, including pending/error state, cache + invalidation, and retries where safe. + +Do not add TanStack Form alongside React Hook Form. It would create two form conventions without +removing any current dependency. Reconsider only as a deliberate repository-wide migration with +benchmarks, a codemod plan, and an approved deprecation path. + +File uploads are not ordinary form fields. Keep file selection and client validation in a focused +component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and +retry state. Do not place upload orchestration in a route-sized page component. + +## Page state + +Pages should compose focused state components instead of accumulating unrelated booleans: + +- query loading, error, and empty states stay adjacent to the query result; +- mutation pending/error state belongs to the component that initiated the mutation; +- route pages coordinate sections and navigation; +- reusable sections own their interaction details; +- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes. + +## Enforcement and rollout + +The lint rules are repository-wide and the initial violations were migrated in the same change. +`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future +maintainability rules must also land with a green migration rather than a warning-only backlog. diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts new file mode 100644 index 00000000..29df8921 --- /dev/null +++ b/e2e/vendors/vendors.spec.ts @@ -0,0 +1,410 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const TEST_USER = { + token: "vendor-e2e-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Vendor E2E Admin", + id: "1", +}; + +const activeVendors = [ + { + Id: 1, + CompanyId: 101, + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", + CompanyPhone: "314-555-0100", + Address: "1 Market St", + City: "St. Louis", + State: "MO", + Zip: "63101", + TradeSpecialties: "Plumbing, Backflow Preventers", + GoogleMapsUrl: "https://maps.google.com/gateway", + Notes: "Preferred after-hours vendor", + TotalJobs: 72, + IsActive: true, + PreferredContact: "Email", + }, + { + Id: 2, + CompanyId: 102, + CompanyName: "Metro HVAC", + ContactName: "Maya Chen", + Email: "maya@metro.test", + Phone: "314-555-0112", + CompanyPhone: "314-555-0110", + Address: "2 Pine St", + City: "Clayton", + State: "MO", + Zip: "63105", + TradeSpecialties: "HVAC", + GoogleMapsUrl: "", + Notes: "", + TotalJobs: 18, + IsActive: true, + }, +]; + +const inactiveVendors = [ + { + ...activeVendors[0], + Id: 3, + CompanyName: "Drain Cleaning Bros", + ContactName: "Drew Banks", + TotalJobs: 151, + IsActive: false, + }, +]; + +interface MockState { + listUrls: string[]; + createdBody?: Record; + updatedBody?: Record; + deletedId?: string; +} + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function captureVisual(page: Page, path: string) { + if (!process.env.VENDOR_VISUAL_CAPTURE) return; + await page.waitForTimeout(400); + await page.screenshot({ path, fullPage: true }); +} + +async function mockVendorApi( + page: Page, + options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {}, +) { + const state: MockState = { listUrls: [] }; + + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + + await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) => + fulfillJson(route, { + data: [ + { id: 1, value: "Plumbing" }, + { id: 2, value: "HVAC" }, + ], + }), + ); + await page.route("**/api/Vendor/facets*", (route) => + fulfillJson(route, { + data: { + companies: activeVendors.map((vendor) => ({ + companyId: vendor.CompanyId, + name: vendor.CompanyName, + companyPhone: vendor.CompanyPhone, + email: vendor.Email, + address: vendor.Address, + city: vendor.City, + state: vendor.State, + zip: vendor.Zip, + googleMapsUrl: vendor.GoogleMapsUrl, + })), + trades: ["Plumbing", "Backflow Preventers", "HVAC"], + locations: [ + { city: "St. Louis", state: "MO", label: "St. Louis, MO" }, + { city: "Clayton", state: "MO", label: "Clayton, MO" }, + ], + jobBuckets: [ + { id: "under-50", label: "Under 50" }, + { id: "50-99", label: "50–99" }, + { id: "100-149", label: "100–149" }, + { id: "150-plus", label: "150+" }, + ], + }, + }), + ); + await page.route("**/api/Vendor/GetVendorList*", async (route) => { + const url = new URL(route.request().url()); + state.listUrls.push(url.toString()); + if (options.listError && url.searchParams.get("pageSize") !== "1") { + await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); + return; + } + + const isActive = url.searchParams.get("isActive") !== "false"; + const source = isActive ? activeVendors : inactiveVendors; + const search = (url.searchParams.get("search") ?? "").toLowerCase(); + const company = url.searchParams.get("companies[0]"); + const filtered = source.filter( + (vendor) => + (!search || + `${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}` + .toLowerCase() + .includes(search)) && + (!company || vendor.CompanyName === company), + ); + const isCount = url.searchParams.get("pageSize") === "1"; + await fulfillJson(route, { + Data: isCount ? filtered.slice(0, 1) : filtered, + TotalCount: filtered.length, + PageNumber: 1, + PageSize: Number(url.searchParams.get("pageSize") ?? 12), + }); + }); + await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => { + const vendorId = Number(route.request().url().split("/").at(-2)); + await fulfillJson(route, { + data: { + vendorId, + canDeactivate: !options.deactivationBlocked, + openWorkOrders: options.deactivationBlocked + ? [ + { + workOrderId: 501, + workOrderNumber: "WO-501", + workOrderTitle: "Emergency boiler repair", + status: "Dispatched", + scheduledDate: "2026-07-24T14:00:00Z", + dispatchId: 801, + }, + ] + : [], + }, + }); + }); + await page.route(/\/api\/vendors\/\d+$/, async (route) => { + const id = route.request().url().split("/").pop() ?? ""; + if (route.request().method() === "PUT") { + state.updatedBody = route.request().postDataJSON(); + await fulfillJson(route, { + data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) }, + }); + return; + } + if (route.request().method() === "DELETE") { + if (options.deleteConflict) { + await fulfillJson( + route, + { message: "Vendor has open work orders and cannot be deactivated." }, + 409, + ); + return; + } + state.deletedId = id; + await fulfillJson(route, { status: "200" }); + return; + } + const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id); + await fulfillJson(route, { data: vendor }); + }); + await page.route(/\/api\/vendors\/?$/, async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + state.createdBody = route.request().postDataJSON(); + await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } }); + }); + + return state; +} + +test.describe("Vendor directory prototype parity", () => { + test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ + page, + }) => { + const state = await mockVendorApi(page); + await page.goto("/vendors"); + + await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); + await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); + await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( + "aria-selected", + "true", + ); + for (const heading of [ + "Company", + "Technician", + "Location", + "Trade", + "Phone", + "Email", + "Total Jobs", + "Status", + ]) { + await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); + } + await expect(page.getByText("Gateway Plumbing")).toBeVisible(); + await expect(page.getByText("+1")).toBeVisible(); + await expect(page.getByText("2 active technicians · 2 active · 1 inactive")).toBeVisible(); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); + + await page.getByPlaceholder("Search name, company, trade...").fill("metro"); + await expect(page.getByText("Metro HVAC")).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( + true, + ); + + await page.getByPlaceholder("Search name, company, trade...").clear(); + await page.getByRole("button", { name: "Open vendor filters" }).click(); + await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); + await page.getByRole("button", { name: /Company/ }).click(); + await page.getByPlaceholder("All companies").click(); + await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); + await page.getByRole("button", { name: "Apply" }).click(); + await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + expect( + state.listUrls.some( + (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", + ), + ).toBe(true); + + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( + "history will be preserved", + ); + await page.getByRole("button", { name: "Deactivate", exact: true }).click(); + await expect.poll(() => state.deletedId).toBe("1"); + + await page.getByRole("tab", { name: "Inactive (1)" }).click(); + await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); + await page.getByRole("button", { name: "Clear all" }).click(); + await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); + await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); + }); + + test("creates with company autofill, then views and edits every detail field", async ({ + page, + }) => { + const state = await mockVendorApi(page); + await page.goto("/vendors"); + + await page.getByRole("button", { name: "+ Add Vendor" }).click(); + await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible(); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); + await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled(); + + await page.getByLabel("Technician Name").fill("New Technician"); + await page.getByRole("combobox", { name: "Company" }).click(); + await page.getByRole("option", { name: "Gateway Plumbing" }).click(); + await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); + await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test"); + await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); + await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing"); + await page.getByRole("button", { name: "Add trade" }).click(); + await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC"); + await page.getByRole("button", { name: "Add trade" }).click(); + await page.getByRole("button", { name: "Move HVAC up" }).click(); + await page.getByLabel("Notes").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Text" }).click(); + await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute( + "aria-pressed", + "true", + ); + await page.getByRole("button", { name: "Add Vendor" }).last().click(); + await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0); + expect(state.createdBody).toMatchObject({ + companyName: "Gateway Plumbing", + companyId: 101, + contactName: "New Technician", + companyPhone: "(314) 555-0100", + preferredContact: "Text", + tradeSpecialties: "HVAC, Plumbing", + notes: "Created in browser E2E", + }); + + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + const detailDrawer = page.locator(".MuiDrawer-paper").last(); + await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).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"); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); + + await detailDrawer.getByRole("button", { name: "Edit" }).click(); + await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72"); + await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly"); + await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E"); + await detailDrawer.getByRole("switch", { name: "Active" }).click(); + await detailDrawer.getByRole("button", { name: "Save" }).click(); + await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); + expect(state.updatedBody?.isActive).toBe(false); + }); + + test("supports keyboard dismissal and free-text company creation", async ({ page }) => { + const state = await mockVendorApi(page); + await page.goto("/vendors"); + + const addButton = page.getByRole("button", { name: "+ Add Vendor" }); + await addButton.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); + await expect(addButton).toBeFocused(); + + await addButton.click(); + await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled(); + await page.getByLabel("Technician Name").fill("Independent Tech"); + await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); + await page.getByRole("button", { name: "Add Vendor" }).last().click(); + + await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC"); + expect(state.createdBody?.companyId).toBeNull(); + }); + + test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({ + page, + }) => { + const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); + await page.goto("/vendors"); + + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); + await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); + await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); + expect(blockedState.deletedId).toBeUndefined(); + + await blockedDialog.getByRole("button", { name: "Cancel" }).click(); + await page.unrouteAll({ behavior: "wait" }); + + const racedState = await mockVendorApi(page, { deleteConflict: true }); + await page.reload(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page + .getByRole("dialog", { name: "Deactivate Vendor" }) + .getByRole("button", { + name: /^Deactivate$/, + }) + .click(); + + await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( + /open work orders|conflict/i, + ); + expect(racedState.deletedId).toBeUndefined(); + await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); + }); + + test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockVendorApi(page, { listError: true }); + await page.goto("/vendors"); + + await expect(page.getByRole("main").getByRole("alert")).toContainText( + /Vendor directory unavailable|failed|server error/i, + { timeout: 15_000 }, + ); + await page.getByRole("button", { name: "Open vendor filters" }).click(); + const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); + await expect(filterDrawer).toBeVisible(); + const box = await filterDrawer.boundingBox(); + expect(box?.width).toBeLessThanOrEqual(391); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png"); + await page.getByRole("button", { name: "Close filters" }).click(); + }); +}); diff --git a/e2e/work-orders/vendor-inline-editor.spec.ts b/e2e/work-orders/vendor-inline-editor.spec.ts new file mode 100644 index 00000000..95639b66 --- /dev/null +++ b/e2e/work-orders/vendor-inline-editor.spec.ts @@ -0,0 +1,90 @@ +import { expect, test, type Route } from "@playwright/test"; + +const TEST_USER = { + token: "work-order-vendor-e2e-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +test("edits only the vendor assigned to the current work order", async ({ page }) => { + let updateRequest: { url: string; body: Record } | undefined; + + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + Id: 501, + InternalWONumber: "WO-501", + WorkerOrderNumber: "CUSTOMER-501", + WorkerOrderTitle: "Emergency boiler repair", + Status: "Dispatched", + Comments: [], + AuditLog: [], + Dispatches: [ + { + Id: 801, + DispatchNumber: "D-801", + VendorId: 7, + VendorName: "Gateway Plumbing", + VendorEmail: "dispatch@gateway.test", + Status: "Sent", + DispatchedAt: "2026-07-23T12:00:00Z", + }, + ], + }), + ); + await page.route("**/api/vendors/7/work-order-update", async (route) => { + updateRequest = { + url: route.request().url(), + body: route.request().postDataJSON() as Record, + }; + await fulfillJson(route, { data: { id: 7 } }); + }); + await page.route("**/api/vendors/7", (route) => + fulfillJson(route, { + data: { + Id: 7, + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + PreferredContact: "Email", + Phone: "3145550198", + Email: "dispatch@gateway.test", + Notes: "Preferred after-hours vendor", + IsActive: true, + }, + }), + ); + await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] })); + + await page.goto("/workorders/501"); + await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible(); + await page.getByRole("button", { name: "Edit vendor" }).click(); + + await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible(); + await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte"); + await page.getByLabel("Technician name").fill("Taylor Tech"); + await page.getByLabel("Preferred contact").click(); + await page.getByRole("option", { name: "Text" }).click(); + await page.getByLabel("Phone").fill("3145550111"); + await page.getByLabel("Email").fill("taylor@example.com"); + await page.getByLabel("Notes").fill("Text before arrival"); + await page.getByRole("button", { name: "Save" }).click(); + + await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden(); + expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/); + expect(updateRequest?.body).toEqual({ + workOrderId: 501, + contactName: "Taylor Tech", + preferredContact: "Text", + phone: "(314) 555-0111", + email: "taylor@example.com", + notes: "Text before arrival", + }); +}); diff --git a/eslint-rules/no-non-boolean-jsx-and.js b/eslint-rules/no-non-boolean-jsx-and.js new file mode 100644 index 00000000..10408424 --- /dev/null +++ b/eslint-rules/no-non-boolean-jsx-and.js @@ -0,0 +1,95 @@ +import ts from "typescript"; + +/** + * Local type-aware rule: the left operand of `&&` in JSX must be entirely + * boolean. + * + * `{value && }` renders its left operand when it is falsy and + * non-boolean (notably `{count && }` renders `0`), so the left operand + * must be `boolean` (or boolean literals) across the whole union. The rule + * asks the TypeScript checker for the type of the left operand and reports + * unless every union constituent is boolean-like. + * + * Type-aware by design: a selector that cannot see operand types would give + * false confidence rather than enforcement. If type services are unexpectedly + * unavailable in a governed TSX file, the rule fails closed (reports) instead + * of silently claiming the expression is safe. + */ +const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral; + +function isBooleanLikeType(type) { + return (type.flags & booleanLikeFlags) !== 0; +} + +function isEntirelyBoolean(type) { + if (type.isUnion()) { + return type.types.every((constituent) => isBooleanLikeType(constituent)); + } + return isBooleanLikeType(type); +} + +const transparentExpressionTypes = new Set([ + "ChainExpression", + "ParenthesizedExpression", + "TSAsExpression", + "TSNonNullExpression", + "TSTypeAssertion", +]); + +function isInRenderedPosition(node) { + let current = node; + while (current.parent) { + const parent = current.parent; + if (parent.type === "JSXExpressionContainer") { + return parent.parent?.type !== "JSXAttribute"; + } + if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) { + current = parent; + continue; + } + return false; + } + return false; +} + +const rule = { + meta: { + type: "problem", + docs: { + description: "Require the left operand of `&&` in JSX to be entirely boolean", + }, + schema: [], + messages: { + nonBooleanLeft: + 'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.', + typeServicesUnavailable: + "Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.", + }, + }, + create(context) { + const services = context.sourceCode?.parserServices ?? context.parserServices; + + return { + LogicalExpression(node) { + if (node.operator !== "&&") { + return; + } + if (!isInRenderedPosition(node)) { + return; + } + + if (!services || services.program === null) { + context.report({ node, messageId: "typeServicesUnavailable" }); + return; + } + + const leftType = services.getTypeAtLocation(node.left); + if (!isEntirelyBoolean(leftType)) { + context.report({ node: node.left, messageId: "nonBooleanLeft" }); + } + }, + }; + }, +}; + +export default rule; diff --git a/eslint-rules/no-vp-error-outside-text.js b/eslint-rules/no-vp-error-outside-text.js new file mode 100644 index 00000000..3498f5ea --- /dev/null +++ b/eslint-rules/no-vp-error-outside-text.js @@ -0,0 +1,55 @@ +function containsVpErrorToken(node) { + if (!node || typeof node !== "object") { + return false; + } + + if (node.type === "Literal" && typeof node.value === "string") { + return node.value.split(/\s+/u).includes("vp-error"); + } + + if (node.type === "TemplateElement") { + return node.value.raw.split(/\s+/u).includes("vp-error"); + } + + return Object.entries(node).some(([key, value]) => { + if (key === "parent") { + return false; + } + if (Array.isArray(value)) { + return value.some(containsVpErrorToken); + } + return containsVpErrorToken(value); + }); +} + +const rule = { + meta: { + type: "problem", + docs: { + description: "Require the Text component for legacy vp-error styling", + }, + schema: [], + messages: { + useText: + 'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.', + }, + }, + create(context) { + return { + JSXAttribute(node) { + if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) { + return; + } + + const elementName = node.parent?.name; + if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") { + return; + } + + context.report({ node, messageId: "useText" }); + }, + }; + }, +}; + +export default rule; diff --git a/eslint.config.js b/eslint.config.js index 8d1f29c2..10c0aa46 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,16 @@ import pluginReactRefresh from "eslint-plugin-react-refresh"; import globals from "globals"; import tseslint from "typescript-eslint"; +import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js"; +import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js"; + +const localRules = { + rules: { + "no-non-boolean-jsx-and": noNonBooleanJsxAnd, + "no-vp-error-outside-text": noVpErrorOutsideText, + }, +}; + const legacyIgnores = [ "src/pages/**", "src/app/store.js", @@ -54,6 +64,7 @@ export default tseslint.config( plugins: { "react-hooks": pluginReactHooks, "react-refresh": pluginReactRefresh, + seahaven: localRules, }, rules: { ...pluginReactHooks.configs.recommended.rules, @@ -62,6 +73,23 @@ export default tseslint.config( "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", + "seahaven/no-non-boolean-jsx-and": "error", + "seahaven/no-vp-error-outside-text": "error", + "no-restricted-syntax": [ + "error", + { + selector: + "JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]", + message: + "Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.", + }, + { + selector: + ":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])", + message: + "Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.", + }, + ], }, }, { diff --git a/package.json b/package.json index ee9bb861..170958c4 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "test:watch": "vitest", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", - "lint": "eslint .", - "lint:fix": "eslint . --fix", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky" diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 83a6b6aa..9b2f25eb 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -109,6 +109,8 @@ export const API_PATHS = { update: "Vendor/Update", delete: "Vendor/Delete", dropdown: "Vendor/Dropdown", + facets: "Vendor/facets", + deactivationImpact: "deactivation-impact", }, calendar: { getEventsForCalendar: "Calendar/GetEventsForCalendar", diff --git a/src/app/(auth)/_components/auth-card-header.tsx b/src/app/(auth)/_components/auth-card-header.tsx index 795ca0a7..aaf95cf6 100644 --- a/src/app/(auth)/_components/auth-card-header.tsx +++ b/src/app/(auth)/_components/auth-card-header.tsx @@ -1,5 +1,6 @@ import type { ComponentPropsWithoutRef } from "react"; +import { Text } from "@/components/ui/text"; import { cn } from "@/lib/utils"; export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & { @@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & { export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) { return (
-

{title}

- {subtitle && ( -

- {subtitle} -

- )} + + {title} + + + {subtitle} +
); } diff --git a/src/app/(auth)/_components/login-form.tsx b/src/app/(auth)/_components/login-form.tsx index e06ba713..3e3d8456 100644 --- a/src/app/(auth)/_components/login-form.tsx +++ b/src/app/(auth)/_components/login-form.tsx @@ -75,7 +75,9 @@ export function LoginForm({ disabled={isLoggingIn} error={Boolean(errors.password)} /> - {loginError && } + {Boolean(loginError) && ( + + )} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load account"} diff --git a/src/app/(protected)/accounts/index.tsx b/src/app/(protected)/accounts/index.tsx index f4aea5da..7697c764 100644 --- a/src/app/(protected)/accounts/index.tsx +++ b/src/app/(protected)/accounts/index.tsx @@ -118,7 +118,7 @@ export default function AccountsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load accounts"} diff --git a/src/app/(protected)/assets/_components/asset-form.tsx b/src/app/(protected)/assets/_components/asset-form.tsx index d01ae284..513f1837 100644 --- a/src/app/(protected)/assets/_components/asset-form.tsx +++ b/src/app/(protected)/assets/_components/asset-form.tsx @@ -91,7 +91,7 @@ export default function AssetFormPage() { {isEdit ? "Edit Asset" : "Create Asset"} - {(error || optionsError) && ( + {Boolean(error || optionsError) && ( {error instanceof Error ? error.message diff --git a/src/app/(protected)/assets/index.tsx b/src/app/(protected)/assets/index.tsx index bea3dd35..e6d6451f 100644 --- a/src/app/(protected)/assets/index.tsx +++ b/src/app/(protected)/assets/index.tsx @@ -116,7 +116,7 @@ export default function AssetsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load assets"} diff --git a/src/app/(protected)/calendar/_components/event-form.tsx b/src/app/(protected)/calendar/_components/event-form.tsx index 183201d2..01134222 100644 --- a/src/app/(protected)/calendar/_components/event-form.tsx +++ b/src/app/(protected)/calendar/_components/event-form.tsx @@ -120,7 +120,7 @@ export default function EventFormPage() { {isEdit ? "Edit Event" : "Create Event"} - {error && Failed to load event} + {Boolean(error) && Failed to load event} diff --git a/src/app/(protected)/contacts/_components/contact-form.tsx b/src/app/(protected)/contacts/_components/contact-form.tsx index f2fefc39..401265d6 100644 --- a/src/app/(protected)/contacts/_components/contact-form.tsx +++ b/src/app/(protected)/contacts/_components/contact-form.tsx @@ -127,7 +127,7 @@ export default function ContactFormPage() { {isEdit ? "Edit Contact" : "Create Contact"} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load contact"} diff --git a/src/app/(protected)/contacts/index.tsx b/src/app/(protected)/contacts/index.tsx index 54591c7e..e910c8f4 100644 --- a/src/app/(protected)/contacts/index.tsx +++ b/src/app/(protected)/contacts/index.tsx @@ -103,7 +103,7 @@ export default function ContactsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load contacts"} diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx index 20be5454..e1af6bf9 100644 --- a/src/app/(protected)/dashboard.tsx +++ b/src/app/(protected)/dashboard.tsx @@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} )} - {description && ( + {Boolean(description) && ( {isFetching && !isLoading && } - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load dashboard stats"} diff --git a/src/app/(protected)/employees/_components/employee-form.tsx b/src/app/(protected)/employees/_components/employee-form.tsx index 78ed8bc1..6d5413a8 100644 --- a/src/app/(protected)/employees/_components/employee-form.tsx +++ b/src/app/(protected)/employees/_components/employee-form.tsx @@ -83,7 +83,7 @@ function SelectField({ ))} - {helperText && ( + {Boolean(helperText) && ( {helperText} @@ -193,7 +193,7 @@ export default function EmployeeFormPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load employee"} diff --git a/src/app/(protected)/employees/index.tsx b/src/app/(protected)/employees/index.tsx index b570dbe3..ed56887b 100644 --- a/src/app/(protected)/employees/index.tsx +++ b/src/app/(protected)/employees/index.tsx @@ -124,7 +124,7 @@ export default function EmployeesListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load employees"} diff --git a/src/app/(protected)/followups/_components/follow-up-form.tsx b/src/app/(protected)/followups/_components/follow-up-form.tsx index 603dd3f1..d7a6a86c 100644 --- a/src/app/(protected)/followups/_components/follow-up-form.tsx +++ b/src/app/(protected)/followups/_components/follow-up-form.tsx @@ -102,7 +102,7 @@ export default function FollowUpFormPage() { {isEdit ? "Edit Follow-up" : "Create Follow-up"} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-up"} diff --git a/src/app/(protected)/followups/index.tsx b/src/app/(protected)/followups/index.tsx index 69b42a59..30980fc0 100644 --- a/src/app/(protected)/followups/index.tsx +++ b/src/app/(protected)/followups/index.tsx @@ -129,7 +129,7 @@ export default function FollowUpsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-ups"} diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx index 20be5454..e1af6bf9 100644 --- a/src/app/(protected)/index.tsx +++ b/src/app/(protected)/index.tsx @@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} )} - {description && ( + {Boolean(description) && ( {isFetching && !isLoading && } - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load dashboard stats"} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx index 79ef1acc..990fc75d 100644 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ b/src/app/(protected)/locations/_components/location-form.tsx @@ -114,7 +114,7 @@ export default function LocationFormPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load location"} diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index df239574..097db2c8 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -118,7 +118,7 @@ export default function LocationsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load locations"} diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx index d3b82f15..0253a149 100644 --- a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx +++ b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx @@ -80,8 +80,8 @@ export default function PmScheduleFormPage() { {isEdit ? "Edit PM Schedule" : "Create PM Schedule"} - {error && Failed to load PM schedule} - {optionsError && ( + {Boolean(error) && Failed to load PM schedule} + {Boolean(optionsError) && ( {optionsError instanceof Error ? optionsError.message : "Failed to load form options"} diff --git a/src/app/(protected)/pmschedules/index.tsx b/src/app/(protected)/pmschedules/index.tsx index 4517d4ed..826381e7 100644 --- a/src/app/(protected)/pmschedules/index.tsx +++ b/src/app/(protected)/pmschedules/index.tsx @@ -122,7 +122,7 @@ export default function PmSchedulesListPage() { )} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load PM schedules"} diff --git a/src/app/(protected)/settings/dropdowns.tsx b/src/app/(protected)/settings/dropdowns.tsx index 4e2c0df7..e4aef562 100644 --- a/src/app/(protected)/settings/dropdowns.tsx +++ b/src/app/(protected)/settings/dropdowns.tsx @@ -158,7 +158,7 @@ export default function DropdownOptionsPage() { alignItems: "stretch", }} > - {parentCategory && ( + {Boolean(parentCategory) && ( Parent Trade - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load work orders"} diff --git a/src/app/v/[token]/_layout.tsx b/src/app/v/[token]/_layout.tsx index 548f4ed3..cd3bb545 100644 --- a/src/app/v/[token]/_layout.tsx +++ b/src/app/v/[token]/_layout.tsx @@ -1,6 +1,7 @@ import { NavLink, Outlet, useLocation, useParams } from "react-router"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider"; +import { Text } from "@/components/ui/text"; import "@/app/v/_components/vendor-portal.css"; function VendorPortalHeader() { @@ -9,7 +10,7 @@ function VendorPortalHeader() { return (
Sea Haven Industries — Vendor Portal
- {status === "ready" && vendor?.companyName && ( + {status === "ready" && vendor != null && Boolean(vendor.companyName) && (
{vendor.companyName}
)}
@@ -55,13 +56,13 @@ function VendorPortalBody() { if (status === "error") { return (
-
-

Access Denied

-

+ + Access Denied + {error?.message || "This link is invalid or has expired. Please contact your dispatcher for a new link."} -

-
+ +
); } diff --git a/src/app/v/[token]/dashboard.tsx b/src/app/v/[token]/dashboard.tsx index a7dc74f9..eeb222a2 100644 --- a/src/app/v/[token]/dashboard.tsx +++ b/src/app/v/[token]/dashboard.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { Text } from "@/components/ui/text"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { formatVendorPortalDateTime, @@ -54,7 +55,7 @@ export default function VendorPortalDashboardPage() { return ( <>
-

Your Work

+ Your Work
Active dispatches and work orders assigned to your company.
@@ -76,7 +77,9 @@ export default function VendorPortalDashboardPage() {
{isLoading &&
Loading dispatches…
} - {error &&
{error.message}
} + + {error?.message} + {!isLoading && !error && filtered.length === 0 && (
No dispatches in this view.
diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx index 2edae8a1..a7000843 100644 --- a/src/app/v/[token]/dispatch/[id].tsx +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -3,6 +3,7 @@ import { Link, useParams } from "react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { SignaturePad } from "@/app/v/_components/signature-pad"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { Text } from "@/components/ui/text"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { formatVendorPortalDateTime, @@ -63,7 +64,7 @@ function ChecklistSection({ - {item.isCompleted && item.completedAt && ( + {item.isCompleted && Boolean(item.completedAt) && ( {formatVendorPortalDateTime(item.completedAt)} @@ -224,7 +225,7 @@ function UpliftRequestsSection({
- {request.vendorReason && ( + {Boolean(request.vendorReason) && (
Reason {request.vendorReason} @@ -429,7 +430,11 @@ export default function VendorPortalDispatchPage() { } if (error && !data) { - return
{error.message}
; + return ( + + {error.message} + + ); } if (!data) return null; @@ -457,20 +462,20 @@ export default function VendorPortalDispatchPage() {
-

{data.dispatchNumber}

+ {data.dispatchNumber}
{data.workOrder?.workerOrderTitle}
{data.status}
- {data.workOrder?.internalWONumber && ( + {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
WO # {data.workOrder.internalWONumber}
)} - {data.poNumber && ( + {Boolean(data.poNumber) && (
PO # {data.poNumber} @@ -482,7 +487,7 @@ export default function VendorPortalDispatchPage() { ${Number(data.nteAmount).toFixed(2)}
)} - {data.scheduledDate && ( + {Boolean(data.scheduledDate) && (
Scheduled {formatVendorPortalDateTime(data.scheduledDate)} @@ -490,9 +495,11 @@ export default function VendorPortalDispatchPage() { )}
- {data.location && ( + {data.location != null && ( <> -

Location

+ + Location +
{data.location.name}
{[ @@ -507,9 +514,11 @@ export default function VendorPortalDispatchPage() { )} - {(data.description || data.workOrder?.description) && ( + {Boolean(data.description || data.workOrder?.description) && ( <> -

Description

+ + Description +
{data.description || data.workOrder?.description}
)} @@ -570,7 +579,7 @@ export default function VendorPortalDispatchPage() {
-

Checklist

+ Checklist
-

NTE Uplift Requests

+ NTE Uplift Requests
-

Customer Signoff

+ Customer Signoff
-

Vendor Signoff

+ Vendor Signoff
-

Comments

+ Comments
-

Your Purchase Orders

+ Your Purchase Orders
Every PO issued to your company, with current status and dollar value.
@@ -113,7 +114,9 @@ export default function VendorPortalPosPage() {
{isLoading &&
Loading POs…
} - {error &&
{error.message}
} + + {error?.message} + {!isLoading && !error && visible.length === 0 && (
No POs match this view.
diff --git a/src/components/common/calendar/event-calendar.tsx b/src/components/common/calendar/event-calendar.tsx index ca7d11aa..0616c81b 100644 --- a/src/components/common/calendar/event-calendar.tsx +++ b/src/components/common/calendar/event-calendar.tsx @@ -22,7 +22,7 @@ export const EventCalendar = forwardRef(functi ) { return ( - {loading && ( + {Boolean(loading) && ( diff --git a/src/components/common/signature-capture.tsx b/src/components/common/signature-capture.tsx index c08123c2..9c14cf18 100644 --- a/src/components/common/signature-capture.tsx +++ b/src/components/common/signature-capture.tsx @@ -185,7 +185,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro fullWidth size="small" /> - {typedName && ( + {Boolean(typedName) && (
- {Icon && } + {Icon != null && } {label}
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index 944591a7..5cf52b5f 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -1,13 +1,13 @@ -import type { ReactNode } from "react"; +import type { ReactElement } from "react"; import { Box, Typography } from "@mui/material"; import { cn } from "@/lib/utils"; type EmptyStateProps = { - icon?: ReactNode; + icon?: ReactElement; title: string; description?: string; - action?: ReactNode; + action?: ReactElement; className?: string; }; @@ -19,11 +19,11 @@ export function EmptyState({ icon, title, description, action, className }: Empt className, )} > - {icon && {icon}} + {Boolean(icon) && {icon}} {title} - {description && ( + {Boolean(description) && ( )} - {action && {action}} + {Boolean(action) && {action}} ); } diff --git a/src/components/ui/filter-popover.tsx b/src/components/ui/filter-popover.tsx index 59824344..2d8d5e8a 100644 --- a/src/components/ui/filter-popover.tsx +++ b/src/components/ui/filter-popover.tsx @@ -34,7 +34,7 @@ export function FilterPopover({
Filters - {activeCount > 0 && onClear && ( + {activeCount > 0 && Boolean(onClear) && ( + + ); +} + +const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement; +const companyIdOutput = () => screen.getByTestId("company-id"); + +describe("VendorFormFields company autocomplete state", () => { + it("clears a previously typed company when the form is reset (create-form reopen)", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Leftover Co"); + expect(companyField()).toHaveValue("Leftover Co"); + + await userEvent.click(screen.getByRole("button", { name: "reset-form" })); + + expect(companyField()).toHaveValue(""); + }); + + it("shows the switched company value instead of previously typed text when the form is reset", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + await userEvent.type(companyField(), "Acme"); + expect(companyField()).toHaveValue("Acme"); + + await userEvent.click(screen.getByRole("button", { name: "reset-form" })); + + expect(companyField()).toHaveValue("Beta LLC"); + }); + + it("reflects the company provided by an external reset even when nothing was typed", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(companyField()).toHaveValue("Acme Corp"); + + await userEvent.click(screen.getByRole("button", { name: "reset-form" })); + + expect(companyField()).toHaveValue("Beta LLC"); + }); + + it("updates the displayed company as the user types (freeSolo)", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Acme"); + + expect(companyField()).toHaveValue("Acme"); + }); + + it("selects a company option, autofills company details, and shows the company name", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Gate"); + const option = await screen.findByRole("option", { name: "Gateway Plumbing" }); + await userEvent.click(option); + + expect(companyField()).toHaveValue("Gateway Plumbing"); + expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198"); + }); + + it("sets companyId when a known company is selected and clears it when the text changes", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Gate"); + await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" })); + expect(companyIdOutput()).toHaveTextContent("co-1"); + + await userEvent.type(companyField(), "!"); + expect(companyIdOutput()).toHaveTextContent(""); + }); + + it("keeps companyId null for free-typed company text that matches no option", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Independent Vendor LLC"); + expect(companyIdOutput()).toHaveTextContent(""); + }); + + it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => { + renderWithProviders(, { + withAuth: false, + }); + + const phone = screen.getByRole("textbox", { name: "Technician Phone" }); + await userEvent.type(phone, "3145550198"); + expect(phone).toHaveValue("(314) 555-0198"); + + const companyPhone = screen.getByRole("textbox", { name: "Company Phone" }); + await userEvent.type(companyPhone, "3145550100"); + expect(companyPhone).toHaveValue("(314) 555-0100"); + }); + + it("exposes a preferred contact selector with Phone, Email, and Text options", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByRole("button", { name: "Phone" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Email" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx new file mode 100644 index 00000000..995dd5dc --- /dev/null +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -0,0 +1,163 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import VendorsListPage from "@/app/(protected)/vendors/index"; +import { renderWithProviders } from "@/test/test-utils"; + +const useVendorsList = vi.fn(); +const useVendorDetail = vi.fn(); +const useVendorDeactivationImpact = vi.fn(); +const mutate = vi.fn(); + +vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ + useVendorsList: (...args: unknown[]) => useVendorsList(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({ + useVendorDetail: (...args: unknown[]) => useVendorDetail(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({ + useVendorDeactivationImpact: (...args: unknown[]) => useVendorDeactivationImpact(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({ + useDeleteVendor: () => ({ mutate, isPending: false }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const activeVendor = { + id: 1, + companyId: "co-1", + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + email: "dispatch@gatewayplumbing.com", + phone: "(314) 555-0198", + city: "St. Louis", + state: "MO", + tradeSpecialties: "Backflow Preventers", + isActive: true, + preferredContact: "Email", +}; + +const inactiveVendor = { + ...activeVendor, + id: 2, + companyName: "Drain Cleaning Bros", + isActive: false, +}; + +function result(items: (typeof activeVendor)[], totalCount: number) { + return { + data: { items, totalCount, page: 1, pageSize: 12 }, + isLoading: false, + isFetching: false, + error: null, + }; +} + +function setupDefaults() { + useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null }); + useVendorDeactivationImpact.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + }); +} + +describe("VendorsListPage", () => { + it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => { + setupDefaults(); + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); + return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); + expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" })); + + expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); + }); + + it("reports the filtered totalCount in the footer when page rows are fewer than total", () => { + setupDefaults(); + const pageVendors = Array.from({ length: 3 }, (_, index) => ({ + ...activeVendor, + id: 100 + index, + companyName: `Vendor ${index + 1}`, + })); + + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 25 : 0); + return result(pageVendors, 25); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); + expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); + }); + + it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { + setupDefaults(); + useVendorDetail.mockReturnValue({ data: activeVendor, isLoading: false, error: null }); + 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 }); + + const row = screen.getByRole("button", { + name: "Open vendor details for Gateway Plumbing", + }); + row.focus(); + await userEvent.keyboard("{Enter}"); + + expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument(); + }); + + it("blocks deactivation when the preflight reports open work orders", async () => { + setupDefaults(); + useVendorDeactivationImpact.mockReturnValue({ + data: { + vendorId: 1, + canDeactivate: false, + openWorkOrders: [ + { + workOrderId: 101, + workOrderNumber: "WO-101", + workOrderTitle: "Boiler repair", + status: "Dispatched", + }, + ], + }, + isLoading: false, + error: null, + }); + 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 }); + + await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" })); + + expect( + screen.getByText(/cannot be deactivated because it still has open work orders/), + ).toBeInTheDocument(); + expect(screen.getByText(/Boiler repair/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); + expect(mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/components/ui/text.test.tsx b/src/test/components/ui/text.test.tsx new file mode 100644 index 00000000..04973d75 --- /dev/null +++ b/src/test/components/ui/text.test.tsx @@ -0,0 +1,82 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Text } from "@/components/ui/text"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("Text", () => { + it("maps visual variants to semantic elements", () => { + renderWithProviders( + <> + Page title + Supporting copy + , + { withAuth: false }, + ); + + expect(screen.getByRole("heading", { level: 1, name: "Page title" })).toBeInTheDocument(); + expect(screen.getByText("Supporting copy").tagName).toBe("P"); + }); + + it("does not render conditional text when its condition is false", () => { + renderWithProviders( + + Saved + , + { withAuth: false }, + ); + + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + + it("keeps the feedback live region mounted so toggling its content is announceable", () => { + const { rerender } = renderWithProviders( + + Saved + , + { withAuth: false }, + ); + + const region = screen.getByRole("status"); + expect(region).toHaveAttribute("aria-live", "polite"); + expect(region).toHaveAttribute("aria-atomic", "true"); + expect(region).toBeEmptyDOMElement(); + + rerender( + + Saved + , + ); + + expect(screen.getByRole("status")).toBe(region); + expect(region).toHaveTextContent("Saved"); + }); + + it("gives error and feedback messages accessible live-region semantics", () => { + renderWithProviders( + <> + Upload failed + Uploading + , + { withAuth: false }, + ); + + const alert = screen.getByRole("alert"); + const status = screen.getByRole("status"); + expect(alert).toHaveTextContent("Upload failed"); + expect(status).toHaveTextContent("Uploading"); + expect(alert).toHaveAttribute("aria-live", "assertive"); + expect(alert).toHaveAttribute("aria-atomic", "true"); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveAttribute("aria-atomic", "true"); + }); + + it("does not attach live-region semantics to non-feedback variants", () => { + renderWithProviders(Saved, { withAuth: false }); + + const node = screen.getByText("Saved"); + expect(node).not.toHaveAttribute("role"); + expect(node).not.toHaveAttribute("aria-live"); + expect(node).not.toHaveAttribute("aria-atomic"); + }); +}); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts new file mode 100644 index 00000000..b66ce1bf --- /dev/null +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API_PATHS } from "@/api/api-paths"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiPut = vi.fn(); +const apiDelete = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), + apiPut: (...args: unknown[]) => apiPut(...args), + apiDelete: (...args: unknown[]) => apiDelete(...args), +})); + +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; + +describe("vendorsApi.getList", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + apiPut.mockReset(); + apiDelete.mockReset(); + apiGet.mockResolvedValue({ + Data: [ + { + Id: 7, + CompanyId: "co-1", + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + City: "St. Louis", + State: "MO", + IsActive: false, + PreferredContact: "Email", + }, + ], + TotalCount: 1, + }); + }); + + it("forwards the status filter and maps list location fields", async () => { + const result = await vendorsApi.getList({ search: "gateway", isActive: false, page: 1 }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, { + searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, + }); + expect(result.items[0]).toMatchObject({ + city: "St. Louis", + state: "MO", + isActive: false, + companyId: "co-1", + preferredContact: "Email", + }); + }); + + it("forwards all selected facets as repeated indexed query parameters", async () => { + await vendorsApi.getList({ + companies: ["Gateway Plumbing", "Metro HVAC"], + trades: ["Plumbing"], + locations: ["St. Louis, MO"], + jobBuckets: ["50-99"], + }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, { + searchParams: { + search: "", + page: 1, + pageSize: 12, + "companies[0]": "Gateway Plumbing", + "companies[1]": "Metro HVAC", + "trades[0]": "Plumbing", + "locations[0]": "St. Louis, MO", + "jobBuckets[0]": "50-99", + }, + }); + }); + + it("maps company profiles and labels returned by the facets endpoint", async () => { + apiGet.mockResolvedValueOnce({ + data: { + companies: [ + { + name: "Gateway Plumbing", + companyPhone: "314-555-0100", + email: "dispatch@gateway.test", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "https://maps.google.com/gateway", + }, + ], + trades: ["Plumbing"], + locations: [{ city: "St. Louis", state: "MO" }], + jobBuckets: [{ id: "50-99", label: "50–99" }], + }, + }); + + const facets = await vendorsApi.getFacets(); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} }); + expect(facets.companies[0]).toMatchObject({ + name: "Gateway Plumbing", + companyPhone: "314-555-0100", + googleMapsUrl: "https://maps.google.com/gateway", + }); + expect(facets.locations[0]?.label).toBe("St. Louis, MO"); + expect(facets.jobBuckets[0]).toEqual({ id: "50-99", label: "50–99" }); + }); + + it("serializes the requested facet scope as an isActive query parameter", async () => { + apiGet.mockResolvedValue({ + data: { companies: [], trades: [], locations: [], jobBuckets: [] }, + }); + + await vendorsApi.getFacets({ isActive: true }); + await vendorsApi.getFacets({ isActive: false }); + + expect(apiGet).toHaveBeenNthCalledWith(1, API_PATHS.vendor.facets, { + searchParams: { isActive: "true" }, + }); + expect(apiGet).toHaveBeenNthCalledWith(2, API_PATHS.vendor.facets, { + searchParams: { isActive: "false" }, + }); + }); + + it("sends all editable prototype fields when creating and updating", async () => { + const values = { + companyId: "42", + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + phone: "+1 314-555-0198", + companyPhone: "314-555-0100", + email: "dispatch@gateway.test", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + tradeSpecialties: "Plumbing, HVAC", + googleMapsUrl: "https://maps.google.com/gateway", + notes: "Preferred after-hours vendor", + totalJobs: 72, + isActive: true, + preferredContact: "Text", + }; + apiPost.mockResolvedValueOnce({ data: values }); + apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } }); + + await vendorsApi.create(values); + await vendorsApi.update(7, { ...values, isActive: false }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.rest.vendors, + expect.objectContaining({ + companyName: "Gateway Plumbing", + notes: values.notes, + companyId: 42, + preferredContact: "Text", + phone: "(314) 555-0198", + companyPhone: "(314) 555-0100", + }), + ); + expect(apiPut).toHaveBeenCalledWith( + `${API_PATHS.rest.vendors}/7`, + expect.objectContaining({ companyName: "Gateway Plumbing", isActive: false }), + ); + expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs"); + expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs"); + }); + + it("sends a null companyId when free-text company has no company link", async () => { + apiPost.mockResolvedValueOnce({ data: {} }); + await vendorsApi.create({ + companyName: "Independent Vendor LLC", + contactName: "Indy Tech", + preferredContact: "Phone", + }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.rest.vendors, + expect.objectContaining({ companyName: "Independent Vendor LLC", companyId: null }), + ); + }); + + it("does not send a non-numeric company id to the integer backend contract", async () => { + apiPost.mockResolvedValueOnce({ data: {} }); + + await vendorsApi.create({ + companyId: "co-1", + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.rest.vendors, + expect.objectContaining({ companyId: null }), + ); + }); + + it("preserves a legacy phone on read and unrelated write", async () => { + apiGet.mockResolvedValueOnce({ + data: { + id: 7, + companyName: "Legacy Vendor", + contactName: "Taylor", + phone: "555-1234 ext 9", + }, + }); + apiPut.mockResolvedValueOnce({ data: {} }); + + const vendor = await vendorsApi.getById(7); + expect(vendor.phone).toBe("555-1234 ext 9"); + + await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" }); + expect(apiPut).toHaveBeenCalledWith( + `${API_PATHS.rest.vendors}/7`, + expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }), + ); + }); + + it("maps the deactivation-impact payload including linked work orders", async () => { + apiGet.mockResolvedValueOnce({ + data: { + vendorId: 7, + canDeactivate: false, + openWorkOrders: [ + { + workOrderId: 101, + workOrderNumber: "WO-101", + workOrderTitle: "Boiler repair", + status: "Dispatched", + scheduledDate: "2025-01-02", + dispatchId: 501, + }, + ], + }, + }); + + const impact = await vendorsApi.getDeactivationImpact(7); + + expect(apiGet).toHaveBeenCalledWith( + `${API_PATHS.rest.vendors}/7/${API_PATHS.vendor.deactivationImpact}`, + ); + expect(impact).toEqual({ + vendorId: 7, + canDeactivate: false, + openWorkOrders: [ + { + workOrderId: 101, + workOrderNumber: "WO-101", + workOrderTitle: "Boiler repair", + status: "Dispatched", + scheduledDate: "2025-01-02", + dispatchId: 501, + }, + ], + }); + }); + + it("deletes a vendor by id and surfaces the 409 error without retry swallowing", async () => { + apiDelete.mockResolvedValueOnce(undefined); + await vendorsApi.delete(7); + expect(apiDelete).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`); + + const conflict = Object.assign(new Error("Conflict"), { response: { status: 409 } }); + apiDelete.mockRejectedValueOnce(conflict); + await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict"); + }); +}); diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts new file mode 100644 index 00000000..244c2b8e --- /dev/null +++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema"; + +const validVendor = { + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + email: "dispatch@gateway.test", + phone: "(314) 555-0198", + googleMapsUrl: "https://maps.google.com/gateway", +}; + +describe("vendorSchema", () => { + it("requires both the company and technician identity", () => { + const result = vendorSchema.safeParse({ ...validVendor, companyName: "", contactName: "" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.companyName).toContain("Company is required"); + expect(result.error.flatten().fieldErrors.contactName).toContain( + "Technician name is required", + ); + } + }); + + it("accepts an empty optional email and HTTPS map URL", () => { + expect(vendorSchema.safeParse({ ...validVendor, email: "" }).success).toBe(true); + }); + + it("rejects malformed email and non-HTTPS map links", () => { + expect( + vendorSchema.safeParse({ + ...validVendor, + email: "not-an-email", + googleMapsUrl: "http://maps.google.com/gateway", + }).success, + ).toBe(false); + }); + + it("accepts canonicalizable North American phones and rejects partials", () => { + expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe( + true, + ); + expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe( + true, + ); + expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(true); + expect(vendorSchema.safeParse({ ...validVendor, phone: "+1 314 555 0198" }).success).toBe(true); + expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false); + }); + + it("accepts a nullable companyId and a known preferred contact value", () => { + expect( + vendorSchema.safeParse({ ...validVendor, companyId: null, preferredContact: "Text" }).success, + ).toBe(true); + expect( + vendorSchema.safeParse({ ...validVendor, companyId: 42, preferredContact: "Email" }).success, + ).toBe(true); + }); + + it("rejects an unknown preferred contact value", () => { + expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false); + }); +}); diff --git a/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx b/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx new file mode 100644 index 00000000..c732e116 --- /dev/null +++ b/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const getFacets = vi.fn(); + +vi.mock("@/domain/vendors/api/vendors-api", () => ({ + vendorsApi: { getFacets: (...args: unknown[]) => getFacets(...args) }, +})); + +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; +import { queryKeys } from "@/infra/query-key/query-key"; + +const emptyFacets = { companies: [], trades: [], locations: [], jobBuckets: [] }; +const activeFacets = { ...emptyFacets, companies: [{ name: "Active Co" }] }; +const inactiveFacets = { ...emptyFacets, companies: [{ name: "Inactive Co" }] }; + +function createClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useVendorFacets", () => { + beforeEach(() => { + getFacets.mockReset(); + }); + + it("switches facet requests true/false as the tab scope changes", async () => { + getFacets.mockResolvedValue(activeFacets); + const { rerender } = renderHook(({ isActive }) => useVendorFacets(isActive), { + initialProps: { isActive: true as boolean | undefined }, + wrapper: makeWrapper(createClient()), + }); + + await waitFor(() => expect(getFacets).toHaveBeenCalledWith({ isActive: true })); + + getFacets.mockResolvedValue(inactiveFacets); + rerender({ isActive: false }); + await waitFor(() => expect(getFacets).toHaveBeenCalledWith({ isActive: false })); + + getFacets.mockResolvedValue(activeFacets); + rerender({ isActive: undefined }); + await waitFor(() => expect(getFacets).toHaveBeenCalledWith(undefined)); + }); + + it("scopes the cache so active, inactive, and unscoped keys cannot collide", () => { + const active = queryKeys.vendors.facets(true); + const inactive = queryKeys.vendors.facets(false); + const unscoped = queryKeys.vendors.facets(); + + expect(active).not.toEqual(inactive); + expect(active).not.toEqual(unscoped); + expect(inactive).not.toEqual(unscoped); + expect(active[active.length - 1]).toBe(true); + expect(inactive[inactive.length - 1]).toBe(false); + expect(unscoped[unscoped.length - 1]).toBeNull(); + }); + + it("reuses the cached facet entry when returning to a previous scope", async () => { + getFacets.mockResolvedValue(activeFacets); + const client = createClient(); + const { result, rerender } = renderHook(({ isActive }) => useVendorFacets(isActive), { + initialProps: { isActive: true as boolean | undefined }, + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(getFacets).toHaveBeenCalledTimes(1)); + + getFacets.mockResolvedValue(inactiveFacets); + rerender({ isActive: false }); + await waitFor(() => expect(getFacets).toHaveBeenCalledTimes(2)); + + getFacets.mockClear(); + rerender({ isActive: true }); + await waitFor(() => expect(result.current.data).toEqual(activeFacets)); + expect(getFacets).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 32040a19..f44e7b30 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { API_PATHS } from "@/api/api-paths"; const apiGet = vi.fn(); +const apiPut = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), + apiPut: (...args: unknown[]) => apiPut(...args), apiPost: vi.fn(), apiDelete: vi.fn(), apiPostNoContent: vi.fn(), @@ -15,6 +17,7 @@ import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; describe("workOrdersApi.getById", () => { beforeEach(() => { apiGet.mockReset(); + apiPut.mockReset(); apiGet.mockResolvedValue({ Id: 10, title: "Test WO", @@ -22,6 +25,22 @@ describe("workOrdersApi.getById", () => { }); }); + it("updates only the assigned vendor through the work-order-scoped endpoint", async () => { + apiPut.mockResolvedValueOnce(undefined); + const payload = { + workOrderId: 10, + contactName: "Taylor Tech", + preferredContact: "Text" as const, + phone: "(314) 555-0199", + email: "taylor@example.com", + notes: "Text before arrival", + }; + + await workOrdersApi.updateVendorFromWorkOrder(7, payload); + + expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7/work-order-update`, payload); + }); + it("calls GetWorkorderById with id query param, not path suffix", async () => { await workOrdersApi.getById(10); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index a9dfc9bf..632fa346 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -1,5 +1,26 @@ import { describe, expect, it } from "vitest"; -import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper"; +import { + mapUpliftRequest, + mapWorkOrderDispatchSummary, +} from "@/domain/work-orders/mappers/work-order-mapper"; + +describe("mapWorkOrderDispatchSummary", () => { + it("preserves the assigned vendor identity needed by inline editing", () => { + expect( + mapWorkOrderDispatchSummary({ + Id: 21, + VendorId: 7, + VendorName: "Gateway Plumbing", + VendorEmail: "dispatch@gateway.test", + }), + ).toMatchObject({ + id: 21, + vendorId: 7, + vendorName: "Gateway Plumbing", + vendorEmail: "dispatch@gateway.test", + }); + }); +}); describe("mapUpliftRequest", () => { it("maps decided uplift fields from PascalCase payload", () => {