fix(workorders): guard service-linked type changes

This commit is contained in:
Alexandre Brandizzi 2026-09-30 19:00:55 -03:00
parent d40d4eaffb
commit 48fc415d54
20 changed files with 504 additions and 57 deletions

View file

@ -37,6 +37,7 @@ type SlideOverInfoTabEditViewProps = {
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
@ -83,6 +84,50 @@ function SlideOverServiceField({
);
}
type SlideOverTypeFieldProps = Pick<
SlideOverInfoTabEditViewProps,
| "draft"
| "services"
| "serviceHistory"
| "serviceHistoryReady"
| "serviceHistoryQuery"
| "onDraftChange"
>;
function SlideOverTypeField({
draft,
services = [],
serviceHistory,
serviceHistoryReady = true,
serviceHistoryQuery,
onDraftChange,
}: SlideOverTypeFieldProps) {
const typeServices = serviceHistory ?? services;
const linkedService = typeServices.find(
(service) => String(service.id) === String(draft.serviceId),
);
const registryUnavailable = draft.serviceId != null && (!serviceHistoryReady || !linkedService);
return (
<TypeCell
value={draft.type}
severity={draft.severity}
registryUnavailable={registryUnavailable}
serviceHistoryQuery={serviceHistoryQuery}
onSave={(type, severity) => {
if (registryUnavailable) return;
onDraftChange(
draft.serviceId != null &&
linkedService != null &&
!serviceSupportsWorkOrderType(linkedService, type)
? { type, severity, serviceId: null, pm: "" }
: { type, severity },
);
}}
/>
);
}
function SlideOverScheduleOnField({
draft,
onDraftChange,
@ -164,6 +209,7 @@ export function SlideOverInfoTabEditView({
services = [],
serviceHistory,
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
allOrders,
onDraftChange,
@ -213,22 +259,13 @@ export function SlideOverInfoTabEditView({
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
<TypeCell
value={draft.type}
severity={draft.severity}
onSave={(type, severity) => {
const typeServices = serviceHistory ?? services;
const selectedService = serviceHistoryReady
? typeServices.find((service) => String(service.id) === String(draft.serviceId))
: undefined;
onDraftChange(
draft.serviceId != null &&
selectedService != null &&
!serviceSupportsWorkOrderType(selectedService, type)
? { type, severity, serviceId: null, pm: "" }
: { type, severity },
);
}}
<SlideOverTypeField
draft={draft}
services={services}
serviceHistory={serviceHistory}
serviceHistoryReady={serviceHistoryReady}
serviceHistoryQuery={serviceHistoryQuery}
onDraftChange={onDraftChange}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">

View file

@ -22,6 +22,7 @@ type SlideOverInfoTabProps = {
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
@ -42,6 +43,7 @@ export function SlideOverInfoTab({
services = [],
serviceHistory,
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
allOrders,
onDraftChange,
@ -63,6 +65,7 @@ export function SlideOverInfoTab({
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryQuery={serviceHistoryQuery}
serviceHistoryReady={serviceHistoryReady}
allOrders={allOrders}
onDraftChange={onDraftChange}

View file

@ -30,6 +30,7 @@ type WorkOrderSlideOverTabsProps = {
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
allOrders: WorkOrderTableRow[];
commentHighlight?: CommentHighlight | null;
@ -166,6 +167,7 @@ export function WorkOrderSlideOverTabs({
services = [],
serviceHistory,
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
allOrders,
commentHighlight,
@ -191,6 +193,7 @@ export function WorkOrderSlideOverTabs({
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryQuery={serviceHistoryQuery}
serviceHistoryReady={serviceHistoryReady}
allOrders={allOrders}
onDraftChange={state.handleDraftChange}

View file

@ -33,6 +33,7 @@ type WorkOrderSlideOverProps = {
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
allOrders: WorkOrderTableRow[];
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
@ -59,6 +60,7 @@ export function WorkOrderSlideOver({
services = [],
serviceHistory,
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
allOrders,
onSave,
@ -114,6 +116,7 @@ export function WorkOrderSlideOver({
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryQuery={serviceHistoryQuery}
serviceHistoryReady={serviceHistoryReady}
allOrders={allOrders}
commentHighlight={commentHighlight}

View file

@ -131,6 +131,7 @@ function PMTypeCellDialog({
pmTypes={props.pmTypes}
services={model.assignableServices}
serviceId={props.serviceId}
serviceQuery={props.serviceQuery}
viewOnly={props.viewOnly ?? false}
onSave={props.onSaveDetails ?? (() => undefined)}
/>

View file

@ -5,6 +5,8 @@ import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_componen
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import { ServiceRegistryStatus } from "./service-registry-status";
export type ServiceNotesDetails = {
pm: string;
@ -25,6 +27,7 @@ type ServiceNotesDialogProps = {
pmTypes: string[];
services?: Service[];
serviceId?: string | number | null;
serviceQuery?: ServiceRegistryQueryState;
viewOnly?: boolean;
onSave: (details: ServiceNotesDetails) => void;
};
@ -40,6 +43,7 @@ export function ServiceNotesDialog({
pmTypes,
services,
serviceId,
serviceQuery,
viewOnly = false,
onSave,
}: ServiceNotesDialogProps) {
@ -71,11 +75,21 @@ export function ServiceNotesDialog({
]
: pmTypes.map((value) => ({ id: value, value }));
const saveServiceId = () => {
if (serviceOptionsUnavailable) return serviceId ?? undefined;
const selectedId = services?.find((service) => service.name === sel[0])?.id;
if (selectedId != null) return selectedId;
if (sel[0] === pm) return serviceId ?? undefined;
return serviceId == null ? undefined : null;
};
const registryManaged = services !== undefined;
const serviceOptionsUnavailable =
registryManaged && serviceQuery != null && !serviceQuery.hasData;
const serviceOptionsEmpty =
registryManaged &&
serviceQuery?.hasData === true &&
!serviceQuery.isLoading &&
!serviceQuery.isError &&
services.length === 0;
return (
<Dialog
@ -103,6 +117,11 @@ export function ServiceNotesDialog({
style={{ paddingTop: 8 }}
>
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
<ServiceRegistryStatus
query={serviceQuery}
empty={serviceOptionsEmpty}
emptyMessage="No services are available for this work-order type."
/>
<WizardServiceNotesFields
services={serviceOptions}
sel={sel}
@ -111,6 +130,7 @@ export function ServiceNotesDialog({
setNote={setNote}
search={search}
setSearch={setSearch}
serviceSelectionDisabled={serviceOptionsUnavailable}
avetaRequired={aveta}
onAvetaRequiredChange={setAveta}
/>

View file

@ -0,0 +1,48 @@
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
type ServiceRegistryStatusProps = {
query?: ServiceRegistryQueryState;
unavailable?: boolean;
empty?: boolean;
emptyMessage?: string;
};
export function ServiceRegistryStatus({
query,
unavailable = false,
empty = false,
emptyMessage = "No services are available.",
}: ServiceRegistryStatusProps) {
if (query?.isLoading && !query.hasData) {
return <div role="status" aria-label="Loading services">Loading services…</div>;
}
if (query?.isError) {
return (
<div role="alert" aria-label="Service options unavailable">
<QueryErrorPanel
compact
message="Service options couldn't load. Retry to load registry services."
onRetry={query.retry}
/>
</div>
);
}
if (query?.isFetching && query.hasData) {
return <div role="status" aria-label="Updating services">Updating services…</div>;
}
if (empty && query?.hasData) {
return <div role="status">{emptyMessage}</div>;
}
if (unavailable) {
return (
<div className="text-xs text-muted-foreground" role="status">
Service history is unavailable. Retry before changing the work-order type.
<button type="button" className="ml-1 underline" onClick={query?.retry}>
Retry
</button>
</div>
);
}
return null;
}

View file

@ -13,12 +13,16 @@ import { HoverChevron } from "./hover-chevron";
import { SeverityBadge } from "./severity-badge";
import { TypeSeverityModal } from "./type-severity-modal";
import { WoTableTypeLabel } from "./wo-table-type-label";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import { ServiceRegistryStatus } from "./service-registry-status";
type TypeCellProps = {
value: WOType | "";
severity?: WorkOrderSeverity | null;
q?: string;
viewOnly?: boolean;
registryUnavailable?: boolean;
serviceHistoryQuery?: ServiceRegistryQueryState;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
};
@ -29,7 +33,15 @@ function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
}
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
export function TypeCell({
value,
severity = null,
q,
viewOnly = false,
registryUnavailable = false,
serviceHistoryQuery,
onSave,
}: TypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
@ -65,6 +77,7 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
<>
<button
type="button"
disabled={registryUnavailable}
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
title={value ? undefined : "Select type of WO"}
onClick={(e) => {
@ -77,12 +90,16 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
{Boolean(showSeverityBadge && severity != null) && (
<SeverityBadge
severity={severity!}
onClick={(e) => {
e.stopPropagation();
setModalSeed(value);
setResetSeverity(false);
setModalOpen(true);
}}
onClick={
registryUnavailable
? undefined
: (e) => {
e.stopPropagation();
setModalSeed(value);
setResetSeverity(false);
setModalOpen(true);
}
}
/>
)}
<HoverChevron />
@ -109,6 +126,8 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
))}
</Popover>
{registryUnavailable && <ServiceRegistryStatus query={serviceHistoryQuery} unavailable />}
<TypeSeverityModal
open={modalOpen}
onOpenChange={setModalOpen}

View file

@ -34,10 +34,11 @@ export function saveExistingRowType({
severity,
serviceHistory = [],
serviceHistoryReady = true,
}: SaveExistingRowTypeOptions) {
}: SaveExistingRowTypeOptions): boolean {
const selectedService = serviceHistoryReady
? serviceHistory.find((service) => String(service.id) === String(row.serviceId))
: undefined;
if (row.serviceId != null && selectedService == null) return false;
const clearService =
row.serviceId != null &&
selectedService != null &&
@ -45,9 +46,10 @@ export function saveExistingRowType({
const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity };
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
onPatch(row.id, patch);
return;
return true;
}
onPatch(row.id, patch, {
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
});
return true;
}

View file

@ -18,6 +18,7 @@ import { TypeCell } from "./cells/type-cell";
import type { WoTableRowHandlers } from "./wo-table-row";
import { resolveDueDateFloor } from "@/domain/work-orders/utils/due-date-floor";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
type WoTableRowCellsProps = {
row: WorkOrderTableRow;
@ -27,6 +28,7 @@ type WoTableRowCellsProps = {
services?: Service[];
serviceHistory?: Service[];
serviceHistoryReady?: boolean;
serviceHistoryQuery?: ServiceRegistryQueryState;
sitePlace?: { city: string; state: string };
search?: string;
forceScheduleHighlight?: boolean;
@ -38,6 +40,62 @@ type WoTableRowCellsProps = {
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
};
type WoTableRowTypeCellProps = Pick<
WoTableRowCellsProps,
| "row"
| "services"
| "serviceHistory"
| "serviceHistoryReady"
| "serviceHistoryQuery"
| "search"
| "handlers"
| "mutationCellStyle"
>;
function WoTableRowTypeCell({
row,
services = [],
serviceHistory,
serviceHistoryReady = true,
serviceHistoryQuery,
search,
handlers,
mutationCellStyle,
}: WoTableRowTypeCellProps) {
const typeServices = serviceHistory ?? services;
const linkedServiceUnavailable =
row.serviceId != null &&
(!serviceHistoryReady ||
!typeServices.some((service) => String(service.id) === String(row.serviceId)));
return (
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(event) => event.stopPropagation()}
>
<TypeCell
value={row.type}
severity={row.severity}
q={search}
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
registryUnavailable={linkedServiceUnavailable}
serviceHistoryQuery={serviceHistoryQuery}
onSave={(type, severity) =>
saveExistingRowType({
onPatch: handlers.onPatch,
row,
type,
severity,
serviceHistory: typeServices,
serviceHistoryReady,
})
}
/>
</td>
);
}
export function WoTableRowCells({
row,
allRows,
@ -46,6 +104,7 @@ export function WoTableRowCells({
services = [],
serviceHistory,
serviceHistoryReady = true,
serviceHistoryQuery,
sitePlace,
search,
forceScheduleHighlight,
@ -116,28 +175,16 @@ export function WoTableRowCells({
onDuplicateFound={handlers.onDuplicateFound}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<TypeCell
value={row.type}
severity={row.severity}
q={search}
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
onSave={(t, sev) =>
saveExistingRowType({
onPatch: handlers.onPatch,
row,
type: t,
severity: sev,
serviceHistory: serviceHistory ?? services,
serviceHistoryReady,
})
}
/>
</td>
<WoTableRowTypeCell
row={row}
services={services}
serviceHistory={serviceHistory}
serviceHistoryReady={serviceHistoryReady}
serviceHistoryQuery={serviceHistoryQuery}
search={search}
handlers={handlers}
mutationCellStyle={mutationCellStyle}
/>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}

View file

@ -64,6 +64,7 @@ type WoTableRowProps = {
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
search?: string;
forceScheduleHighlight?: boolean;
@ -110,6 +111,7 @@ export function WoTableRow({
services = [],
serviceHistory = [],
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
search,
forceScheduleHighlight,
@ -210,6 +212,7 @@ export function WoTableRow({
services={services}
serviceHistory={serviceHistory}
serviceHistoryReady={serviceHistoryReady}
serviceHistoryQuery={serviceHistoryQuery}
sitePlace={sitePlace}
search={search}
forceScheduleHighlight={forceScheduleHighlight}

View file

@ -33,6 +33,7 @@ type WoTableProps = {
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
weekMonday: string;
onNavigateToDay: (dateIso: string) => void;
@ -103,6 +104,7 @@ type WoTableRowRenderProps = {
services: Service[];
serviceHistory: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady: boolean;
pmTypes: string[];
search: string;
@ -208,6 +210,7 @@ export function WoTable({
services = [],
serviceHistory = [],
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
weekMonday,
onNavigateToDay,
@ -260,6 +263,7 @@ export function WoTable({
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryQuery={serviceHistoryQuery}
serviceHistoryReady={serviceHistoryReady}
pmTypes={pmTypes}
search={tableData.search}

View file

@ -75,6 +75,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
services={services}
serviceHistory={serviceHistory}
serviceQuery={servicePickerState}
serviceHistoryQuery={serviceHistoryState}
serviceHistoryReady={serviceHistoryReadyForTypeChange}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}

View file

@ -17,6 +17,7 @@ type WizardServiceNotesFieldsProps = {
setSearch: (v: string) => void;
avetaRequired?: boolean;
onAvetaRequiredChange?: (value: boolean) => void;
serviceSelectionDisabled?: boolean;
};
function SvcSectionLabel({ children }: { children: ReactNode }) {
@ -48,6 +49,7 @@ export function WizardServiceNotesFields({
setSearch,
avetaRequired = false,
onAvetaRequiredChange,
serviceSelectionDisabled = false,
}: WizardServiceNotesFieldsProps) {
const filtered = search
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
@ -61,6 +63,7 @@ export function WizardServiceNotesFields({
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search services…"
disabled={serviceSelectionDisabled}
className="mb-1.5 w-full rounded px-2 py-1.5 text-[12px] focus:outline-none focus:ring-1"
style={{
border: "1px solid var(--color-border)",
@ -81,6 +84,7 @@ export function WizardServiceNotesFields({
key={String(s.id)}
type="button"
onClick={() => onToggle(s.value)}
disabled={serviceSelectionDisabled}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-[#F9FAFB]"
>
<span

View file

@ -23,11 +23,7 @@ export function useWorkOrderServiceRegistry() {
const historyQuery = useAllServices();
const servicePickerState = toServiceRegistryQueryState(activeQuery);
const serviceHistoryState = toServiceRegistryQueryState(historyQuery);
const serviceHistoryReadyForTypeChange =
serviceHistoryState.hasData &&
!serviceHistoryState.isLoading &&
!serviceHistoryState.isError &&
!serviceHistoryState.isFetching;
const serviceHistoryReadyForTypeChange = serviceHistoryState.hasData;
return {
services: activeQuery.data ?? [],

View file

@ -113,6 +113,7 @@ export default function WorkOrdersListPage() {
services={page.services}
serviceHistory={page.serviceHistory}
serviceQuery={page.servicePickerState}
serviceHistoryQuery={page.serviceHistoryState}
serviceHistoryReady={page.serviceHistoryReadyForTypeChange}
weekMonday={filters.weekMonday}
onNavigateToDay={filters.navigateToDay}

View file

@ -1,11 +1,41 @@
import { useState } from "react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
import { renderWithProviders } from "@/test/test-utils";
describe("Aveta Service & Notes", () => {
it("defers linked type selection with visible loading and retry feedback", () => {
const retry = vi.fn();
const onSave = vi.fn();
const query = {
isLoading: false,
isError: true,
isFetching: false,
hasData: false,
retry,
};
renderWithProviders(
<TypeCell
value="PM"
severity={null}
registryUnavailable
serviceHistoryQuery={query}
onSave={onSave}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: /Type of WO, PM/i })).toBeDisabled();
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(retry).toHaveBeenCalledOnce();
expect(onSave).not.toHaveBeenCalled();
});
it("shows loading and preserves the current value while registry services are pending", () => {
renderWithProviders(
<PMTypeCell
@ -300,4 +330,169 @@ describe("Aveta Service & Notes", () => {
}),
);
});
it("shows pending and successful-empty registry states without blocking notes", () => {
const initial = {
open: true,
onOpenChange: vi.fn(),
pm: "Saved service",
extraServices: [],
serviceNotes: "Saved notes",
pmTypes: ["Leak"],
services: [] as {
id: number;
name: string;
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplate: null;
isActive: boolean;
supportedWorkOrderTypes: "PM"[];
}[],
serviceId: 701,
onSave: vi.fn(),
};
const view = renderWithProviders(
<ServiceNotesDialog
{...initial}
serviceQuery={{
isLoading: true,
isError: false,
isFetching: true,
hasData: false,
retry: vi.fn(),
}}
/>,
{ withAuth: false },
);
expect(screen.getByRole("status", { name: /loading services/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText(/search services/i)).toBeDisabled();
expect(screen.getByPlaceholderText(/describe the specific issue/i)).toBeEnabled();
view.rerender(
<ServiceNotesDialog
{...initial}
serviceQuery={{
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
}}
/>,
);
expect(
screen.getByText(/no services are available for this work-order type/i),
).toBeInTheDocument();
expect(screen.getByPlaceholderText(/describe the specific issue/i)).toBeEnabled();
});
it.each(["picker add-details", "existing details trigger"])(
"shows registry retry and keeps notes editable from the %s entry",
(entry) => {
const onRetry = vi.fn();
const onSaveDetails = vi.fn();
const props = {
value: "Saved service",
pmTypes: ["Leak"],
services: [],
serviceId: 701,
serviceNotes: entry === "picker add-details" ? "" : "Original notes",
extraServices: [],
onSave: vi.fn(),
onSaveDetails,
serviceQuery: {
isLoading: false,
isError: true,
isFetching: false,
hasData: false,
retry: onRetry,
},
};
renderWithProviders(<PMTypeCell {...props} />, { withAuth: false });
if (entry === "picker add-details") {
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i }));
} else {
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
}
expect(
screen.getByRole("alert", { name: /service options unavailable/i }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
const notes = screen.getByPlaceholderText(/describe the specific issue/i);
expect(notes).toBeEnabled();
fireEvent.change(notes, { target: { value: "Updated notes" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSaveDetails).toHaveBeenCalledWith(
expect.objectContaining({
pm: "Saved service",
serviceId: 701,
serviceNotes: "Updated notes",
}),
);
expect(onRetry).toHaveBeenCalledOnce();
},
);
it("allows registry selection after retry resolves and saves the service ID", () => {
const onSave = vi.fn();
function RetryHarness() {
const [resolved, setResolved] = useState(false);
return (
<ServiceNotesDialog
open
onOpenChange={vi.fn()}
pm="Saved service"
extraServices={[]}
serviceNotes=""
pmTypes={["Leak"]}
services={
resolved
? [
{
id: 702,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
]
: []
}
serviceId={701}
serviceQuery={{
isLoading: false,
isError: !resolved,
isFetching: false,
hasData: resolved,
retry: () => setResolved(true),
}}
onSave={onSave}
/>
);
}
renderWithProviders(<RetryHarness />, { withAuth: false });
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText(/search services/i)).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(screen.getByPlaceholderText(/search services/i)).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: /Saved servicePrimary/i }));
fireEvent.click(screen.getByRole("button", { name: /QA Test Service/i }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ pm: "QA Test Service", serviceId: 702 }),
);
});
});

View file

@ -151,7 +151,7 @@ describe("saveExistingRowType", () => {
});
});
it("preserves a service when the registry history has not loaded yet", () => {
it("defers a linked service type change when history has not loaded", () => {
const onPatch = vi.fn();
const inactiveService: Service = {
id: 701,
@ -173,6 +173,20 @@ describe("saveExistingRowType", () => {
serviceHistoryReady: false,
});
expect(onPatch).not.toHaveBeenCalled();
});
it("allows type changes for unlinked legacy rows while history is unavailable", () => {
const onPatch = vi.fn();
saveExistingRowType({
onPatch,
row: { ...row, serviceId: null },
type: "Emergency",
severity: null,
serviceHistoryReady: false,
});
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: null });
});
});

View file

@ -343,10 +343,7 @@ describe("SlideOverInfoTabEditView additional contacts", () => {
fireEvent.click(screen.getByRole("button", { name: "Change type to Emergency" }));
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onSave).toHaveBeenCalledWith([
{ field: "severity", value: "2" },
{ field: "workOrderType", value: "Emergency" },
]);
expect(onSave).toHaveBeenCalledWith([]);
});
it("passes additionalContacts into SiteCell and persists them on save", () => {

View file

@ -0,0 +1,49 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const queryFixtures = vi.hoisted(() => ({ active: null as unknown, history: null as unknown }));
vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", () => ({
useActiveServices: () => queryFixtures.active,
useAllServices: () => queryFixtures.history,
}));
import { useWorkOrderServiceRegistry } from "@/app/(protected)/workorders/_hooks/use-work-order-service-registry";
function query(data: unknown, isFetching: boolean) {
return {
data,
isPending: data === undefined,
isError: false,
isFetching,
refetch: vi.fn(),
};
}
describe("useWorkOrderServiceRegistry", () => {
beforeEach(() => {
queryFixtures.active = query([], false);
queryFixtures.history = query([], false);
});
it("uses cached service history while a background refresh is in progress", () => {
const cached = [{ id: 701, name: "Cached historical service" }];
queryFixtures.history = query(cached, true);
const { result } = renderHook(() => useWorkOrderServiceRegistry());
expect(result.current.serviceHistory).toEqual(cached);
expect(result.current.serviceHistoryReadyForTypeChange).toBe(true);
});
it("does not treat initial pending or failed queries without data as ready", () => {
queryFixtures.history = { ...query(undefined, true), isPending: true };
const pending = renderHook(() => useWorkOrderServiceRegistry());
expect(pending.result.current.serviceHistoryReadyForTypeChange).toBe(false);
pending.unmount();
queryFixtures.history = { ...query(undefined, false), isError: true, isPending: false };
const failed = renderHook(() => useWorkOrderServiceRegistry());
expect(failed.result.current.serviceHistoryReadyForTypeChange).toBe(false);
});
});