mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
fix(vendors): correct roster validation and editing
This commit is contained in:
parent
19a54e38c6
commit
35bfb9b7c8
10 changed files with 206 additions and 10 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 |
|
|
@ -211,7 +211,7 @@ function DrawerBody({
|
|||
<Divider />
|
||||
<Box>
|
||||
<SectionTitle>Notes</SectionTitle>
|
||||
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
<Text variant="description" sx={{ overflowWrap: "anywhere", whiteSpace: "pre-wrap" }}>
|
||||
{roster.notes}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -341,7 +341,7 @@ function DrawerEditor({
|
|||
<Button onClick={onClose} disabled={form.isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
||||
<Button type="submit" variant="contained" disabled={form.isSaving}>
|
||||
{form.isSaving ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -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 DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import {
|
||||
|
|
@ -16,8 +22,11 @@ import {
|
|||
} from "@mui/material";
|
||||
import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field";
|
||||
import { formatNorthAmericanPhone } from "@/lib/format/na-phone";
|
||||
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import {
|
||||
VENDOR_NOTES_MAX_LENGTH,
|
||||
type RosterTechnicianValues,
|
||||
type VendorCompanyRosterFormValues,
|
||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||
|
||||
interface VendorRosterFormFieldsProps {
|
||||
|
|
@ -121,6 +130,8 @@ function CompanyFields({
|
|||
onSelectCompany,
|
||||
onClearSelectedCompany,
|
||||
}: VendorRosterFormFieldsProps) {
|
||||
const notes = useWatch({ control, name: "notes" }) ?? "";
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack spacing={2}>
|
||||
|
|
@ -222,6 +233,19 @@ function CompanyFields({
|
|||
multiline
|
||||
minRows={2}
|
||||
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",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
||||
<Button type="submit" variant="contained" disabled={form.isSaving}>
|
||||
{submitLabel(form.isSaving, isEdit)}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ const northAmericanPhone = z
|
|||
|
||||
const optionalEmail = z.union([z.string().email("Invalid email"), z.literal("")]);
|
||||
|
||||
export const VENDOR_NOTES_MAX_LENGTH = 500;
|
||||
|
||||
export const rosterTechnicianSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).optional(),
|
||||
contactName: z.string(),
|
||||
|
|
@ -56,7 +58,9 @@ const baseCompanyFields = {
|
|||
state: z.string(),
|
||||
zip: z.string(),
|
||||
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),
|
||||
};
|
||||
|
||||
|
|
@ -67,7 +71,7 @@ export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine
|
|||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["companyPhone"],
|
||||
message: "Company phone or email is required",
|
||||
message: "Provide a company phone or email (at least one required)",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
rosterGet.mockReset();
|
||||
|
|
|
|||
52
src/test/app/(protected)/vendors/vendor-create-modal.test.tsx
vendored
Normal file
52
src/test/app/(protected)/vendors/vendor-create-modal.test.tsx
vendored
Normal 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/),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -51,6 +51,48 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
|
|||
}
|
||||
|
||||
describe("VendorDetailDrawer selected-technician display", () => {
|
||||
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", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([
|
||||
|
|
@ -137,6 +179,38 @@ describe("VendorDetailDrawer selected-technician display", () => {
|
|||
});
|
||||
|
||||
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", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(rosterWith([]));
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
emptyRosterTechnician,
|
||||
emptyVendorCompanyRosterForm,
|
||||
isAbsoluteHttpsUrl,
|
||||
VENDOR_NOTES_MAX_LENGTH,
|
||||
vendorCompanyRosterSchema,
|
||||
vendorCompanyRosterUpdateSchema,
|
||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
|
|
@ -28,7 +29,9 @@ describe("vendorCompanyRosterSchema", () => {
|
|||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
|
@ -54,6 +57,20 @@ describe("vendorCompanyRosterSchema", () => {
|
|||
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", () => {
|
||||
const result = vendorCompanyRosterSchema.safeParse({
|
||||
...baseCompany,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue