Skip to content

Commit acfaa00

Browse files
gwleclercclaude
andcommitted
feat(client): schema-complete mock editor with a form/YAML toggle
Rework mock creation and make the visual editor cover the whole mock format: - Extract the creation drawer to NewMock and mount it on both Mocks and History; History's 'Create a new mock from entry' now opens it in place (no navigation). - Toggle between the visual form and raw YAML: the YAML is the source of truth, and switching to the form is gated on it being a single mock valid against the schema (ajv). Validate before saving and keep the drawer open on error so the mock is never lost. Drop the now-redundant read-only preview. - Complete the form vs the schema: response/proxy delay (fixed or random), all 17 matchers, every HTTP method plus method-as-matcher, path and body matchers for all methods, multi-value query/header keys, numeric/bool values coerced to strings. Reverse conversion (mock -> form) with round-trip tests. - Keep pagination in the URL (?page/?page-size) so a page is shareable, with directional page scroll; the mock link preserves ?session and back returns to the list. - Only sync the ?session param on /pages/* routes so it no longer clobbers the '*' -> /pages/history redirect (which left the root on a blank page). Tests: vitest 64 (conversion, rendered component, YAML validation, utils) and the Playwright TNR grows to 22. New deps (pinned, audit-clean): ajv (runtime), and @testing-library/react + user-event (dev). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cb1c59f commit acfaa00

27 files changed

Lines changed: 2026 additions & 563 deletions

client/components/History.tsx

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ import {
3030
cleanupResponse,
3131
entryToCurl,
3232
formatQueryParams,
33+
scrollToPage,
34+
useQueryParams,
3335
} from "../modules/utils";
3436
import Code from "./Code";
3537
import "./History.scss";
38+
import NewMock from "./NewMock";
3639
import PageHeader from "./PageHeader";
3740

3841
const TableRow = ([key, values]: [string, string[]]) => (
@@ -56,7 +59,14 @@ const newMockFromEntry = (entry: Entry): string => {
5659
return dump([{ request, response }]);
5760
};
5861

59-
const EntryComponent = React.memo(({ value }: { value: Entry }) => {
62+
const EntryComponent = React.memo(
63+
({
64+
value,
65+
onCreateMock,
66+
}: {
67+
value: Entry;
68+
onCreateMock: (mock: string) => void;
69+
}) => {
6070
const path =
6171
value.request.path + formatQueryParams(value.request.query_params);
6272

@@ -143,14 +153,16 @@ const EntryComponent = React.memo(({ value }: { value: Entry }) => {
143153
</span>
144154
</div>
145155
<Typography.Paragraph>
146-
<Link to="/pages/mocks" state={{ newMock: newMockFromEntry(value) }}>
147-
<Button block type="dashed">
148-
<PlusCircleOutlined />
149-
{value.response.status >= 600
150-
? "Create a new mock from request"
151-
: "Create a new mock from entry"}
152-
</Button>
153-
</Link>
156+
<Button
157+
block
158+
type="dashed"
159+
onClick={() => onCreateMock(newMockFromEntry(value))}
160+
>
161+
<PlusCircleOutlined />
162+
{value.response.status >= 600
163+
? "Create a new mock from request"
164+
: "Create a new mock from entry"}
165+
</Button>
154166
</Typography.Paragraph>
155167
{value.response.headers && (
156168
<table>
@@ -202,12 +214,23 @@ const HistoryComponent = (): React.JSX.Element => {
202214
);
203215
const [filter, setFilter] = useLocalStorage("history.filter", "all");
204216

205-
// Pagination
217+
// Pagination — kept in the URL query (?page, ?page-size) so a given page is shareable.
206218
const minPageSize = 10;
207-
const [page, setPage] = React.useState(1);
208-
const [pageSize, setPageSize] = React.useState(minPageSize);
219+
const [queryParams, setQueryParams] = useQueryParams();
220+
const page = Math.max(1, Number(queryParams.get("page")) || 1);
221+
const pageSize = Number(queryParams.get("page-size")) || minPageSize;
222+
const setPage = (p: number) => setQueryParams({ page: String(p) });
223+
const setPageAndSize = (p: number, ps: number) =>
224+
setQueryParams({ page: String(p), "page-size": String(ps) });
209225
const [polling, setPolling] = React.useState(false);
210226

227+
// Mock creation drawer opened in place from an entry (no navigation to the Mocks page).
228+
const [newMockValue, setNewMockValue] = React.useState<string | null>(null);
229+
const handleCreateMock = React.useCallback(
230+
(mock: string) => setNewMockValue(mock),
231+
[]
232+
);
233+
211234
const historyQuery = useHistory(sessionID, {
212235
refetchInterval: polling ? 10000 : false,
213236
});
@@ -217,14 +240,15 @@ const HistoryComponent = (): React.JSX.Element => {
217240

218241
const togglePolling = () => setPolling((p) => !p);
219242

220-
const ref = React.createRef<HTMLDivElement>();
243+
const ref = React.useRef<HTMLDivElement>(null);
244+
const prevPageRef = React.useRef(page);
245+
const prevPageSizeRef = React.useRef(pageSize);
221246
React.useLayoutEffect(() => {
222-
if (ref.current) {
223-
ref.current.scrollIntoView({
224-
behavior: "smooth",
225-
block: "start",
226-
});
227-
}
247+
const sizeChanged = pageSize !== prevPageSizeRef.current;
248+
const goingBack = !sizeChanged && page < prevPageRef.current;
249+
prevPageRef.current = page;
250+
prevPageSizeRef.current = pageSize;
251+
return scrollToPage(ref.current, goingBack);
228252
}, [page, pageSize]);
229253

230254
let body = null;
@@ -255,10 +279,7 @@ const HistoryComponent = (): React.JSX.Element => {
255279
}
256280
} else {
257281
const onChangePage = (p: number) => setPage(p);
258-
const onChangePageSize = (p: number, ps: number) => {
259-
setPage(p);
260-
setPageSize(ps);
261-
};
282+
const onChangePageSize = (p: number, ps: number) => setPageAndSize(p, ps);
262283
const pagination = (
263284
<Row justify="space-between" align="middle" className="container">
264285
<div>
@@ -287,7 +308,11 @@ const HistoryComponent = (): React.JSX.Element => {
287308
Math.min(page * pageSize, filteredEntries.length)
288309
)
289310
.map((entry, index) => (
290-
<EntryComponent key={`entry-${index}`} value={entry} />
311+
<EntryComponent
312+
key={`entry-${index}`}
313+
value={entry}
314+
onCreateMock={handleCreateMock}
315+
/>
291316
))}
292317
{filteredEntries.length > minPageSize && pagination}
293318
</>
@@ -354,6 +379,8 @@ const HistoryComponent = (): React.JSX.Element => {
354379
defaultValue={filter}
355380
variant="borderless"
356381
className="ant-btn-link"
382+
// Drop the dropdown caret so this renders as an inline link within the sentence.
383+
suffixIcon={null}
357384
popupMatchSelectWidth={180}
358385
onChange={onFilter}
359386
options={filterOptions}
@@ -364,6 +391,12 @@ const HistoryComponent = (): React.JSX.Element => {
364391
{body}
365392
</Spin>
366393
</PageHeader>
394+
{newMockValue !== null && (
395+
<NewMock
396+
defaultValue={newMockValue}
397+
onClose={() => setNewMockValue(null)}
398+
/>
399+
)}
367400
</div>
368401
);
369402
};

client/components/MockEditor/BodyMatcherEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const BodyMatcherEditor = ({
3939
([key, value]) => {
4040
// TODO: handle more matchers
4141
actions.add({ key, matcher: "ShouldEqual", value });
42-
}
42+
},
4343
);
4444
setInitialized(true);
4545
} catch (e) {

client/components/MockEditor/KeyValueEditor.tsx

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
Space,
1111
} from "antd";
1212
import { defaultMatcher } from "../../modules/types";
13-
import { positiveMatchers, negativeMatchers, unaryMatchers } from "./utils";
13+
import { matcherOptions, unaryMatchers } from "./utils";
1414

1515
export interface KeyValueEditorProps {
1616
name: string[];
@@ -33,23 +33,6 @@ export const KeyValueEditor = ({
3333
</Form.List>
3434
);
3535

36-
const matcherOptions = [
37-
{
38-
label: "Positive",
39-
options: positiveMatchers.map((matcher) => ({
40-
value: matcher,
41-
label: matcher,
42-
})),
43-
},
44-
{
45-
label: "Negative",
46-
options: negativeMatchers.map((matcher) => ({
47-
value: matcher,
48-
label: matcher,
49-
})),
50-
},
51-
];
52-
5336
export interface KeyValueEditorEngineProps extends KeyValueEditorProps {
5437
fields: FormListFieldData[];
5538
actions: FormListOperation;
@@ -76,7 +59,7 @@ export const KeyValueEditorEngine = ({
7659

7760
{withMatchers && (
7861
<Form.Item {...restField} name={[fieldName, "matcher"]}>
79-
<Select options={matcherOptions} />
62+
<Select style={{ width: 210 }} options={matcherOptions} />
8063
</Form.Item>
8164
)}
8265

@@ -86,7 +69,7 @@ export const KeyValueEditorEngine = ({
8669
{...restField}
8770
name={[fieldName, "value"]}
8871
hidden={unaryMatchers.includes(
89-
getFieldValue([...name, fieldName, "matcher"])
72+
getFieldValue([...name, fieldName, "matcher"]),
9073
)}
9174
>
9275
<Input placeholder="Value" />

client/components/MockEditor/MockContextEditor.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,20 @@ export const MockContextEditor = (): React.JSX.Element => (
99

1010
<Form.Item
1111
label="Limit this mock to be called"
12-
shouldUpdate={(prevValues, currentValues) => prevValues?.context?.times_enabled !==
13-
currentValues?.context?.times_enabled ||
14-
prevValues?.context?.times !== currentValues?.context?.times}
12+
shouldUpdate={(prevValues, currentValues) =>
13+
prevValues?.context?.times_enabled !==
14+
currentValues?.context?.times_enabled ||
15+
prevValues?.context?.times !== currentValues?.context?.times
16+
}
1517
style={{ marginBottom: 0, paddingLeft: "5px" }}
1618
>
1719
{({ getFieldValue }) => (
1820
<>
1921
<Form.Item name={["context", "times"]} noStyle>
2022
<InputNumber
2123
min={1}
22-
disabled={!getFieldValue(["context", "times_enabled"])} />
24+
disabled={!getFieldValue(["context", "times_enabled"])}
25+
/>
2326
</Form.Item>
2427
{getFieldValue(["context", "times"]) <= 1 ? (
2528
<span> time</span>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as React from "react";
2+
import { Form, Input, Radio } from "antd";
3+
4+
// Response/proxy delay: none, a fixed duration (e.g. "200ms"), or a random range between min and
5+
// max. `prefix` is the parent field path (["response"] or ["proxy"]).
6+
export const MockDelayEditor = ({
7+
prefix,
8+
}: {
9+
prefix: string[];
10+
}): React.JSX.Element => (
11+
<div className="inline-form-items">
12+
<span style={{ paddingRight: "0.5em" }}>Delay:</span>
13+
<Form.Item name={[...prefix, "delay_mode"]} noStyle>
14+
<Radio.Group
15+
size="small"
16+
optionType="button"
17+
options={[
18+
{ label: "None", value: "none" },
19+
{ label: "Fixed", value: "fixed" },
20+
{ label: "Random", value: "random" },
21+
]}
22+
/>
23+
</Form.Item>
24+
<Form.Item
25+
noStyle
26+
shouldUpdate={(prev, cur) => {
27+
const at = (v: Record<string, unknown>) =>
28+
prefix.reduce<unknown>(
29+
(acc, k) => (acc as Record<string, unknown>)?.[k],
30+
v,
31+
);
32+
return (
33+
(at(prev) as { delay_mode?: string })?.delay_mode !==
34+
(at(cur) as { delay_mode?: string })?.delay_mode
35+
);
36+
}}
37+
>
38+
{({ getFieldValue }) => {
39+
const mode = getFieldValue([...prefix, "delay_mode"]) ?? "none";
40+
if (mode === "fixed") {
41+
return (
42+
<Form.Item name={[...prefix, "delay_value"]} noStyle>
43+
<Input
44+
size="small"
45+
placeholder="e.g. 200ms, 1s"
46+
style={{ width: 160, marginLeft: "0.5em" }}
47+
/>
48+
</Form.Item>
49+
);
50+
}
51+
if (mode === "random") {
52+
return (
53+
<span style={{ marginLeft: "0.5em" }}>
54+
<Form.Item name={[...prefix, "delay_min"]} noStyle>
55+
<Input
56+
size="small"
57+
placeholder="min, e.g. 100ms"
58+
style={{ width: 150 }}
59+
/>
60+
</Form.Item>
61+
<span style={{ padding: "0 0.5em" }}></span>
62+
<Form.Item name={[...prefix, "delay_max"]} noStyle>
63+
<Input
64+
size="small"
65+
placeholder="max, e.g. 1s"
66+
style={{ width: 150 }}
67+
/>
68+
</Form.Item>
69+
</span>
70+
);
71+
}
72+
return null;
73+
}}
74+
</Form.Item>
75+
</div>
76+
);

client/components/MockEditor/MockDynamicResponseEditor.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const MockDynamicResponseEditor = (): React.JSX.Element => (
77
<Form.Item label="Engine" name={["dynamic_response", "engine"]}>
88
<Select
99
options={[
10+
{ value: "go_template", label: "Go Template" },
1011
{ value: "go_template_yaml", label: "Go Template (YAML)" },
1112
{ value: "go_template_json", label: "Go Template (JSON)" },
1213
{ value: "lua", label: "Lua" },
@@ -16,8 +17,10 @@ export const MockDynamicResponseEditor = (): React.JSX.Element => (
1617

1718
<Form.Item
1819
noStyle
19-
shouldUpdate={(prevValues, currentValues) => prevValues?.dynamic_response?.engine !==
20-
currentValues?.dynamic_response?.engine}
20+
shouldUpdate={(prevValues, currentValues) =>
21+
prevValues?.dynamic_response?.engine !==
22+
currentValues?.dynamic_response?.engine
23+
}
2124
>
2225
{({ getFieldValue }) => (
2326
<Form.Item label="Script" name={["dynamic_response", "script"]}>

0 commit comments

Comments
 (0)