mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 06:53:24 +00:00
Merge branch 'dev' into feat/sh-244-advanced-filter-fields
This commit is contained in:
commit
776d3ebd9c
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 |
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
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/),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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([]));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue