mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
2 commits
35bfb9b7c8
...
f0f3485bee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f3485bee | ||
|
|
146491fafb |
5 changed files with 158 additions and 43 deletions
|
|
@ -97,6 +97,7 @@ export interface VendorRosterForm {
|
|||
selectedCompanyId: string | number | null;
|
||||
selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
|
||||
clearSelectedCompany: (nextName?: string) => void;
|
||||
setTechnicianActive: (index: number, isActive: boolean) => void;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +134,7 @@ export function useVendorRosterForm({
|
|||
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
|
||||
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
|
||||
// render. Reading dirtyFields inside the submit callback would not subscribe and would come
|
||||
// back empty, so it is resolved here on every render.
|
||||
|
|
@ -163,6 +164,16 @@ export function useVendorRosterForm({
|
|||
clearConflict();
|
||||
}, [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) => {
|
||||
setConflict(null);
|
||||
const values = withoutBlankNewTechnicians(formValues);
|
||||
|
|
@ -228,6 +239,7 @@ export function useVendorRosterForm({
|
|||
selectedCompanyId: selection.selectedCompanyId,
|
||||
selectCompany: selection.selectCompany,
|
||||
clearSelectedCompany: selection.clearSelectedCompany,
|
||||
setTechnicianActive,
|
||||
resetForm,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,19 +8,35 @@ import {
|
|||
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||
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(
|
||||
routeRoster: VendorCompanyRoster | undefined,
|
||||
): Resolver<VendorCompanyRosterFormValues> {
|
||||
const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []);
|
||||
return useMemo<Resolver<VendorCompanyRosterFormValues>>(
|
||||
() => async (values, context, options) => {
|
||||
if (
|
||||
routeRoster &&
|
||||
getSingleStatusOnlyChange(routeRoster, values as VendorCompanyRosterFormValues)
|
||||
) {
|
||||
return { values, errors: {} };
|
||||
const normalizedValues = withoutTechnicianStubs(values);
|
||||
if (routeRoster && getSingleStatusOnlyChange(routeRoster, normalizedValues)) {
|
||||
return { values: normalizedValues, errors: {} };
|
||||
}
|
||||
return strictResolver(values, context, options);
|
||||
return strictResolver(normalizedValues, context, options);
|
||||
},
|
||||
[routeRoster, strictResolver],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
|
|
@ -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({
|
||||
vendor,
|
||||
onClose,
|
||||
|
|
@ -312,22 +338,11 @@ function DrawerEditor({
|
|||
Total Jobs
|
||||
</Text>
|
||||
</Box>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={`technicians.${selectedIndex}.isActive`}
|
||||
render={({ field }) => (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<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>
|
||||
)}
|
||||
<TechnicianStatusControl
|
||||
isActive={Boolean(technicians[selectedIndex]?.isActive)}
|
||||
persistedIsActive={persistedIsActive}
|
||||
onRequestDeactivation={() => onRequestDeactivation(vendor)}
|
||||
onChange={(checked) => form.setTechnicianActive(selectedIndex, checked)}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -348,23 +348,22 @@ function TechnicianRow({
|
|||
tradeOptions={tradeOptions}
|
||||
tradeOptionsLoading={tradeOptionsLoading}
|
||||
/>
|
||||
{showStatus && (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`technicians.${index}.isActive`}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={(_event, checked) => field.onChange(checked)}
|
||||
/>
|
||||
}
|
||||
label={field.value ? "Active" : "Inactive"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name={`technicians.${index}.isActive`}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
sx={{ display: showStatus ? undefined : "none" }}
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(field.value)}
|
||||
onChange={(_event, checked) => field.onChange(checked)}
|
||||
/>
|
||||
}
|
||||
label={field.value ? "Active" : "Inactive"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { 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 { renderWithProviders } from "@/test/test-utils";
|
||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const useVendorCompanyRoster = vi.fn();
|
||||
const saveMutate = vi.fn();
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
|
||||
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 = {
|
||||
id: 1,
|
||||
companyId: "co-1",
|
||||
|
|
@ -50,7 +61,69 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
|
|||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveMutate.mockReset();
|
||||
});
|
||||
|
||||
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([
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue