Merge branch 'dev' into feat/sh-244-advanced-filter-fields

This commit is contained in:
Arthur Bassi 2026-08-20 22:07:10 -03:00 • committed by GitHub
commit 776d3ebd9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 364 additions and 53 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View file

@ -97,6 +97,7 @@ export interface VendorRosterForm {
selectedCompanyId: string | number | null; selectedCompanyId: string | number | null;
selectCompany: (company: VendorFacetCompany | null) => Promise<void>; selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
clearSelectedCompany: (nextName?: string) => void; clearSelectedCompany: (nextName?: string) => void;
setTechnicianActive: (index: number, isActive: boolean) => void;
resetForm: () => void; resetForm: () => void;
} }
@ -133,7 +134,7 @@ export function useVendorRosterForm({
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
mode: "onChange", mode: "onChange",
}); });
const { control, handleSubmit, reset, formState } = form; const { control, handleSubmit, reset, setValue, formState } = form;
// react-hook-form exposes formState through a Proxy that only tracks properties read during // react-hook-form exposes formState through a Proxy that only tracks properties read during
// render. Reading dirtyFields inside the submit callback would not subscribe and would come // render. Reading dirtyFields inside the submit callback would not subscribe and would come
// back empty, so it is resolved here on every render. // back empty, so it is resolved here on every render.
@ -163,6 +164,16 @@ export function useVendorRosterForm({
clearConflict(); clearConflict();
}, [clearConflict, createDefaults, reset, resetSelection]); }, [clearConflict, createDefaults, reset, resetSelection]);
const setTechnicianActive = useCallback(
(index: number, isActive: boolean) => {
setValue(`technicians.${index}.isActive`, isActive, {
shouldDirty: true,
shouldValidate: true,
});
},
[setValue],
);
const submit = (formValues: VendorCompanyRosterFormValues) => { const submit = (formValues: VendorCompanyRosterFormValues) => {
setConflict(null); setConflict(null);
const values = withoutBlankNewTechnicians(formValues); const values = withoutBlankNewTechnicians(formValues);
@ -228,6 +239,7 @@ export function useVendorRosterForm({
selectedCompanyId: selection.selectedCompanyId, selectedCompanyId: selection.selectedCompanyId,
selectCompany: selection.selectCompany, selectCompany: selection.selectCompany,
clearSelectedCompany: selection.clearSelectedCompany, clearSelectedCompany: selection.clearSelectedCompany,
setTechnicianActive,
resetForm, resetForm,
}; };
} }

View file

@ -8,19 +8,35 @@ import {
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
function withoutTechnicianStubs(
values: VendorCompanyRosterFormValues,
): VendorCompanyRosterFormValues {
return {
...values,
technicians: values.technicians.filter((technician) => {
if (technician === null || typeof technician !== "object" || Array.isArray(technician)) {
return true;
}
const candidate = technician as unknown as Record<string, unknown>;
const keys = Object.keys(candidate);
const isGeneratedStatusStub =
keys.length === 1 && keys[0] === "isActive" && typeof candidate.isActive === "boolean";
return !isGeneratedStatusStub;
}),
};
}
export function useVendorRosterResolver( export function useVendorRosterResolver(
routeRoster: VendorCompanyRoster | undefined, routeRoster: VendorCompanyRoster | undefined,
): Resolver<VendorCompanyRosterFormValues> { ): Resolver<VendorCompanyRosterFormValues> {
const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []); const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []);
return useMemo<Resolver<VendorCompanyRosterFormValues>>( return useMemo<Resolver<VendorCompanyRosterFormValues>>(
() => async (values, context, options) => { () => async (values, context, options) => {
if ( const normalizedValues = withoutTechnicianStubs(values);
routeRoster && if (routeRoster && getSingleStatusOnlyChange(routeRoster, normalizedValues)) {
getSingleStatusOnlyChange(routeRoster, values as VendorCompanyRosterFormValues) return { values: normalizedValues, errors: {} };
) {
return { values, errors: {} };
} }
return strictResolver(values, context, options); return strictResolver(normalizedValues, context, options);
}, },
[routeRoster, strictResolver], [routeRoster, strictResolver],
); );

View file

@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Controller, useWatch } from "react-hook-form"; import { useWatch } from "react-hook-form";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch"; import LaunchIcon from "@mui/icons-material/Launch";
@ -211,7 +211,7 @@ function DrawerBody({
<Divider /> <Divider />
<Box> <Box>
<SectionTitle>Notes</SectionTitle> <SectionTitle>Notes</SectionTitle>
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}> <Text variant="description" sx={{ overflowWrap: "anywhere", whiteSpace: "pre-wrap" }}>
{roster.notes} {roster.notes}
</Text> </Text>
</Box> </Box>
@ -240,6 +240,32 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
); );
} }
function TechnicianStatusControl({
isActive,
persistedIsActive,
onRequestDeactivation,
onChange,
}: {
isActive: boolean;
persistedIsActive: boolean;
onRequestDeactivation: () => void;
onChange: (checked: boolean) => void;
}) {
return (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
<Switch
checked={isActive}
slotProps={{ input: { "aria-label": "Active status" } }}
onChange={(_event, checked) => {
if (persistedIsActive && !checked) onRequestDeactivation();
else onChange(checked);
}}
/>
</Stack>
);
}
function DrawerEditor({ function DrawerEditor({
vendor, vendor,
onClose, onClose,
@ -312,22 +338,11 @@ function DrawerEditor({
Total Jobs Total Jobs
</Text> </Text>
</Box> </Box>
<Controller <TechnicianStatusControl
control={form.control} isActive={Boolean(technicians[selectedIndex]?.isActive)}
name={`technicians.${selectedIndex}.isActive`} persistedIsActive={persistedIsActive}
render={({ field }) => ( onRequestDeactivation={() => onRequestDeactivation(vendor)}
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}> onChange={(checked) => form.setTechnicianActive(selectedIndex, checked)}
<Text variant="description">{field.value ? "Active" : "Inactive"}</Text>
<Switch
checked={Boolean(field.value)}
slotProps={{ input: { "aria-label": "Active status" } }}
onChange={(_event, checked) => {
if (persistedIsActive && !checked) onRequestDeactivation(vendor);
else field.onChange(checked);
}}
/>
</Stack>
)}
/> />
</Stack> </Stack>
</> </>
@ -341,7 +356,7 @@ function DrawerEditor({
<Button onClick={onClose} disabled={form.isSaving}> <Button onClick={onClose} disabled={form.isSaving}>
Cancel Cancel
</Button> </Button>
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}> <Button type="submit" variant="contained" disabled={form.isSaving}>
{form.isSaving ? "Saving..." : "Save changes"} {form.isSaving ? "Saving..." : "Save changes"}
</Button> </Button>
</Stack> </Stack>

View file

@ -1,4 +1,10 @@
import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form"; import {
Controller,
useFieldArray,
useWatch,
type Control,
type FieldErrors,
} from "react-hook-form";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import { import {
@ -16,8 +22,11 @@ import {
} from "@mui/material"; } from "@mui/material";
import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field"; import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field";
import { formatNorthAmericanPhone } from "@/lib/format/na-phone"; import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; import {
import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema"; VENDOR_NOTES_MAX_LENGTH,
type RosterTechnicianValues,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
interface VendorRosterFormFieldsProps { interface VendorRosterFormFieldsProps {
@ -121,6 +130,8 @@ function CompanyFields({
onSelectCompany, onSelectCompany,
onClearSelectedCompany, onClearSelectedCompany,
}: VendorRosterFormFieldsProps) { }: VendorRosterFormFieldsProps) {
const notes = useWatch({ control, name: "notes" }) ?? "";
return ( return (
<Box> <Box>
<Stack spacing={2}> <Stack spacing={2}>
@ -222,6 +233,19 @@ function CompanyFields({
multiline multiline
minRows={2} minRows={2}
fullWidth fullWidth
error={Boolean(errors.notes)}
helperText={`${notes.length}/${VENDOR_NOTES_MAX_LENGTH} characters · ${
errors.notes?.message ?? `Maximum ${VENDOR_NOTES_MAX_LENGTH} characters`
}`}
slotProps={{
htmlInput: {
maxLength: VENDOR_NOTES_MAX_LENGTH,
style: {
overflowWrap: "anywhere",
whiteSpace: "pre-wrap",
},
},
}}
/> />
)} )}
/> />
@ -324,23 +348,22 @@ function TechnicianRow({
tradeOptions={tradeOptions} tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading} tradeOptionsLoading={tradeOptionsLoading}
/> />
{showStatus && ( <Controller
<Controller control={control}
control={control} name={`technicians.${index}.isActive`}
name={`technicians.${index}.isActive`} render={({ field }) => (
render={({ field }) => ( <FormControlLabel
<FormControlLabel sx={{ display: showStatus ? undefined : "none" }}
control={ control={
<Switch <Switch
checked={field.value} checked={Boolean(field.value)}
onChange={(_event, checked) => field.onChange(checked)} onChange={(_event, checked) => field.onChange(checked)}
/> />
} }
label={field.value ? "Active" : "Inactive"} label={field.value ? "Active" : "Inactive"}
/> />
)} )}
/> />
)}
</Stack> </Stack>
); );
} }

View file

@ -140,7 +140,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}> <Button type="submit" variant="contained" disabled={form.isSaving}>
{submitLabel(form.isSaving, isEdit)} {submitLabel(form.isSaving, isEdit)}
</Button> </Button>
</Stack> </Stack>

View file

@ -35,6 +35,8 @@ const northAmericanPhone = z
const optionalEmail = z.union([z.string().email("Invalid email"), z.literal("")]); const optionalEmail = z.union([z.string().email("Invalid email"), z.literal("")]);
export const VENDOR_NOTES_MAX_LENGTH = 500;
export const rosterTechnicianSchema = z.object({ export const rosterTechnicianSchema = z.object({
id: z.union([z.string(), z.number()]).optional(), id: z.union([z.string(), z.number()]).optional(),
contactName: z.string(), contactName: z.string(),
@ -56,7 +58,9 @@ const baseCompanyFields = {
state: z.string(), state: z.string(),
zip: z.string(), zip: z.string(),
googleMapsUrl: httpsUrl, googleMapsUrl: httpsUrl,
notes: z.string(), notes: z
.string()
.max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
technicians: z.array(rosterTechnicianSchema), technicians: z.array(rosterTechnicianSchema),
}; };
@ -67,7 +71,7 @@ export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["companyPhone"], path: ["companyPhone"],
message: "Company phone or email is required", message: "Provide a company phone or email (at least one required)",
}); });
} }
}); });

View file

@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useController } from "react-hook-form"; import { useController, useFieldArray } from "react-hook-form";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const rosterGet = vi.fn(); const rosterGet = vi.fn();
@ -321,6 +321,31 @@ describe("useVendorRosterForm prototype defaults", () => {
}); });
}); });
describe("useVendorRosterForm technician removal", () => {
it("keeps a valid roster saveable after an existing technician is removed", async () => {
rosterQueryResult = {
data: rosterWithTechnicians,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
};
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "update", companyId: 5 });
const technicians = useFieldArray({ control: form.control, name: "technicians" });
return { form, technicians };
},
{ wrapper: makeWrapper(createClient()) },
);
await waitFor(() => expect(result.current.form.isValid).toBe(true));
act(() => result.current.technicians.remove(1));
await waitFor(() => expect(result.current.form.isValid).toBe(true));
});
});
describe("useVendorRosterForm stale-selection handling", () => { describe("useVendorRosterForm stale-selection handling", () => {
beforeEach(() => { beforeEach(() => {
rosterGet.mockReset(); rosterGet.mockReset();

View file

@ -0,0 +1,52 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({ data: { companies: [], trades: [] }, isLoading: false }),
}));
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
const actual = await vi.importActual<
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
>("@/domain/vendors/use-cases/use-save-vendor-company-roster");
return {
...actual,
useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }),
};
});
describe("VendorCreateModal validation", () => {
it("explains the company phone-or-email requirement after submission", async () => {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
fireEvent.change(screen.getByRole("combobox", { name: /Company/ }), {
target: { value: "Gateway Plumbing" },
});
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(
await screen.findByText("Provide a company phone or email (at least one required)"),
).toBeInTheDocument();
});
it("shows the notes limit and live character counter", async () => {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
const notes = screen.getByRole("textbox", { name: "Notes (optional)" });
fireEvent.change(notes, { target: { value: "abc" } });
expect(notes).toHaveAttribute("maxlength", "500");
await waitFor(() =>
expect(notes).toHaveAccessibleDescription(/3\/500 characters · Maximum 500 characters/),
);
});
});

View file

@ -1,16 +1,27 @@
import { screen } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { VendorDetailDrawer } from "@/app/(protected)/vendors/_components/vendor-detail-drawer"; import { VendorDetailDrawer } from "@/app/(protected)/vendors/_components/vendor-detail-drawer";
import { renderWithProviders } from "@/test/test-utils"; import { renderWithProviders } from "@/test/test-utils";
import type { VendorListItem } from "@/domain/vendors/types/vendor"; import type { VendorListItem } from "@/domain/vendors/types/vendor";
const useVendorCompanyRoster = vi.fn(); const useVendorCompanyRoster = vi.fn();
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args), useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args),
})); }));
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
const actual = await vi.importActual<
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
>("@/domain/vendors/use-cases/use-save-vendor-company-roster");
return {
...actual,
useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }),
};
});
const vendor: VendorListItem = { const vendor: VendorListItem = {
id: 1, id: 1,
companyId: "co-1", companyId: "co-1",
@ -50,7 +61,111 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
}; };
} }
beforeEach(() => {
saveMutate.mockReset();
});
describe("VendorDetailDrawer selected-technician display", () => { describe("VendorDetailDrawer selected-technician display", () => {
it.each([
{ removedPosition: 1, remainingId: 2, remainingName: "Beth" },
{ removedPosition: 2, remainingId: 1, remainingName: "Adam" },
])(
"reconciles the remaining roster after removing technician $removedPosition",
async ({ removedPosition, remainingId, remainingName }) => {
useVendorCompanyRoster.mockReturnValue(
rosterWith([
{
id: 1,
contactName: "Adam",
phone: "314-555-0198",
email: "",
tradeSpecialties: "",
isActive: removedPosition === 1,
totalJobs: 5,
},
{
id: 2,
contactName: "Beth",
phone: "314-555-0199",
email: "",
tradeSpecialties: "",
isActive: removedPosition === 2,
totalJobs: 2,
},
]),
);
renderWithProviders(
<VendorDetailDrawer open={true} vendor={vendor} startInEdit={true} onClose={vi.fn()} />,
{ route: "/vendors", withAuth: false },
);
await userEvent.click(
screen.getByRole("button", { name: `Remove technician ${removedPosition}` }),
);
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1));
expect(saveMutate).toHaveBeenCalledWith(
expect.objectContaining({
mode: "update",
values: expect.objectContaining({
technicians: [
expect.objectContaining({
id: remainingId,
contactName: remainingName,
isActive: false,
}),
],
}),
}),
expect.any(Object),
);
},
);
it("keeps Save changes available after removing a technician from a legacy roster", async () => {
const roster = rosterWith([
{
id: 1,
contactName: "Adam",
phone: "314-555-0198",
email: "",
tradeSpecialties: "",
isActive: true,
totalJobs: 5,
},
{
id: 2,
contactName: "Beth",
phone: "314-555-0199",
email: "",
tradeSpecialties: "",
isActive: true,
totalJobs: 2,
},
]);
roster.data.companyPhone = "";
roster.data.email = "";
useVendorCompanyRoster.mockReturnValue(roster);
renderWithProviders(
<VendorDetailDrawer open={true} vendor={vendor} startInEdit={true} onClose={vi.fn()} />,
{ route: "/vendors", withAuth: false },
);
await userEvent.click(screen.getByRole("button", { name: "Remove technician 2" }));
const save = screen.getByRole("button", { name: "Save changes" });
expect(save).toBeEnabled();
await userEvent.click(save);
expect(
await screen.findByText("Provide a company phone or email (at least one required)"),
).toBeInTheDocument();
});
it("renders no preference label when preferredContact is absent", () => { it("renders no preference label when preferredContact is absent", () => {
useVendorCompanyRoster.mockReturnValue( useVendorCompanyRoster.mockReturnValue(
rosterWith([ rosterWith([
@ -137,6 +252,38 @@ describe("VendorDetailDrawer selected-technician display", () => {
}); });
describe("VendorDetailDrawer design parity", () => { describe("VendorDetailDrawer design parity", () => {
it("wraps long unbroken company notes inside the drawer", () => {
const roster = rosterWith([]);
roster.data.notes = "x".repeat(5000);
useVendorCompanyRoster.mockReturnValue(roster);
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
const notes = screen.getByText("x".repeat(5000));
expect(notes).toHaveStyle({ overflowWrap: "anywhere", whiteSpace: "pre-wrap" });
});
it("preserves an oversized legacy note in edit mode while showing the new limit", async () => {
const roster = rosterWith([]);
const legacyNotes = "x".repeat(501);
roster.data.notes = legacyNotes;
useVendorCompanyRoster.mockReturnValue(roster);
renderWithProviders(
<VendorDetailDrawer open={true} vendor={vendor} startInEdit={true} onClose={vi.fn()} />,
{ route: "/vendors", withAuth: false },
);
const notes = await screen.findByRole("textbox", { name: "Notes (optional)" });
expect(notes).toHaveValue(legacyNotes);
expect(notes).toHaveAttribute("maxlength", "500");
expect(notes).toHaveStyle({ overflowWrap: "anywhere", whiteSpace: "pre-wrap" });
expect(screen.getByText(/501\/500 characters/)).toBeInTheDocument();
});
it("renders the company once when there is no technician name to head the panel", () => { it("renders the company once when there is no technician name to head the panel", () => {
useVendorCompanyRoster.mockReturnValue(rosterWith([])); useVendorCompanyRoster.mockReturnValue(rosterWith([]));

View file

@ -3,6 +3,7 @@ import {
emptyRosterTechnician, emptyRosterTechnician,
emptyVendorCompanyRosterForm, emptyVendorCompanyRosterForm,
isAbsoluteHttpsUrl, isAbsoluteHttpsUrl,
VENDOR_NOTES_MAX_LENGTH,
vendorCompanyRosterSchema, vendorCompanyRosterSchema,
vendorCompanyRosterUpdateSchema, vendorCompanyRosterUpdateSchema,
} from "@/domain/vendors/schemas/vendor-roster-schema"; } from "@/domain/vendors/schemas/vendor-roster-schema";
@ -28,7 +29,9 @@ describe("vendorCompanyRosterSchema", () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
if (!result.success) { if (!result.success) {
expect( expect(
result.error.issues.some((issue) => issue.message === "Company phone or email is required"), result.error.issues.some(
(issue) => issue.message === "Provide a company phone or email (at least one required)",
),
).toBe(true); ).toBe(true);
} }
}); });
@ -54,6 +57,20 @@ describe("vendorCompanyRosterSchema", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("accepts exactly 500 note characters and rejects 501", () => {
const atLimit = vendorCompanyRosterSchema.safeParse({
...baseCompany,
notes: "x".repeat(VENDOR_NOTES_MAX_LENGTH),
});
const overLimit = vendorCompanyRosterSchema.safeParse({
...baseCompany,
notes: "x".repeat(VENDOR_NOTES_MAX_LENGTH + 1),
});
expect(atLimit.success).toBe(true);
expect(overLimit.success).toBe(false);
});
it("accepts a technician row without a name or phone", () => { it("accepts a technician row without a name or phone", () => {
const result = vendorCompanyRosterSchema.safeParse({ const result = vendorCompanyRosterSchema.safeParse({
...baseCompany, ...baseCompany,