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.
This commit is contained in:
Arthur Bassi 2026-06-16 17:03:07 -03:00
parent 2a210eeab0
commit 06d0a8ecf4
8 changed files with 251 additions and 62 deletions

View file

@ -0,0 +1,33 @@
import { Alert, Box, Button, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
export function PmScheduleEditUnavailablePanel() {
return (
<Box className="mx-auto max-w-3xl">
<Alert severity="warning" className="mb-4">
PM Schedule editing is unavailable — the production API does not expose{" "}
<code className="text-sm">PMSchedule/GetById</code>.
</Alert>
<Typography variant="body2" color="text.secondary">
List and create remain available via <code className="text-sm">GetList</code> and{" "}
<code className="text-sm">Save</code>. Enable editing after the backend Dev branch exposes
GetById.
</Typography>
</Box>
);
}
export default function PmScheduleEditUnavailablePage() {
const navigate = useNavigate();
return (
<Box className="p-4">
<PmScheduleEditUnavailablePanel />
<Box className="mt-4 flex justify-end">
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
Back to PM Schedules
</Button>
</Box>
</Box>
);
}

View file

@ -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;

View file

@ -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]"
/>
<Button variant="contained" onClick={() => navigate("/pmschedules/new")}>
+ New PM Schedule
</Button>
{pmScheduleCapabilities.canCreate && (
<Button variant="contained" onClick={() => navigate("/pmschedules/new")}>
+ New PM Schedule
</Button>
)}
</Stack>
</Stack>
@ -114,21 +120,23 @@ export default function PmSchedulesListPage() {
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Asset</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
{showActionsColumn && (
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<TableCell colSpan={columnCount} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" className="py-10">
<TableCell colSpan={columnCount} align="center" className="py-10">
<Typography color="text.secondary">No PM schedules found</Typography>
</TableCell>
</TableRow>
@ -136,14 +144,20 @@ export default function PmSchedulesListPage() {
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
{pmScheduleCapabilities.canEdit ? (
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
>
{row.name || "—"}
</Button>
) : (
<Typography variant="body2" className="font-semibold">
{row.name || "—"}
</Typography>
)}
</TableCell>
<TableCell>{row.frequency || "—"}</TableCell>
<TableCell>{row.startDate || "—"}</TableCell>
@ -157,26 +171,32 @@ export default function PmSchedulesListPage() {
</TableCell>
<TableCell>{row.asset || "—"}</TableCell>
<TableCell>{row.location || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit PM schedule"
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete PM schedule"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
{showActionsColumn && (
<TableCell align="right">
{pmScheduleCapabilities.canEdit && (
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit PM schedule"
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{pmScheduleCapabilities.canDelete && (
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete PM schedule"
onClick={() => setDeleteTarget(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
)}
</TableRow>
))
)}
@ -197,28 +217,30 @@ export default function PmSchedulesListPage() {
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete PM Schedule</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deletePmSchedule.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deletePmSchedule.isPending}
>
{deletePmSchedule.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
{pmScheduleCapabilities.canDelete && (
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete PM Schedule</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot
be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deletePmSchedule.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deletePmSchedule.isPending}
>
{deletePmSchedule.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
)}
</Box>
);
}

View file

@ -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;

View file

@ -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<void, Error, number> {
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!");

View file

@ -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<PmSchedule, Err
return useQuery({
queryKey: queryKeys.pmSchedules.detail(id ?? ""),
queryFn: () => pmSchedulesApi.getById(requireQueryParam(id, "PM schedule id")),
enabled: Boolean(id),
enabled: Boolean(id) && pmScheduleCapabilities.canEdit,
});
}

View file

@ -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(<PmSchedulesListPage />, { 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(<PmSchedulesListPage />, { 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(<EditPage />, {
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();
});
});

View file

@ -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);
});
});