mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 20:32:05 +00:00
chore: merge origin/dev into feature/sh-127-assigned-to-ownership
This commit is contained in:
commit
0d883f625c
17 changed files with 453 additions and 63 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
82
e2e/vendors/vendors.spec.ts
vendored
82
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -450,8 +450,86 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||||
).toBeEnabled();
|
).toBeEnabled();
|
||||||
|
|
||||||
await page.getByRole("combobox", { name: "Company" }).click();
|
const companyLabel = page
|
||||||
|
.getByRole("dialog", { name: /Add Vendor/ })
|
||||||
|
.locator("label")
|
||||||
|
.filter({ hasText: "Company (required)" });
|
||||||
|
await expect(companyLabel).toBeVisible();
|
||||||
|
const companyLabelMetrics = await companyLabel.evaluate((element) => {
|
||||||
|
const field = element.closest(".MuiFormControl-root");
|
||||||
|
const labelRect = element.getBoundingClientRect();
|
||||||
|
const fieldRect = field?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
clientWidth: element.clientWidth,
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
withinFieldGeometry: fieldRect
|
||||||
|
? labelRect.left >= fieldRect.left &&
|
||||||
|
labelRect.right <= fieldRect.right &&
|
||||||
|
labelRect.top >= fieldRect.top &&
|
||||||
|
labelRect.bottom <= fieldRect.bottom
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(companyLabelMetrics.clientWidth).toBeGreaterThanOrEqual(companyLabelMetrics.scrollWidth);
|
||||||
|
expect(companyLabelMetrics.withinFieldGeometry).toBe(true);
|
||||||
|
|
||||||
|
await page.getByRole("combobox", { name: "Company (required)" }).click();
|
||||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||||
|
const floatedCompanyLabel = page
|
||||||
|
.getByRole("dialog", { name: /Add Vendor/ })
|
||||||
|
.locator("label")
|
||||||
|
.filter({ hasText: "Company (required)" });
|
||||||
|
const floatedCompanyField = floatedCompanyLabel.locator("..");
|
||||||
|
await expect(floatedCompanyLabel).toHaveText("Company (required)");
|
||||||
|
await expect(floatedCompanyField.locator("legend")).toHaveText("Company (required)");
|
||||||
|
const floatedMetrics = await floatedCompanyLabel.evaluate((element) => {
|
||||||
|
const labelRect = element.getBoundingClientRect();
|
||||||
|
const legendRect = element
|
||||||
|
.closest(".MuiFormControl-root")
|
||||||
|
?.querySelector("legend")
|
||||||
|
?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
labelClientWidth: element.clientWidth,
|
||||||
|
labelScrollWidth: element.scrollWidth,
|
||||||
|
legendWidth: legendRect?.width ?? 0,
|
||||||
|
labelWidth: labelRect.width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(floatedMetrics.labelClientWidth).toBeGreaterThanOrEqual(floatedMetrics.labelScrollWidth);
|
||||||
|
expect(floatedMetrics.legendWidth).toBeGreaterThanOrEqual(floatedMetrics.labelWidth);
|
||||||
|
|
||||||
|
// Scrolling focus down the form and back to Company must keep the floated label
|
||||||
|
// inside the dialog's visible scrollport.
|
||||||
|
const dialogContent = page
|
||||||
|
.getByRole("dialog", { name: /Add Vendor/ })
|
||||||
|
.locator(".MuiDialogContent-root");
|
||||||
|
await page.getByLabel("Technician name (optional)").click();
|
||||||
|
await expect
|
||||||
|
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await page.getByRole("combobox", { name: "Company (required)" }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
|
||||||
|
.toBeLessThanOrEqual(1);
|
||||||
|
await expect(floatedCompanyLabel).toBeVisible();
|
||||||
|
const labelScrollportMetrics = await floatedCompanyLabel.evaluate((element) => {
|
||||||
|
const content = element.closest(".MuiDialogContent-root");
|
||||||
|
return {
|
||||||
|
labelTop: element.getBoundingClientRect().top,
|
||||||
|
labelBottom: element.getBoundingClientRect().bottom,
|
||||||
|
dialogTop: content?.getBoundingClientRect().top ?? Number.NaN,
|
||||||
|
dialogBottom: content?.getBoundingClientRect().bottom ?? Number.NaN,
|
||||||
|
overflowY: content ? getComputedStyle(content).overflowY : "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(["auto", "scroll"]).toContain(labelScrollportMetrics.overflowY);
|
||||||
|
expect(labelScrollportMetrics.labelTop).toBeGreaterThanOrEqual(
|
||||||
|
labelScrollportMetrics.dialogTop - 1,
|
||||||
|
);
|
||||||
|
expect(labelScrollportMetrics.labelBottom).toBeLessThanOrEqual(
|
||||||
|
labelScrollportMetrics.dialogBottom + 1,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
|
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
|
||||||
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
||||||
"dispatch@gateway.test",
|
"dispatch@gateway.test",
|
||||||
|
|
@ -560,7 +638,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||||
).toBeEnabled();
|
).toBeEnabled();
|
||||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
|
||||||
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
||||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||||
|
|
||||||
|
|
|
||||||
89
src/app/(protected)/vendors/_components/vendor-company-contact-fields.tsx
vendored
Normal file
89
src/app/(protected)/vendors/_components/vendor-company-contact-fields.tsx
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { Controller, useWatch, type Control, type FieldErrors } from "react-hook-form";
|
||||||
|
import { Stack, TextField } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
|
||||||
|
type VendorCompanyRosterFormValues,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
|
||||||
|
|
||||||
|
const COMPANY_CONTACT_ERROR_ID = "vendor-company-contact-error";
|
||||||
|
const COMPANY_PHONE_HELPER_ID = "vendor-company-phone-helper";
|
||||||
|
const COMPANY_EMAIL_HELPER_ID = "vendor-company-email-helper";
|
||||||
|
|
||||||
|
interface VendorCompanyContactFieldsProps {
|
||||||
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
|
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorCompanyContactFields({ control, errors }: VendorCompanyContactFieldsProps) {
|
||||||
|
const companyPhone = useWatch({ control, name: "companyPhone" }) ?? "";
|
||||||
|
const companyEmail = useWatch({ control, name: "email" }) ?? "";
|
||||||
|
const showCompanyContactError = Boolean(
|
||||||
|
errors.companyContact && !companyPhone.trim() && !companyEmail.trim(),
|
||||||
|
);
|
||||||
|
const contactDescribedBy = (helperId: string, hasHelper: boolean) =>
|
||||||
|
[hasHelper ? helperId : null, showCompanyContactError ? COMPANY_CONTACT_ERROR_ID : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="companyPhone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Company Phone (optional)"
|
||||||
|
placeholder="(214) 555-0100"
|
||||||
|
inputMode="tel"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||||
|
error={Boolean(errors.companyPhone)}
|
||||||
|
helperText={errors.companyPhone?.message}
|
||||||
|
slotProps={{
|
||||||
|
formHelperText: { id: COMPANY_PHONE_HELPER_ID },
|
||||||
|
htmlInput: {
|
||||||
|
"aria-describedby": contactDescribedBy(
|
||||||
|
COMPANY_PHONE_HELPER_ID,
|
||||||
|
Boolean(errors.companyPhone?.message),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Email (optional)"
|
||||||
|
placeholder="dispatch@vendor.com"
|
||||||
|
type="email"
|
||||||
|
error={Boolean(errors.email)}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
slotProps={{
|
||||||
|
formHelperText: { id: COMPANY_EMAIL_HELPER_ID },
|
||||||
|
htmlInput: {
|
||||||
|
"aria-describedby": contactDescribedBy(
|
||||||
|
COMPANY_EMAIL_HELPER_ID,
|
||||||
|
Boolean(errors.email?.message),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Text variant="error" id={COMPANY_CONTACT_ERROR_ID} when={showCompanyContactError}>
|
||||||
|
{VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,12 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
Register a vendor company and its technicians.
|
Register a vendor company and its technicians.
|
||||||
</Text>
|
</Text>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent sx={{ maxHeight: "55vh" }}>
|
<DialogContent
|
||||||
|
sx={{
|
||||||
|
maxHeight: "55vh",
|
||||||
|
"&.MuiDialogContent-root": { pt: "12px" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Stack spacing={2} className="mt-1">
|
<Stack spacing={2} className="mt-1">
|
||||||
{form.loadErrorMessage !== null && (
|
{form.loadErrorMessage !== null && (
|
||||||
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,15 @@ interface VendorRosterConflictAlertProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorRosterConflictAlert({ conflict, onReload }: VendorRosterConflictAlertProps) {
|
export function VendorRosterConflictAlert({ conflict, onReload }: VendorRosterConflictAlertProps) {
|
||||||
|
if (conflict.kind === "duplicate") {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning">
|
||||||
|
<AlertTitle>Company name already exists</AlertTitle>
|
||||||
|
{conflict.message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (conflict.kind === "stale") {
|
if (conflict.kind === "stale") {
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
type VendorCompanyRosterFormValues,
|
type VendorCompanyRosterFormValues,
|
||||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
import { VendorCompanyContactFields } from "./vendor-company-contact-fields";
|
||||||
|
|
||||||
interface VendorRosterFormFieldsProps {
|
interface VendorRosterFormFieldsProps {
|
||||||
control: Control<VendorCompanyRosterFormValues>;
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
|
|
@ -64,9 +65,9 @@ function CompanyNameField({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Company"
|
label="Company (required)"
|
||||||
placeholder="Start typing a company…"
|
placeholder="Start typing a company…"
|
||||||
required
|
slotProps={{ htmlInput: { required: true } }}
|
||||||
error={Boolean(errors.name)}
|
error={Boolean(errors.name)}
|
||||||
helperText={errors.name?.message}
|
helperText={errors.name?.message}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -108,12 +109,15 @@ function CompanyNameField({
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
label="Company"
|
label="Company (required)"
|
||||||
required
|
|
||||||
placeholder="Start typing a company…"
|
placeholder="Start typing a company…"
|
||||||
error={Boolean(errors.name)}
|
error={Boolean(errors.name)}
|
||||||
helperText={errors.name?.message}
|
helperText={errors.name?.message}
|
||||||
inputRef={field.ref}
|
inputRef={field.ref}
|
||||||
|
slotProps={{
|
||||||
|
...params.slotProps,
|
||||||
|
htmlInput: { ...params.slotProps?.htmlInput, required: true },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -143,40 +147,7 @@ function CompanyFields({
|
||||||
onSelectCompany={onSelectCompany}
|
onSelectCompany={onSelectCompany}
|
||||||
onClearSelectedCompany={onClearSelectedCompany}
|
onClearSelectedCompany={onClearSelectedCompany}
|
||||||
/>
|
/>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
<VendorCompanyContactFields control={control} errors={errors} />
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="companyPhone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
label="Company Phone (optional)"
|
|
||||||
placeholder="(214) 555-0100"
|
|
||||||
inputMode="tel"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
|
||||||
error={Boolean(errors.companyPhone)}
|
|
||||||
helperText={errors.companyPhone?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
label="Email (optional)"
|
|
||||||
placeholder="dispatch@vendor.com"
|
|
||||||
type="email"
|
|
||||||
error={Boolean(errors.email)}
|
|
||||||
helperText={errors.email?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="address"
|
name="address"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const OPEN_WORK_ORDER_MESSAGE =
|
||||||
|
|
||||||
async function readConflictBody(error: unknown): Promise<unknown> {
|
async function readConflictBody(error: unknown): Promise<unknown> {
|
||||||
if (!isHTTPError(error)) return undefined;
|
if (!isHTTPError(error)) return undefined;
|
||||||
|
if (error.data !== undefined && error.data !== null) return error.data;
|
||||||
try {
|
try {
|
||||||
return await error.response.json();
|
return await error.response.json();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -179,8 +179,13 @@ export function mapRosterConflict(raw: unknown, fallbackMessage: string): Vendor
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const blockedRaw = item.blockedWorkOrders ?? item.BlockedWorkOrders ?? item.openWorkOrders;
|
const blockedRaw = item.blockedWorkOrders ?? item.BlockedWorkOrders ?? item.openWorkOrders;
|
||||||
const blockedWorkOrders = Array.isArray(blockedRaw) ? blockedRaw.map(mapBlockedWorkOrder) : [];
|
const blockedWorkOrders = Array.isArray(blockedRaw) ? blockedRaw.map(mapBlockedWorkOrder) : [];
|
||||||
|
const code = readString(item, "code", "Code");
|
||||||
const kind: VendorRosterConflictKind =
|
const kind: VendorRosterConflictKind =
|
||||||
blockedWorkOrders.length > 0 ? "open-work-orders" : "stale";
|
code === "duplicate_vendor_company_name"
|
||||||
|
? "duplicate"
|
||||||
|
: blockedWorkOrders.length > 0
|
||||||
|
? "open-work-orders"
|
||||||
|
: "stale";
|
||||||
const message = readString(item, "message", "Message") || fallbackMessage;
|
const message = readString(item, "message", "Message") || fallbackMessage;
|
||||||
return { kind, message, blockedWorkOrders };
|
return { kind, message, blockedWorkOrders };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ export type RosterTechnicianValues = z.infer<typeof rosterTechnicianSchema>;
|
||||||
|
|
||||||
const baseCompanyFields = {
|
const baseCompanyFields = {
|
||||||
name: z.string().trim().min(1, "Company is required"),
|
name: z.string().trim().min(1, "Company is required"),
|
||||||
|
// Validation-only identity for the phone-or-email group. It is never
|
||||||
|
// registered as an input and Zod omits it from parsed values when absent,
|
||||||
|
// so it cannot enter the vendor payload.
|
||||||
|
companyContact: z.never().optional(),
|
||||||
companyPhone: northAmericanPhone,
|
companyPhone: northAmericanPhone,
|
||||||
email: optionalEmail,
|
email: optionalEmail,
|
||||||
address: z.string(),
|
address: z.string(),
|
||||||
|
|
@ -64,14 +68,20 @@ const baseCompanyFields = {
|
||||||
technicians: z.array(rosterTechnicianSchema),
|
technicians: z.array(rosterTechnicianSchema),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE =
|
||||||
|
"Provide a company phone or email (at least one required)";
|
||||||
|
|
||||||
|
// The phone-or-email rule spans two optional inputs, so it is reported at a
|
||||||
|
// validation-only path instead of being attributed to `companyPhone`, which
|
||||||
|
// would mark that input individually invalid.
|
||||||
export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine((data, ctx) => {
|
export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine((data, ctx) => {
|
||||||
const hasPhone = Boolean(data.companyPhone && data.companyPhone.trim());
|
const hasPhone = Boolean(data.companyPhone && data.companyPhone.trim());
|
||||||
const hasEmail = Boolean(data.email && data.email.trim());
|
const hasEmail = Boolean(data.email && data.email.trim());
|
||||||
if (!hasPhone && !hasEmail) {
|
if (!hasPhone && !hasEmail) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["companyPhone"],
|
path: ["companyContact"],
|
||||||
message: "Provide a company phone or email (at least one required)",
|
message: VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
2
src/domain/vendors/types/vendor.ts
vendored
2
src/domain/vendors/types/vendor.ts
vendored
|
|
@ -145,7 +145,7 @@ export interface VendorRosterBlockedWorkOrder {
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VendorRosterConflictKind = "open-work-orders" | "stale";
|
export type VendorRosterConflictKind = "duplicate" | "open-work-orders" | "stale";
|
||||||
|
|
||||||
export interface VendorRosterConflict {
|
export interface VendorRosterConflict {
|
||||||
kind: VendorRosterConflictKind;
|
kind: VendorRosterConflictKind;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
|
import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
|
||||||
|
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
|
||||||
|
import {
|
||||||
|
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
|
||||||
|
emptyVendorCompanyRosterForm,
|
||||||
|
vendorCompanyRosterSchema,
|
||||||
|
type VendorCompanyRosterFormValues,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
|
||||||
|
|
@ -18,28 +27,128 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () =>
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function PlainCompanyFieldsHarness() {
|
||||||
|
const { control, handleSubmit, formState } = useForm<VendorCompanyRosterFormValues>({
|
||||||
|
defaultValues: emptyVendorCompanyRosterForm,
|
||||||
|
resolver: zodResolver(vendorCompanyRosterSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(() => {})}>
|
||||||
|
<VendorRosterFormFields control={control} errors={formState.errors} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCreateModal() {
|
||||||
|
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
|
||||||
|
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
|
||||||
|
target: { value: name },
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
|
||||||
|
}
|
||||||
|
|
||||||
describe("VendorCreateModal validation", () => {
|
describe("VendorCreateModal validation", () => {
|
||||||
it("explains the company phone-or-email requirement after submission", async () => {
|
it("explains the company phone-or-email requirement after submission", async () => {
|
||||||
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
|
renderCreateModal();
|
||||||
route: "/vendors",
|
await fillCompanyNameAndSubmit();
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("combobox", { name: /Company/ }), {
|
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
|
||||||
target: { value: "Gateway Plumbing" },
|
});
|
||||||
});
|
|
||||||
|
it("labels the autocomplete company field exactly, required, with no generated asterisk", () => {
|
||||||
|
renderCreateModal();
|
||||||
|
|
||||||
|
const company = screen.getByRole("combobox", { name: "Company (required)" });
|
||||||
|
expect(company).toBeRequired();
|
||||||
|
expect(screen.getAllByText("Company (required)")).toHaveLength(2);
|
||||||
|
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull();
|
||||||
|
expect(company.closest(".MuiFormControl-root")?.querySelector("legend")).toHaveTextContent(
|
||||||
|
/^Company \(required\)$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the plain company field exactly, required, with no generated asterisk", () => {
|
||||||
|
renderWithProviders(<PlainCompanyFieldsHarness />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
const company = screen.getByRole("textbox", { name: "Company (required)" });
|
||||||
|
expect(company).toBeRequired();
|
||||||
|
expect(screen.getAllByText("Company (required)")).toHaveLength(2);
|
||||||
|
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull();
|
||||||
|
expect(company.closest(".MuiFormControl-root")?.querySelector("legend")).toHaveTextContent(
|
||||||
|
/^Company \(required\)$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the phone-or-email error once without marking either input individually invalid", async () => {
|
||||||
|
renderCreateModal();
|
||||||
|
await fillCompanyNameAndSubmit();
|
||||||
|
|
||||||
|
const message = await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE);
|
||||||
|
expect(message).toHaveAttribute("id");
|
||||||
|
expect(screen.getAllByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toHaveLength(1);
|
||||||
|
|
||||||
|
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
|
||||||
|
const email = screen.getByRole("textbox", { name: "Email (optional)" });
|
||||||
|
expect(phone).not.toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(email).not.toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(phone.getAttribute("aria-describedby")).toContain(message.getAttribute("id"));
|
||||||
|
expect(email.getAttribute("aria-describedby")).toContain(message.getAttribute("id"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only the phone input invalid when the phone is malformed", async () => {
|
||||||
|
renderCreateModal();
|
||||||
|
await fillCompanyNameAndSubmit();
|
||||||
|
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
|
||||||
|
const email = screen.getByRole("textbox", { name: "Email (optional)" });
|
||||||
|
|
||||||
|
await userEvent.type(phone, "314");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
|
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
|
||||||
|
|
||||||
expect(
|
expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument();
|
||||||
await screen.findByText("Provide a company phone or email (at least one required)"),
|
expect(phone).toHaveAttribute("aria-invalid", "true");
|
||||||
).toBeInTheDocument();
|
expect(email).not.toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only the email input invalid when the email is malformed", async () => {
|
||||||
|
renderCreateModal();
|
||||||
|
await fillCompanyNameAndSubmit();
|
||||||
|
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
|
||||||
|
const email = screen.getByRole("textbox", { name: "Email (optional)" });
|
||||||
|
|
||||||
|
await userEvent.type(email, "not-an-email");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Invalid email")).toBeInTheDocument();
|
||||||
|
expect(email).toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(phone).not.toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the phone-or-email error once a valid phone is entered", async () => {
|
||||||
|
renderCreateModal();
|
||||||
|
await fillCompanyNameAndSubmit();
|
||||||
|
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
|
||||||
|
|
||||||
|
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.type(phone, "3145550100");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the notes limit and live character counter", async () => {
|
it("shows the notes limit and live character counter", async () => {
|
||||||
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
|
renderCreateModal();
|
||||||
route: "/vendors",
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const notes = screen.getByRole("textbox", { name: "Notes (optional)" });
|
const notes = screen.getByRole("textbox", { name: "Notes (optional)" });
|
||||||
fireEvent.change(notes, { target: { value: "abc" } });
|
fireEvent.change(notes, { target: { value: "abc" } });
|
||||||
|
|
|
||||||
22
src/test/app/(protected)/vendors/vendor-roster-conflict-alert.test.tsx
vendored
Normal file
22
src/test/app/(protected)/vendors/vendor-roster-conflict-alert.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { VendorRosterConflictAlert } from "@/app/(protected)/vendors/_components/vendor-roster-conflict-alert";
|
||||||
|
|
||||||
|
describe("VendorRosterConflictAlert", () => {
|
||||||
|
it("explains a duplicate company without offering a stale-data reload", () => {
|
||||||
|
render(
|
||||||
|
<VendorRosterConflictAlert
|
||||||
|
conflict={{
|
||||||
|
kind: "duplicate",
|
||||||
|
message: "Another vendor company already uses that name.",
|
||||||
|
blockedWorkOrders: [],
|
||||||
|
}}
|
||||||
|
onReload={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Company name already exists")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Another vendor company already uses that name.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Reload" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -329,7 +329,7 @@ describe("VendorsListPage", () => {
|
||||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
const company = screen.getByRole("textbox", { name: "Company" });
|
const company = screen.getByRole("textbox", { name: "Company (required)" });
|
||||||
await userEvent.clear(company);
|
await userEvent.clear(company);
|
||||||
await userEvent.type(company, "Draft Company Name");
|
await userEvent.type(company, "Draft Company Name");
|
||||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||||
|
|
@ -362,7 +362,9 @@ describe("VendorsListPage", () => {
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
expect(screen.getByRole("textbox", { name: "Company (required)" })).toHaveValue(
|
||||||
|
"Gateway Plumbing",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens inline company edit when the row has no vendor id", async () => {
|
it("opens inline company edit when the row has no vendor id", async () => {
|
||||||
|
|
@ -385,6 +387,8 @@ describe("VendorsListPage", () => {
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
expect(screen.getByRole("textbox", { name: "Company (required)" })).toHaveValue(
|
||||||
|
"Gateway Plumbing",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,60 @@ describe("vendorCompanyRosterApi", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws a duplicate conflict for the stable create response", async () => {
|
||||||
|
apiPost.mockRejectedValueOnce(
|
||||||
|
httpError(409, {
|
||||||
|
code: "duplicate_vendor_company_name",
|
||||||
|
message: "Another vendor company already uses that name.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toSatisfy(
|
||||||
|
(error: unknown) => {
|
||||||
|
if (!isVendorRosterConflictError(error)) return false;
|
||||||
|
return (
|
||||||
|
error.conflict.kind === "duplicate" &&
|
||||||
|
error.conflict.message === "Another vendor company already uses that name."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a duplicate 409 from parsed error.data when the response body is consumed", async () => {
|
||||||
|
const conflictError = new Error("HTTPError") as Error & {
|
||||||
|
__kyHTTPError: true;
|
||||||
|
data: unknown;
|
||||||
|
response: { status: number; json: () => Promise<unknown>; text: () => Promise<string> };
|
||||||
|
};
|
||||||
|
conflictError.__kyHTTPError = true;
|
||||||
|
conflictError.data = {
|
||||||
|
code: "duplicate_vendor_company_name",
|
||||||
|
message: "Another vendor company already uses that name.",
|
||||||
|
};
|
||||||
|
const consumedBody = () => {
|
||||||
|
const rejection = Promise.reject(new TypeError("Body has already been consumed"));
|
||||||
|
rejection.catch(() => {});
|
||||||
|
return rejection;
|
||||||
|
};
|
||||||
|
conflictError.response = {
|
||||||
|
status: 409,
|
||||||
|
json: () => consumedBody(),
|
||||||
|
text: () => consumedBody(),
|
||||||
|
};
|
||||||
|
|
||||||
|
apiPost.mockRejectedValueOnce(conflictError);
|
||||||
|
|
||||||
|
await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toSatisfy(
|
||||||
|
(error: unknown) => {
|
||||||
|
if (!isVendorRosterConflictError(error)) return false;
|
||||||
|
return (
|
||||||
|
error.conflict.kind === "duplicate" &&
|
||||||
|
error.conflict.message === "Another vendor company already uses that name."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rethrows non-conflict errors untouched", async () => {
|
it("rethrows non-conflict errors untouched", async () => {
|
||||||
const generic = new Error("boom");
|
const generic = new Error("boom");
|
||||||
apiPost.mockRejectedValueOnce(generic);
|
apiPost.mockRejectedValueOnce(generic);
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,20 @@ describe("vendor roster mapper", () => {
|
||||||
expect(conflict.message).toBe("stale rowversion");
|
expect(conflict.message).toBe("stale rowversion");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies the stable duplicate-company response", () => {
|
||||||
|
const conflict = mapRosterConflict(
|
||||||
|
{
|
||||||
|
code: "duplicate_vendor_company_name",
|
||||||
|
message: "Another vendor company already uses that name.",
|
||||||
|
},
|
||||||
|
"fallback",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(conflict.kind).toBe("duplicate");
|
||||||
|
expect(conflict.message).toBe("Another vendor company already uses that name.");
|
||||||
|
expect(conflict.blockedWorkOrders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves an explicit Phone/Email/Text preferred contact on read", () => {
|
it("preserves an explicit Phone/Email/Text preferred contact on read", () => {
|
||||||
expect(
|
expect(
|
||||||
mapRosterTechnician({ contactName: "A", preferredContact: "Phone" }).preferredContact,
|
mapRosterTechnician({ contactName: "A", preferredContact: "Phone" }).preferredContact,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
|
||||||
emptyRosterTechnician,
|
emptyRosterTechnician,
|
||||||
emptyVendorCompanyRosterForm,
|
emptyVendorCompanyRosterForm,
|
||||||
isAbsoluteHttpsUrl,
|
isAbsoluteHttpsUrl,
|
||||||
|
|
@ -30,12 +31,29 @@ describe("vendorCompanyRosterSchema", () => {
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(
|
expect(
|
||||||
result.error.issues.some(
|
result.error.issues.some(
|
||||||
(issue) => issue.message === "Provide a company phone or email (at least one required)",
|
(issue) => issue.message === VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports the phone-or-email rule at the companyContact path, not on companyPhone", () => {
|
||||||
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
|
...emptyVendorCompanyRosterForm,
|
||||||
|
name: "Solo Co",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
const issue = result.error.issues.find(
|
||||||
|
(candidate) => candidate.message === VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE,
|
||||||
|
);
|
||||||
|
expect(issue?.path).toEqual(["companyContact"]);
|
||||||
|
expect(result.error.issues.some((candidate) => candidate.path.includes("companyPhone"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts a company with zero technicians when phone is present", () => {
|
it("accepts a company with zero technicians when phone is present", () => {
|
||||||
const result = vendorCompanyRosterSchema.safeParse({
|
const result = vendorCompanyRosterSchema.safeParse({
|
||||||
...emptyVendorCompanyRosterForm,
|
...emptyVendorCompanyRosterForm,
|
||||||
|
|
@ -43,6 +61,7 @@ describe("vendorCompanyRosterSchema", () => {
|
||||||
companyPhone: "(314) 555-0100",
|
companyPhone: "(314) 555-0100",
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data).not.toHaveProperty("companyContact");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a company with email only and multiple technicians", () => {
|
it("accepts a company with email only and multiple technicians", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue