Two new fields added to the customer add/edit form, both
operator-facing for the active/inactive customer workflow:
- customers.status (bool): flips the customer active/inactive.
Legacy rows pre-date the field and load as undefined; both
the form and the read side collapse that to active=true so
the migration date doesn't silently flip the existing base.
- customers.site_location (text): free-text site/shaft label
keyed off the customer's company. Distinct from the existing
'site' free-text mine tag owned by the pin tool. Storage is
plain text on purpose, with a /api/v1/customers/sites
endpoint returning distinct existing values for an input
+ datalist autocomplete under the company combobox.
surfaces:
- Active/Inactive badge on customer detail and list rows
- site_location row in the customer Details card and under
the company line on the list (desktop + mobile card)
- Switch under the sales-rep section of the form to flip
status; existing customers stay active by default
also fixes a UX trap on edit: previously opening the edit
form showed 'Select company...' / 'Select rep...' as placeholders
for an instant until the lookup lists resolved, making a real,
saved company/rep look unlinked. CompanySearch and UserSearch
now accept a selectedLabel fallback; CustomerForm passes the
resolved customer.expand.company / customer.expand.rep names
through so the triggers display the linked names immediately.
listDistinctSiteLocations helper added to customers-service.ts
alongside the existing listDistinctSites.
164 lines
5.1 KiB
TypeScript
164 lines
5.1 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { CustomerForm } from "./customer-form";
|
|
|
|
const {
|
|
createMock,
|
|
findCustomerDuplicatesMock,
|
|
getAllCompaniesMock,
|
|
} = vi.hoisted(() => ({
|
|
createMock: vi.fn(),
|
|
findCustomerDuplicatesMock: vi.fn(),
|
|
getAllCompaniesMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/pocketbase", () => ({
|
|
__esModule: true,
|
|
default: () => ({
|
|
collection: () => ({
|
|
create: createMock,
|
|
update: vi.fn(),
|
|
}),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: { error: vi.fn(), success: vi.fn() },
|
|
}));
|
|
|
|
vi.mock("@/lib/pb-helpers", () => ({
|
|
getAllCompanies: getAllCompaniesMock,
|
|
findCustomerDuplicates: findCustomerDuplicatesMock,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/company-search", () => ({
|
|
CompanySearch: () => <div>Company Search</div>,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/user-search", () => ({
|
|
UserSearch: () => <div>User Search</div>,
|
|
}));
|
|
|
|
// `CustomerForm` now fetches `/api/v1/customers/sites` (added 2026-09
|
|
// for the `site_location` `<datalist>` autocomplete). jsdom has no
|
|
// global `fetch` — stub one so the effect's no-result promise
|
|
// resolves cleanly without each test having to wire fetch up.
|
|
const stubFetch = () =>
|
|
Promise.resolve(new Response(JSON.stringify({ items: [] }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
})) as unknown as Response;
|
|
|
|
beforeAll(() => {
|
|
if (typeof globalThis.fetch !== "function") {
|
|
globalThis.fetch = stubFetch as unknown as typeof fetch;
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
getAllCompaniesMock.mockResolvedValue([]);
|
|
findCustomerDuplicatesMock.mockResolvedValue([]);
|
|
});
|
|
|
|
// The form's useEffect on mount calls `getAllCompanies()` to populate
|
|
// the company combobox. The pre-existing test (delivery-coords
|
|
// position) never awaited that promise — it just used `render` — and
|
|
// the form reads `companies.length` in a separate effect. Provide a
|
|
// safe default before each test.
|
|
beforeAll(() => {
|
|
getAllCompaniesMock.mockResolvedValue([]);
|
|
});
|
|
|
|
describe("CustomerForm", () => {
|
|
it("shows delivery coordinates in the contact section before details", () => {
|
|
render(
|
|
<CustomerForm
|
|
open
|
|
onOpenChange={() => undefined}
|
|
onSaved={() => undefined}
|
|
/>
|
|
);
|
|
|
|
const content = document.body.textContent ?? "";
|
|
expect(content.indexOf("Delivery Coordinates")).toBeGreaterThan(content.indexOf("Contact"));
|
|
expect(content.indexOf("Delivery Coordinates")).toBeLessThan(content.indexOf("Details"));
|
|
});
|
|
|
|
it("blocks save when an existing customer has the same (name, company) pair and lets the operator override", async () => {
|
|
// Form is filling out (John Smith, company=acme-id, email=).
|
|
// PB returns an existing customer with the same name+company —
|
|
// a real collision. Submit should pop the duplicate dialog.
|
|
findCustomerDuplicatesMock.mockResolvedValueOnce([
|
|
{
|
|
id: "cu-existing",
|
|
customer: "John Smith",
|
|
company: "acme-id",
|
|
email: "",
|
|
coordinates: { lon: 0, lat: 0 },
|
|
} as never,
|
|
]);
|
|
createMock.mockResolvedValue({ id: "cu1" });
|
|
|
|
render(
|
|
<CustomerForm
|
|
open
|
|
onOpenChange={() => undefined}
|
|
onSaved={() => undefined}
|
|
/>
|
|
);
|
|
|
|
const form = screen.getByRole("dialog").querySelector("form")!;
|
|
fireEvent.change(within(form).getByPlaceholderText("Full name or business name"), {
|
|
target: { value: "john smith" },
|
|
});
|
|
fireEvent.click(within(form).getByRole("button", { name: "Create Customer" }));
|
|
|
|
// Wait for the dialog to show the existing match.
|
|
await waitFor(() => {
|
|
expect(screen.getByText("John Smith")).toBeInTheDocument();
|
|
});
|
|
// The write was NOT performed yet.
|
|
expect(createMock).not.toHaveBeenCalled();
|
|
|
|
// "Save anyway" should land the write.
|
|
fireEvent.click(screen.getByRole("button", { name: /save anyway/i }));
|
|
await waitFor(() => {
|
|
expect(createMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ customer: "john smith" })
|
|
);
|
|
});
|
|
});
|
|
|
|
it("passes the current record's id as excludeId in edit mode", async () => {
|
|
// The form is bound to an existing customer. The duplicate check
|
|
// must exclude the record being edited so it doesn't flag itself.
|
|
const existing = { id: "cu-self", customer: "John Smith" };
|
|
findCustomerDuplicatesMock.mockResolvedValue([]);
|
|
render(
|
|
<CustomerForm
|
|
open
|
|
onOpenChange={() => undefined}
|
|
onSaved={() => undefined}
|
|
customer={existing as never}
|
|
/>
|
|
);
|
|
|
|
const form = screen.getByRole("dialog").querySelector("form")!;
|
|
fireEvent.change(within(form).getByPlaceholderText("Full name or business name"), {
|
|
target: { value: "John Smith" },
|
|
});
|
|
|
|
// The live check debounces by 300ms — wait for the helper to be
|
|
// called with the record's id as the excludeId.
|
|
await waitFor(() => {
|
|
expect(findCustomerDuplicatesMock).toHaveBeenCalledWith(
|
|
"John Smith",
|
|
"",
|
|
"",
|
|
"cu-self"
|
|
);
|
|
});
|
|
});
|
|
});
|