From 06d0a8ecf4b71a0433eb90ce434b6647b4b74c4c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 16 Jun 2026 17:03:07 -0300 Subject: [PATCH] fix(pm-schedules): gate edit/delete for Dev API contract Production Dev API exposes only GetList and Save. Hide unsupported edit/delete UI and block the edit route. Add regression tests for disabled actions. --- .../_components/pm-schedule-unavailable.tsx | 33 +++++ src/app/(protected)/pmschedules/edit/[id].tsx | 6 +- src/app/(protected)/pmschedules/index.tsx | 140 ++++++++++-------- src/config/pm-schedule-capabilities.ts | 20 +++ .../use-cases/use-delete-pm-schedule.ts | 9 +- .../use-cases/use-pm-schedule-detail.ts | 3 +- .../pm-schedules-list-actions.test.tsx | 92 ++++++++++++ .../config/pm-schedule-capabilities.test.ts | 10 ++ 8 files changed, 251 insertions(+), 62 deletions(-) create mode 100644 src/app/(protected)/pmschedules/_components/pm-schedule-unavailable.tsx create mode 100644 src/config/pm-schedule-capabilities.ts create mode 100644 src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx create mode 100644 src/test/config/pm-schedule-capabilities.test.ts diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-unavailable.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-unavailable.tsx new file mode 100644 index 00000000..4e77c2ff --- /dev/null +++ b/src/app/(protected)/pmschedules/_components/pm-schedule-unavailable.tsx @@ -0,0 +1,33 @@ +import { Alert, Box, Button, Typography } from "@mui/material"; +import { useNavigate } from "react-router-dom"; + +export function PmScheduleEditUnavailablePanel() { + return ( + + + PM Schedule editing is unavailable — the production API does not expose{" "} + PMSchedule/GetById. + + + List and create remain available via GetList and{" "} + Save. Enable editing after the backend Dev branch exposes + GetById. + + + ); +} + +export default function PmScheduleEditUnavailablePage() { + const navigate = useNavigate(); + + return ( + + + + + + + ); +} diff --git a/src/app/(protected)/pmschedules/edit/[id].tsx b/src/app/(protected)/pmschedules/edit/[id].tsx index d6a04793..24b9b509 100644 --- a/src/app/(protected)/pmschedules/edit/[id].tsx +++ b/src/app/(protected)/pmschedules/edit/[id].tsx @@ -1 +1,5 @@ -export { default } from "../_components/pm-schedule-form"; +import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities"; +import PmScheduleFormPage from "../_components/pm-schedule-form"; +import PmScheduleEditUnavailablePage from "../_components/pm-schedule-unavailable"; + +export default pmScheduleCapabilities.canEdit ? PmScheduleFormPage : PmScheduleEditUnavailablePage; diff --git a/src/app/(protected)/pmschedules/index.tsx b/src/app/(protected)/pmschedules/index.tsx index f4056917..be8d453b 100644 --- a/src/app/(protected)/pmschedules/index.tsx +++ b/src/app/(protected)/pmschedules/index.tsx @@ -27,6 +27,7 @@ import { Tooltip, Typography, } from "@mui/material"; +import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities"; import type { PmScheduleListItem } from "@/domain/pm-schedules/types/pm-schedule"; import { useDeletePmSchedule } from "@/domain/pm-schedules/use-cases/use-delete-pm-schedule"; import { usePmSchedulesList } from "@/domain/pm-schedules/use-cases/use-pm-schedules-list"; @@ -42,6 +43,9 @@ export default function PmSchedulesListPage() { const debouncedSearch = useDebounce(search, 300); const deletePmSchedule = useDeletePmSchedule(); + const showActionsColumn = pmScheduleCapabilities.canEdit || pmScheduleCapabilities.canDelete; + const columnCount = showActionsColumn ? 7 : 6; + const listParams = useMemo( () => ({ search: debouncedSearch, @@ -57,7 +61,7 @@ export default function PmSchedulesListPage() { const totalCount = data?.totalCount ?? 0; const handleDeleteConfirm = () => { - if (!deleteTarget) return; + if (!deleteTarget || !pmScheduleCapabilities.canDelete) return; deletePmSchedule.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null), }); @@ -92,9 +96,11 @@ export default function PmSchedulesListPage() { placeholder="Search PM schedules..." className="min-w-[220px]" /> - + {pmScheduleCapabilities.canCreate && ( + + )} @@ -114,21 +120,23 @@ export default function PmSchedulesListPage() { Status Asset Location - - Actions - + {showActionsColumn && ( + + Actions + + )} {isLoading ? ( - + ) : rows.length === 0 ? ( - + No PM schedules found @@ -136,14 +144,20 @@ export default function PmSchedulesListPage() { rows.map((row) => ( - + {pmScheduleCapabilities.canEdit ? ( + + ) : ( + + {row.name || "—"} + + )} {row.frequency || "—"} {row.startDate || "—"} @@ -157,26 +171,32 @@ export default function PmSchedulesListPage() { {row.asset || "—"} {row.location || "—"} - - - navigate(`/pmschedules/edit/${row.id}`)} - > - - - - - setDeleteTarget(row)} - > - - - - + {showActionsColumn && ( + + {pmScheduleCapabilities.canEdit && ( + + navigate(`/pmschedules/edit/${row.id}`)} + > + + + + )} + {pmScheduleCapabilities.canDelete && ( + + setDeleteTarget(row)} + > + + + + )} + + )} )) )} @@ -197,28 +217,30 @@ export default function PmSchedulesListPage() { rowsPerPageOptions={[12, 24, 48]} /> - setDeleteTarget(null)}> - Delete PM Schedule - - - Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be - undone. - - - - - - - + {pmScheduleCapabilities.canDelete && ( + setDeleteTarget(null)}> + Delete PM Schedule + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot + be undone. + + + + + + + + )} ); } diff --git a/src/config/pm-schedule-capabilities.ts b/src/config/pm-schedule-capabilities.ts new file mode 100644 index 00000000..653575e3 --- /dev/null +++ b/src/config/pm-schedule-capabilities.ts @@ -0,0 +1,20 @@ +/** + * PM Schedule API capabilities aligned with production backend (`origin/Dev`). + * + * Exposed controller endpoints on Dev: + * - GET PMSchedule/GetList + * - POST PMSchedule/Save (Id=0 create, Id>0 update) + * + * Not exposed on Dev (present on `main` after 59385cf): + * - GET PMSchedule/GetById + * - POST PMSchedule/Delete + * + * Flip flags to true only after Dev exposes the matching endpoints. + */ +export const pmScheduleCapabilities = { + canCreate: true, + canEdit: false, + canDelete: false, +} as const; + +export type PmScheduleCapabilities = typeof pmScheduleCapabilities; diff --git a/src/domain/pm-schedules/use-cases/use-delete-pm-schedule.ts b/src/domain/pm-schedules/use-cases/use-delete-pm-schedule.ts index 66594fce..92a55fdc 100644 --- a/src/domain/pm-schedules/use-cases/use-delete-pm-schedule.ts +++ b/src/domain/pm-schedules/use-cases/use-delete-pm-schedule.ts @@ -1,5 +1,6 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; import { toast } from "react-toastify"; +import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities"; import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; import { queryKeys } from "@/infra/query-key/query-key"; @@ -7,7 +8,13 @@ export function useDeletePmSchedule(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (id: number) => pmSchedulesApi.delete(id), + mutationFn: (id: number) => { + if (!pmScheduleCapabilities.canDelete) { + return Promise.reject(new Error("PM Schedule delete is not supported by the current API")); + } + + return pmSchedulesApi.delete(id); + }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.pmSchedules.all }); toast.success("PM schedule deleted successfully!"); diff --git a/src/domain/pm-schedules/use-cases/use-pm-schedule-detail.ts b/src/domain/pm-schedules/use-cases/use-pm-schedule-detail.ts index 1f559234..c0bfc643 100644 --- a/src/domain/pm-schedules/use-cases/use-pm-schedule-detail.ts +++ b/src/domain/pm-schedules/use-cases/use-pm-schedule-detail.ts @@ -1,4 +1,5 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities"; import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; import type { PmSchedule } from "@/domain/pm-schedules/types/pm-schedule"; import { queryKeys } from "@/infra/query-key/query-key"; @@ -8,6 +9,6 @@ export function usePmScheduleDetail(id?: string): UseQueryResult pmSchedulesApi.getById(requireQueryParam(id, "PM schedule id")), - enabled: Boolean(id), + enabled: Boolean(id) && pmScheduleCapabilities.canEdit, }); } diff --git a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx new file mode 100644 index 00000000..f26b9ea5 --- /dev/null +++ b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx @@ -0,0 +1,92 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import PmSchedulesListPage from "@/app/(protected)/pmschedules/index"; +import { renderWithProviders } from "@/test/test-utils"; + +const usePmSchedulesList = vi.fn(); +const useDeletePmSchedule = vi.fn(); + +vi.mock("@/domain/pm-schedules/use-cases/use-pm-schedules-list", () => ({ + usePmSchedulesList: (...args: unknown[]) => usePmSchedulesList(...args), +})); + +vi.mock("@/domain/pm-schedules/use-cases/use-delete-pm-schedule", () => ({ + useDeletePmSchedule: (...args: unknown[]) => useDeletePmSchedule(...args), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const mockRow = { + id: 1, + name: "Quarterly HVAC", + frequency: "Quarterly", + startDate: "2026-01-01", + isActive: true, + asset: "Unit A", + location: "Building 1", +}; + +describe("PmSchedulesListPage actions", () => { + it("does not render edit or delete actions when capabilities are disabled", () => { + useDeletePmSchedule.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + }); + usePmSchedulesList.mockReturnValue({ + data: { + items: [mockRow], + totalCount: 1, + page: 1, + pageSize: 12, + }, + isLoading: false, + isFetching: false, + error: null, + }); + + renderWithProviders(, { route: "/pmschedules", withAuth: false }); + + expect(screen.queryByLabelText("Edit PM schedule")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Delete PM schedule")).not.toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Quarterly HVAC" })).not.toBeInTheDocument(); + expect(screen.getByText("Quarterly HVAC")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("still renders create when canCreate is enabled", () => { + useDeletePmSchedule.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + }); + usePmSchedulesList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 12 }, + isLoading: false, + isFetching: false, + error: null, + }); + + renderWithProviders(, { route: "/pmschedules", withAuth: false }); + + expect(screen.getByRole("button", { name: "+ New PM Schedule" })).toBeInTheDocument(); + }); +}); + +describe("PmScheduleEditUnavailablePage", () => { + it("shows unavailable message instead of edit form when canEdit is false", async () => { + const EditPage = (await import("@/app/(protected)/pmschedules/edit/[id]")).default; + + renderWithProviders(, { + route: "/pmschedules/edit/1", + routerProps: { initialEntries: ["/pmschedules/edit/1"] }, + withAuth: false, + }); + + expect(screen.getByText(/PM Schedule editing is unavailable/i)).toBeInTheDocument(); + expect(screen.getByText(/PMSchedule\/GetById/i)).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Edit PM Schedule" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Back to PM Schedules" })).toBeInTheDocument(); + }); +}); diff --git a/src/test/config/pm-schedule-capabilities.test.ts b/src/test/config/pm-schedule-capabilities.test.ts new file mode 100644 index 00000000..8ae57e27 --- /dev/null +++ b/src/test/config/pm-schedule-capabilities.test.ts @@ -0,0 +1,10 @@ +import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities"; +import { describe, expect, it } from "vitest"; + +describe("pmScheduleCapabilities", () => { + it("reflects production Dev API contract (GetList + Save only)", () => { + expect(pmScheduleCapabilities.canCreate).toBe(true); + expect(pmScheduleCapabilities.canEdit).toBe(false); + expect(pmScheduleCapabilities.canDelete).toBe(false); + }); +});