Skip to content

Commit 4f12421

Browse files
gwleclercclaude
andcommitted
feat: edit and delete mocks while the session has no calls
Most-requested feature (issues #299, #266, #303). Mocks stay append-only once the session's history references them (a history entry is tied to the mock that answered it), so edition/deletion is allowed only while the session history is still empty — the maintainers' stated position on #231. - Backend: additive PUT /mocks/:id and DELETE /mocks/:id. UpdateMock keeps the mock's identity (ID + creation date) and replaces its definition; both refuse with 409 once the session has received calls (types.MockEditForbidden). No change to the mock format, its serialization, or the existing routes. - Frontend: Edit/Delete controls on each mock, shown only once the history query resolves empty. Edit reuses the creation drawer (form/YAML) pre-filled and PUTs; Delete confirms via Popconfirm. Tests: Go service tests (update/delete happy path, edit-forbidden-after-call, not-found) and a Playwright TNR covering the edit+delete flow plus the gate (no controls on the seeded session, which has a call). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 05ead0a commit 4f12421

9 files changed

Lines changed: 441 additions & 29 deletions

File tree

client/components/Mocks.tsx

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
DeleteOutlined,
3+
EditOutlined,
24
LockFilled,
35
PauseCircleFilled,
46
PlayCircleFilled,
@@ -10,15 +12,19 @@ import {
1012
Button,
1113
Empty,
1214
Pagination,
15+
Popconfirm,
1316
Row,
1417
Spin,
1518
Tag,
1619
Typography,
1720
} from "antd";
1821
import dayjs from "dayjs";
22+
import { dump } from "js-yaml";
1923
import * as React from "react";
2024
import { Link, useLocation, useParams } from "react-router-dom";
2125
import {
26+
useDeleteMock,
27+
useHistory,
2228
useLockMocks,
2329
useMocks,
2430
useSessionsSummary,
@@ -204,12 +210,21 @@ const MockComponent = ({
204210
loading,
205211
lockMock,
206212
unlockMock,
213+
editable,
214+
deleting,
215+
onEdit,
216+
onDelete,
207217
}: {
208218
mock: Mock;
209219
canPoll: boolean;
210220
loading: boolean;
211221
lockMock: (mockID: string) => unknown;
212222
unlockMock: (mockID: string) => unknown;
223+
// A mock can be edited or deleted only while the session has received no calls yet.
224+
editable: boolean;
225+
deleting: boolean;
226+
onEdit: (mock: Mock) => unknown;
227+
onDelete: (mockID: string) => unknown;
213228
}) => {
214229
const onLockMock = () => lockMock(mock.state.id);
215230
const onUnlockMock = () => unlockMock(mock.state.id);
@@ -245,9 +260,35 @@ const MockComponent = ({
245260
{mock.state.id}
246261
</Link>
247262
</div>
248-
<span className="date">
249-
{dayjs(mock.state.creation_date).format(dateFormat)}
250-
</span>
263+
<div>
264+
{editable && (
265+
<>
266+
<Button
267+
type="link"
268+
icon={<EditOutlined />}
269+
title="Edit this mock"
270+
onClick={() => onEdit(mock)}
271+
/>
272+
<Popconfirm
273+
title="Delete this mock?"
274+
okText="Delete"
275+
okButtonProps={{ danger: true }}
276+
onConfirm={() => onDelete(mock.state.id)}
277+
>
278+
<Button
279+
type="link"
280+
danger
281+
icon={<DeleteOutlined />}
282+
loading={deleting}
283+
title="Delete this mock"
284+
/>
285+
</Popconfirm>
286+
</>
287+
)}
288+
<span className="date">
289+
{dayjs(mock.state.creation_date).format(dateFormat)}
290+
</span>
291+
</div>
251292
</div>
252293
<div className="content">
253294
<MockRequestComponent request={mock.request} />
@@ -294,15 +335,25 @@ const MocksComponent = (): React.JSX.Element => {
294335
const loading = mocksQuery.isFetching;
295336
const error = mocksQuery.error;
296337

338+
// A mock may be edited/deleted only while the session has received no calls (empty history),
339+
// matching the append-only guarantee the history relies on. Require the history query to have
340+
// resolved first, so the controls don't flash on sessions that actually have calls.
341+
const historyQuery = useHistory(sessionID);
342+
const editable =
343+
canPoll && historyQuery.isSuccess && historyQuery.data.length === 0;
344+
297345
const lockMocksMut = useLockMocks();
298346
const unlockMocksMut = useUnlockMocks();
347+
const deleteMockMut = useDeleteMock();
299348

300349
const initialNewMock = (location.state as { newMock?: string } | null)
301350
?.newMock;
302-
// Value of the mock creation drawer, or null when it is closed.
351+
// Value of the mock creation/edition drawer, or null when it is closed. editMockId is set when
352+
// the drawer edits an existing mock (PUT) rather than creating a new one (POST).
303353
const [newMockValue, setNewMockValue] = React.useState<string | null>(
304354
initialNewMock ?? null,
305355
);
356+
const [editMockId, setEditMockId] = React.useState<string | null>(null);
306357

307358
const togglePolling = () => setPolling((p) => !p);
308359
const ref = React.useRef<HTMLDivElement>(null);
@@ -315,6 +366,15 @@ const MocksComponent = (): React.JSX.Element => {
315366
prevPageSizeRef.current = pageSize;
316367
return scrollToPage(ref.current, goingBack);
317368
}, [page, pageSize]);
369+
// Open the drawer pre-filled with the mock (without its server-managed state) in edit mode.
370+
const handleEditMock = (mock: Mock) => {
371+
const { state: _state, ...definition } = mock;
372+
setEditMockId(mock.state.id);
373+
setNewMockValue(dump([definition]));
374+
};
375+
const handleDeleteMock = (mockID: string) =>
376+
deleteMockMut.mutate({ sessionID, id: mockID });
377+
318378
const isEmpty = mocks.length === 0;
319379
let filteredMocks = mocks;
320380
let body = null;
@@ -364,15 +424,25 @@ const MocksComponent = (): React.JSX.Element => {
364424
loading={loading}
365425
lockMock={lockMock}
366426
unlockMock={unlockMock}
427+
editable={editable}
428+
deleting={deleteMockMut.isPending}
429+
onEdit={handleEditMock}
430+
onDelete={handleDeleteMock}
367431
/>
368432
))}
369433
{filteredMocks.length > minPageSize && pagination}
370434
</>
371435
);
372436
}
373437

374-
const handleAddNewMock = () => setNewMockValue("");
375-
const handleCloseNewMock = () => setNewMockValue(null);
438+
const handleAddNewMock = () => {
439+
setEditMockId(null);
440+
setNewMockValue("");
441+
};
442+
const handleCloseNewMock = () => {
443+
setNewMockValue(null);
444+
setEditMockId(null);
445+
};
376446
return (
377447
<div className="mocks" ref={ref}>
378448
<PageHeader
@@ -415,7 +485,12 @@ const MocksComponent = (): React.JSX.Element => {
415485
</Spin>
416486
</PageHeader>
417487
{newMockValue !== null && (
418-
<NewMock defaultValue={newMockValue} onClose={handleCloseNewMock} />
488+
<NewMock
489+
defaultValue={newMockValue}
490+
editId={editMockId ?? undefined}
491+
sessionId={sessionID}
492+
onClose={handleCloseNewMock}
493+
/>
419494
)}
420495
</div>
421496
);

client/components/NewMock.tsx

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Alert, Button, Drawer, Form, Tabs } from "antd";
22
import * as React from "react";
3-
import { useAddMocks } from "../modules/api";
3+
import { useAddMocks, useUpdateMock } from "../modules/api";
44
import {
55
parseYamlForVisualEditor,
66
validateMocksForSave,
@@ -10,18 +10,26 @@ import type { MockEditorForm } from "./MockEditor/convert";
1010
import MockEditor from "./MockEditor/MockEditor";
1111
import "./NewMock.scss";
1212

13-
// Mock creation drawer, self-contained (owns the editor value and the add-mocks mutation) so it
14-
// can be dropped onto any page. The YAML string is the single source of truth: the Visual Editor
13+
// Mock creation/edition drawer, self-contained (owns the editor value and the mutation) so it can
14+
// be dropped onto any page. The YAML string is the single source of truth: the Visual Editor
1515
// writes to it continuously, and switching back to it re-hydrates from that YAML — but only if the
1616
// YAML is a single mock valid against the schema (otherwise the switch is blocked with an error).
17+
// When editId is set the drawer edits an existing mock (PUT) instead of creating one (POST).
1718
export const NewMock = ({
1819
defaultValue = "",
20+
editId,
21+
sessionId,
1922
onClose,
2023
}: {
2124
defaultValue?: string;
25+
editId?: string;
26+
sessionId?: string;
2227
onClose: () => void;
2328
}): React.JSX.Element => {
2429
const addMocksMut = useAddMocks();
30+
const updateMockMut = useUpdateMock();
31+
const isEdit = editId !== undefined;
32+
const saving = addMocksMut.isPending || updateMockMut.isPending;
2533
const [mock, setMock] = React.useState(defaultValue);
2634
const [view, setView] = React.useState<"visual" | "raw">(
2735
defaultValue.trim() === "" ? "visual" : "raw",
@@ -42,13 +50,18 @@ export const NewMock = ({
4250
return;
4351
}
4452
// Keep the drawer open on failure (e.g. a server-side error) so the mock isn't lost.
45-
addMocksMut.mutate(mock, {
53+
const opts = {
4654
onSuccess: () => onClose(),
47-
onError: (e) => {
55+
onError: (e: unknown) => {
4856
setError(`Can't save — ${(e as Error).message}`);
4957
setView("raw");
5058
},
51-
});
59+
};
60+
if (isEdit) {
61+
updateMockMut.mutate({ sessionID: sessionId, id: editId, mock }, opts);
62+
} else {
63+
addMocksMut.mutate(mock, opts);
64+
}
5265
};
5366

5467
const onChangeView = (key: string) => {
@@ -79,7 +92,7 @@ export const NewMock = ({
7992

8093
return (
8194
<Drawer
82-
title="Add new mocks"
95+
title={isEdit ? "Edit mock" : "Add new mocks"}
8396
placement="right"
8497
className="drawer"
8598
closable={false}
@@ -89,11 +102,7 @@ export const NewMock = ({
89102
footer={
90103
<div className="action buttons">
91104
<Button onClick={onClose}>Cancel</Button>
92-
<Button
93-
onClick={handleSubmit}
94-
type="primary"
95-
loading={addMocksMut.isPending}
96-
>
105+
<Button onClick={handleSubmit} type="primary" loading={saving}>
97106
Save
98107
</Button>
99108
</div>

client/modules/api.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,27 @@ export const postUnlockMocks = async (ids: string[]): Promise<Mocks> => {
151151
return MocksSchema.parse(data);
152152
};
153153

154+
export const putUpdateMock = async (args: {
155+
sessionID?: string;
156+
id: string;
157+
mock: string;
158+
}): Promise<void> => {
159+
await request(`/mocks/${args.id}${sessionQuery(args.sessionID)}`, {
160+
method: "PUT",
161+
body: args.mock,
162+
contentType: ContentTypeYAML,
163+
});
164+
};
165+
166+
export const deleteMock = async (args: {
167+
sessionID?: string;
168+
id: string;
169+
}): Promise<void> => {
170+
await request(`/mocks/${args.id}${sessionQuery(args.sessionID)}`, {
171+
method: "DELETE",
172+
});
173+
};
174+
154175
export const postReset = async (): Promise<void> => {
155176
await request("/reset", { method: "POST" });
156177
};
@@ -255,6 +276,22 @@ export const useAddMocks = () => {
255276
});
256277
};
257278

279+
export const useUpdateMock = () => {
280+
const queryClient = useQueryClient();
281+
return useMutation({
282+
mutationFn: putUpdateMock,
283+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["mocks"] }),
284+
});
285+
};
286+
287+
export const useDeleteMock = () => {
288+
const queryClient = useQueryClient();
289+
return useMutation({
290+
mutationFn: deleteMock,
291+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["mocks"] }),
292+
});
293+
};
294+
258295
export const useLockMocks = () => {
259296
const queryClient = useQueryClient();
260297
return useMutation({

server/admin_server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ func Serve(config config.Config) {
5555
mocksGroup.POST("", handler.AddMocks)
5656
mocksGroup.POST("/lock", handler.LockMocks)
5757
mocksGroup.POST("/unlock", handler.UnlockMocks)
58+
mocksGroup.PUT("/:id", handler.UpdateMock)
59+
mocksGroup.DELETE("/:id", handler.DeleteMock)
5860

5961
historyGroup := adminServerEngine.Group("/history")
6062
historyGroup.GET("", handler.GetHistory)

server/handlers/admin.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handlers
22

33
import (
4+
"errors"
45
"log/slog"
56
"net/http"
67
"strconv"
@@ -82,6 +83,60 @@ func (a *Admin) AddMocks(c echo.Context) error {
8283
})
8384
}
8485

86+
func (a *Admin) sessionIDFromQuery(c echo.Context) string {
87+
sessionID := c.QueryParam("session")
88+
if sessionID == "" {
89+
sessionID = a.mocksServices.GetLastSession().ID
90+
}
91+
return sessionID
92+
}
93+
94+
// mockMutationError maps a service edit/delete error to the right HTTP status.
95+
func mockMutationError(err error) error {
96+
switch {
97+
case errors.Is(err, types.MockEditForbidden):
98+
return echo.NewHTTPError(http.StatusConflict, err.Error())
99+
case errors.Is(err, types.MockNotFound), errors.Is(err, types.SessionNotFound):
100+
return echo.NewHTTPError(http.StatusNotFound, err.Error())
101+
default:
102+
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
103+
}
104+
}
105+
106+
func (a *Admin) UpdateMock(c echo.Context) error {
107+
sessionID := a.sessionIDFromQuery(c)
108+
id := c.Param("id")
109+
110+
var mocks types.Mocks
111+
if err := bindAccordingAccept(c, &mocks); err != nil {
112+
return err
113+
}
114+
if len(mocks) != 1 {
115+
return echo.NewHTTPError(http.StatusBadRequest, "expected exactly one mock to update")
116+
}
117+
if err := mocks[0].Validate(); err != nil {
118+
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
119+
}
120+
121+
updated, err := a.mocksServices.UpdateMock(sessionID, id, mocks[0])
122+
if err != nil {
123+
return mockMutationError(err)
124+
}
125+
return c.JSON(http.StatusOK, updated)
126+
}
127+
128+
func (a *Admin) DeleteMock(c echo.Context) error {
129+
sessionID := a.sessionIDFromQuery(c)
130+
id := c.Param("id")
131+
132+
if err := a.mocksServices.DeleteMock(sessionID, id); err != nil {
133+
return mockMutationError(err)
134+
}
135+
return c.JSON(http.StatusOK, echo.Map{
136+
"message": "Mock deleted successfully",
137+
})
138+
}
139+
85140
func (a *Admin) LockMocks(c echo.Context) error {
86141
var ids []string
87142
if err := bindAccordingAccept(c, &ids); err != nil {

0 commit comments

Comments
 (0)