@thepalaceproject/circulation-admin 1.40.0-post.4 → 1.41.0-post.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -51,7 +51,7 @@
51
51
  "numeral": "^2.0.6",
52
52
  "opds-feed-parser": "0.0.17",
53
53
  "prop-types": "^15.7.2",
54
- "qs": "^6.14.2",
54
+ "qs": "^6.15.2",
55
55
  "react": "^16.8.6",
56
56
  "react-beautiful-dnd": "^2.3.1",
57
57
  "react-bootstrap": "^0.32.4",
@@ -101,7 +101,7 @@
101
101
  "eslint-plugin-jsx-a11y": "^6.2.3",
102
102
  "eslint-plugin-prettier": "^3.1.3",
103
103
  "eslint-plugin-react": "^7.19.0",
104
- "eslint-plugin-react-hooks": "^4.0.0",
104
+ "eslint-plugin-react-hooks": "^7.1.1",
105
105
  "fetch-mock": "^10.0.7",
106
106
  "fetch-mock-jest": "^1.5.1",
107
107
  "fetch-ponyfill": "^7.1.0",
@@ -109,7 +109,7 @@
109
109
  "follow-redirects": "^1.16.0",
110
110
  "husky": "^4.3.0",
111
111
  "jest": "^29.3.1",
112
- "jest-environment-jsdom": "^29.3.1",
112
+ "jest-environment-jsdom": "^30.4.1",
113
113
  "jest-fixed-jsdom": "^0.0.9",
114
114
  "jsdom": "^20.0.3",
115
115
  "json-loader": "^0.5.4",
@@ -136,13 +136,13 @@
136
136
  "ts-node": "^10.9.2",
137
137
  "tslint": "^6.1.3",
138
138
  "tslint-react-a11y": "^1.1.0",
139
- "typedoc": "^0.27.9",
139
+ "typedoc": "^0.28.19",
140
140
  "typescript": "^5.7.3",
141
141
  "url-loader": "^4.1.1",
142
142
  "webpack": "^5.105.2",
143
143
  "webpack-cli": "^5.0.1",
144
- "webpack-dev-server": "^5.2.2",
145
- "webpack-merge": "^5.8.0"
144
+ "webpack-dev-server": "^5.2.4",
145
+ "webpack-merge": "^6.0.1"
146
146
  },
147
147
  "husky": {
148
148
  "hooks": {
@@ -154,5 +154,5 @@
154
154
  "*.{js,jsx,ts,tsx,css,md}": "prettier --write",
155
155
  "*.{js,css,md}": "prettier --write"
156
156
  },
157
- "version": "1.40.0-post.4"
157
+ "version": "1.41.0-post.17"
158
158
  }
@@ -0,0 +1,518 @@
1
+ import * as React from "react";
2
+ import { render, screen, act, fireEvent } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import JsonField, { JsonFieldHandle } from "../../../src/components/JsonField";
5
+
6
+ const setting = {
7
+ key: "my_json",
8
+ label: "JSON Setting",
9
+ description: "<p>A JSON field</p>",
10
+ };
11
+
12
+ const requiredSetting = { ...setting, required: true };
13
+
14
+ describe("JsonField", () => {
15
+ it("renders textarea with label and description", () => {
16
+ render(<JsonField setting={setting} />);
17
+ expect(screen.getByLabelText("JSON Setting")).toBeInstanceOf(
18
+ HTMLTextAreaElement
19
+ );
20
+ expect(screen.getByText("A JSON field")).toBeInTheDocument();
21
+ });
22
+
23
+ it("shows empty textarea for null value", () => {
24
+ render(<JsonField setting={setting} value={null} />);
25
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
26
+ expect(ta.value).toBe("");
27
+ });
28
+
29
+ it("shows empty textarea when no value prop provided", () => {
30
+ render(<JsonField setting={setting} />);
31
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
32
+ expect(ta.value).toBe("");
33
+ });
34
+
35
+ it("displays pretty-printed JSON for an object value", () => {
36
+ const obj = { foo: "bar", baz: 42 };
37
+ render(<JsonField setting={setting} value={obj} />);
38
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
39
+ expect(ta.value).toBe(JSON.stringify(obj, null, 2));
40
+ });
41
+
42
+ it("displays pretty-printed JSON for an array value", () => {
43
+ const arr = [1, "two", { three: 3 }];
44
+ render(<JsonField setting={setting} value={arr} />);
45
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
46
+ expect(ta.value).toBe(JSON.stringify(arr, null, 2));
47
+ });
48
+
49
+ it("resets textarea when value prop changes externally", () => {
50
+ const { rerender } = render(
51
+ <JsonField setting={setting} value={{ a: 1 }} />
52
+ );
53
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
54
+ expect(ta.value).toBe(JSON.stringify({ a: 1 }, null, 2));
55
+
56
+ rerender(<JsonField setting={setting} value={{ b: 2 }} />);
57
+ expect(ta.value).toBe(JSON.stringify({ b: 2 }, null, 2));
58
+ });
59
+
60
+ it("shows inline error for invalid JSON input", () => {
61
+ render(<JsonField setting={setting} />);
62
+ const ta = screen.getByLabelText("JSON Setting");
63
+ fireEvent.change(ta, { target: { value: "not valid json" } });
64
+ expect(screen.getByText(/unexpected token|expected/i)).toBeInTheDocument();
65
+ });
66
+
67
+ it("clears the error when input becomes valid JSON", () => {
68
+ render(<JsonField setting={setting} />);
69
+ const ta = screen.getByLabelText("JSON Setting");
70
+
71
+ fireEvent.change(ta, { target: { value: "{invalid" } });
72
+ expect(screen.getByText(/unexpected token|expected/i)).toBeInTheDocument();
73
+
74
+ fireEvent.change(ta, { target: { value: '{"key":"value"}' } });
75
+ expect(
76
+ screen.queryByText(/unexpected token|expected/i)
77
+ ).not.toBeInTheDocument();
78
+ });
79
+
80
+ it("clears the error when textarea is emptied", () => {
81
+ render(<JsonField setting={setting} />);
82
+ const ta = screen.getByLabelText("JSON Setting");
83
+
84
+ fireEvent.change(ta, { target: { value: "bad" } });
85
+ expect(screen.getByText(/unexpected token|expected/i)).toBeInTheDocument();
86
+
87
+ fireEvent.change(ta, { target: { value: "" } });
88
+ expect(
89
+ screen.queryByText(/unexpected token|expected/i)
90
+ ).not.toBeInTheDocument();
91
+ });
92
+
93
+ it("shows the Required badge for required settings", () => {
94
+ render(<JsonField setting={requiredSetting} />);
95
+ expect(screen.getByText("Required")).toBeInTheDocument();
96
+ });
97
+
98
+ it("disables the textarea when disabled prop is true", () => {
99
+ render(<JsonField setting={setting} disabled={true} />);
100
+ expect(screen.getByLabelText("JSON Setting")).toBeDisabled();
101
+ });
102
+
103
+ it("calls onChange with parsed value on valid input", () => {
104
+ const onChange = jest.fn();
105
+ render(<JsonField setting={setting} onChange={onChange} />);
106
+ const ta = screen.getByLabelText("JSON Setting");
107
+
108
+ fireEvent.change(ta, { target: { value: '{"x":1}' } });
109
+ expect(onChange).toHaveBeenCalledWith({ x: 1 });
110
+ });
111
+
112
+ it("does not call onChange when JSON is invalid", () => {
113
+ const onChange = jest.fn();
114
+ render(<JsonField setting={setting} onChange={onChange} />);
115
+ const ta = screen.getByLabelText("JSON Setting");
116
+
117
+ fireEvent.change(ta, { target: { value: "{bad" } });
118
+ expect(onChange).not.toHaveBeenCalled();
119
+ });
120
+
121
+ describe("error message visibility", () => {
122
+ it("is absent before the field is focused and there is no error", () => {
123
+ render(<JsonField setting={setting} />);
124
+ expect(
125
+ document.querySelector(".json-field-error-msg")
126
+ ).not.toBeInTheDocument();
127
+ });
128
+
129
+ it("appears (empty) when the field is focused with no error", () => {
130
+ render(<JsonField setting={setting} />);
131
+ const ta = screen.getByLabelText("JSON Setting");
132
+ fireEvent.focus(ta);
133
+ const el = document.querySelector(".json-field-error-msg");
134
+ expect(el).toBeInTheDocument();
135
+ expect(el!.textContent).toBe("");
136
+ });
137
+
138
+ it("disappears on blur when there is no error", () => {
139
+ render(<JsonField setting={setting} />);
140
+ const ta = screen.getByLabelText("JSON Setting");
141
+ fireEvent.focus(ta);
142
+ fireEvent.blur(ta);
143
+ expect(
144
+ document.querySelector(".json-field-error-msg")
145
+ ).not.toBeInTheDocument();
146
+ });
147
+
148
+ it("stays visible after blur when there is an error", () => {
149
+ render(<JsonField setting={setting} />);
150
+ const ta = screen.getByLabelText("JSON Setting");
151
+ fireEvent.focus(ta);
152
+ fireEvent.change(ta, { target: { value: "bad json" } });
153
+ fireEvent.blur(ta);
154
+ expect(
155
+ screen.getByText(/unexpected token|expected/i)
156
+ ).toBeInTheDocument();
157
+ });
158
+
159
+ it("sets aria-invalid on the textarea when JSON is invalid", () => {
160
+ render(<JsonField setting={setting} />);
161
+ const ta = screen.getByLabelText("JSON Setting");
162
+ expect(ta).not.toHaveAttribute("aria-invalid", "true");
163
+ fireEvent.change(ta, { target: { value: "bad json" } });
164
+ expect(ta).toHaveAttribute("aria-invalid", "true");
165
+ fireEvent.change(ta, { target: { value: '{"x":1}' } });
166
+ expect(ta).not.toHaveAttribute("aria-invalid", "true");
167
+ });
168
+
169
+ it("adds the error element id to aria-describedby when invalid", () => {
170
+ render(<JsonField setting={setting} />);
171
+ const ta = screen.getByLabelText("JSON Setting");
172
+ expect(ta.getAttribute("aria-describedby") ?? "").not.toContain(
173
+ "json-error-"
174
+ );
175
+ fireEvent.change(ta, { target: { value: "bad" } });
176
+ expect(ta.getAttribute("aria-describedby")).toContain("json-error-");
177
+ });
178
+
179
+ it("includes the description element id in aria-describedby when description is present", () => {
180
+ render(<JsonField setting={setting} />);
181
+ const ta = screen.getByLabelText("JSON Setting");
182
+ expect(ta.getAttribute("aria-describedby")).toContain("json-desc-");
183
+ });
184
+ });
185
+
186
+ describe("getValue()", () => {
187
+ it("returns null for empty textarea", () => {
188
+ const ref = React.createRef<JsonFieldHandle>();
189
+ render(<JsonField setting={setting} ref={ref} />);
190
+ expect(ref.current!.getValue()).toBeNull();
191
+ });
192
+
193
+ it("returns parsed object for valid JSON", () => {
194
+ const ref = React.createRef<JsonFieldHandle>();
195
+ render(<JsonField setting={setting} ref={ref} />);
196
+ const ta = screen.getByLabelText("JSON Setting");
197
+
198
+ fireEvent.change(ta, { target: { value: '{"a":1}' } });
199
+ expect(ref.current!.getValue()).toEqual({ a: 1 });
200
+ });
201
+
202
+ it("returns undefined for invalid JSON", () => {
203
+ const ref = React.createRef<JsonFieldHandle>();
204
+ render(<JsonField setting={setting} ref={ref} />);
205
+ const ta = screen.getByLabelText("JSON Setting");
206
+
207
+ fireEvent.change(ta, { target: { value: "not json" } });
208
+ expect(ref.current!.getValue()).toBeUndefined();
209
+ });
210
+ });
211
+
212
+ describe("Copy button", () => {
213
+ beforeEach(() => {
214
+ Object.defineProperty(navigator, "clipboard", {
215
+ value: { writeText: jest.fn().mockResolvedValue(undefined) },
216
+ writable: true,
217
+ configurable: true,
218
+ });
219
+ });
220
+
221
+ it("is disabled when textarea is empty", () => {
222
+ render(<JsonField setting={setting} />);
223
+ expect(
224
+ screen.getByRole("button", { name: "Copy to clipboard" })
225
+ ).toBeDisabled();
226
+ });
227
+
228
+ it("is enabled when textarea has content", () => {
229
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
230
+ expect(
231
+ screen.getByRole("button", { name: "Copy to clipboard" })
232
+ ).not.toBeDisabled();
233
+ });
234
+
235
+ it("copies textarea text to clipboard", () => {
236
+ const obj = { a: 1 };
237
+ render(<JsonField setting={setting} value={obj} />);
238
+ fireEvent.click(
239
+ screen.getByRole("button", { name: "Copy to clipboard" })
240
+ );
241
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
242
+ JSON.stringify(obj, null, 2)
243
+ );
244
+ });
245
+
246
+ it("shows Copied! text briefly after click then hides it", async () => {
247
+ jest.useFakeTimers();
248
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
249
+ expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
250
+ await act(async () => {
251
+ fireEvent.click(
252
+ screen.getByRole("button", { name: "Copy to clipboard" })
253
+ );
254
+ });
255
+ expect(screen.getByText("Copied!")).toBeInTheDocument();
256
+ act(() => jest.advanceTimersByTime(2000));
257
+ expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
258
+ jest.useRealTimers();
259
+ });
260
+
261
+ it("shows 'Copy failed.' when navigator.clipboard is unavailable", () => {
262
+ Object.defineProperty(navigator, "clipboard", {
263
+ value: undefined,
264
+ writable: true,
265
+ configurable: true,
266
+ });
267
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
268
+ fireEvent.click(
269
+ screen.getByRole("button", { name: "Copy to clipboard" })
270
+ );
271
+ expect(screen.getByText("Copy failed.")).toBeInTheDocument();
272
+ expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
273
+ });
274
+
275
+ it("shows 'Copy failed.' when clipboard write fails", async () => {
276
+ Object.defineProperty(navigator, "clipboard", {
277
+ value: {
278
+ writeText: jest.fn().mockRejectedValue(new Error("denied")),
279
+ },
280
+ writable: true,
281
+ configurable: true,
282
+ });
283
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
284
+ await act(async () => {
285
+ fireEvent.click(
286
+ screen.getByRole("button", { name: "Copy to clipboard" })
287
+ );
288
+ });
289
+ const msg = screen.getByText("Copy failed.");
290
+ expect(msg).toBeInTheDocument();
291
+ expect(msg).toHaveAttribute("aria-live", "assertive");
292
+ expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
293
+ });
294
+ });
295
+
296
+ describe("Clear button", () => {
297
+ it("is disabled when textarea is empty", () => {
298
+ render(<JsonField setting={setting} />);
299
+ expect(
300
+ screen.getByRole("button", { name: "Clear field" })
301
+ ).toBeDisabled();
302
+ });
303
+
304
+ it("is disabled when field is disabled", () => {
305
+ render(<JsonField setting={setting} value={{ a: 1 }} disabled={true} />);
306
+ expect(
307
+ screen.getByRole("button", { name: "Clear field" })
308
+ ).toBeDisabled();
309
+ });
310
+
311
+ it("is disabled when field is readOnly", () => {
312
+ render(<JsonField setting={setting} value={{ a: 1 }} readOnly={true} />);
313
+ expect(
314
+ screen.getByRole("button", { name: "Clear field" })
315
+ ).toBeDisabled();
316
+ });
317
+
318
+ it("clears the textarea when clicked", () => {
319
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
320
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
321
+ expect(ta.value).not.toBe("");
322
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
323
+ expect(ta.value).toBe("");
324
+ });
325
+
326
+ it("calls onChange with null when clicked", () => {
327
+ const onChange = jest.fn();
328
+ render(
329
+ <JsonField setting={setting} value={{ a: 1 }} onChange={onChange} />
330
+ );
331
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
332
+ expect(onChange).toHaveBeenCalledWith(null);
333
+ });
334
+
335
+ it("clears inline JSON error when clicked", () => {
336
+ render(<JsonField setting={setting} />);
337
+ const ta = screen.getByLabelText("JSON Setting");
338
+ fireEvent.change(ta, { target: { value: "bad json" } });
339
+ expect(
340
+ screen.queryByText(/unexpected token|expected/i)
341
+ ).toBeInTheDocument();
342
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
343
+ expect(
344
+ screen.queryByText(/unexpected token|expected/i)
345
+ ).not.toBeInTheDocument();
346
+ });
347
+
348
+ it("shows 'Cleared! Ctrl-Z / Cmd-Z to recover.' message after clicking", () => {
349
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
350
+ expect(screen.queryByText(/Cleared!/i)).not.toBeInTheDocument();
351
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
352
+ expect(
353
+ screen.getByText("Cleared! Ctrl-Z / Cmd-Z to recover.")
354
+ ).toBeInTheDocument();
355
+ });
356
+
357
+ it("hides 'Cleared!' message after timeout", () => {
358
+ jest.useFakeTimers();
359
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
360
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
361
+ expect(
362
+ screen.getByText("Cleared! Ctrl-Z / Cmd-Z to recover.")
363
+ ).toBeInTheDocument();
364
+ act(() => jest.advanceTimersByTime(5000));
365
+ expect(screen.queryByText(/Cleared!/i)).not.toBeInTheDocument();
366
+ jest.useRealTimers();
367
+ });
368
+
369
+ it("hides 'Cleared!' message when user starts typing", () => {
370
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
371
+ const ta = screen.getByLabelText("JSON Setting");
372
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
373
+ expect(
374
+ screen.getByText("Cleared! Ctrl-Z / Cmd-Z to recover.")
375
+ ).toBeInTheDocument();
376
+ fireEvent.change(ta, { target: { value: "42" } });
377
+ expect(screen.queryByText(/Cleared!/i)).not.toBeInTheDocument();
378
+ });
379
+ });
380
+
381
+ describe("undo after clear (Ctrl+Z)", () => {
382
+ it("restores content after clicking Clear then pressing Ctrl+Z", () => {
383
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
384
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
385
+ const original = ta.value;
386
+
387
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
388
+ expect(ta.value).toBe("");
389
+
390
+ fireEvent.keyDown(ta, { key: "z", ctrlKey: true });
391
+ expect(ta.value).toBe(original);
392
+ });
393
+
394
+ it("hides 'Cleared!' message on undo", () => {
395
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
396
+ const ta = screen.getByLabelText("JSON Setting");
397
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
398
+ expect(
399
+ screen.getByText("Cleared! Ctrl-Z / Cmd-Z to recover.")
400
+ ).toBeInTheDocument();
401
+ fireEvent.keyDown(ta, { key: "z", ctrlKey: true });
402
+ expect(screen.queryByText(/Cleared!/i)).not.toBeInTheDocument();
403
+ });
404
+
405
+ it("restores content using Cmd+Z (macOS)", () => {
406
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
407
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
408
+ const original = ta.value;
409
+
410
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
411
+ fireEvent.keyDown(ta, { key: "z", metaKey: true });
412
+ expect(ta.value).toBe(original);
413
+ });
414
+
415
+ it("calls onChange with restored value on undo", () => {
416
+ const onChange = jest.fn();
417
+ const obj = { a: 1 };
418
+ render(<JsonField setting={setting} value={obj} onChange={onChange} />);
419
+ const ta = screen.getByLabelText("JSON Setting");
420
+
421
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
422
+ onChange.mockClear();
423
+ fireEvent.keyDown(ta, { key: "z", ctrlKey: true });
424
+ expect(onChange).toHaveBeenCalledWith(obj);
425
+ });
426
+
427
+ it("does not undo when no clear has occurred", () => {
428
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
429
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
430
+ const original = ta.value;
431
+
432
+ fireEvent.keyDown(ta, { key: "z", ctrlKey: true });
433
+ expect(ta.value).toBe(original);
434
+ });
435
+
436
+ it("discards undo state once the user starts typing after a clear", () => {
437
+ render(<JsonField setting={setting} value={{ a: 1 }} />);
438
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
439
+
440
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
441
+ fireEvent.change(ta, { target: { value: "42" } });
442
+ fireEvent.keyDown(ta, { key: "z", ctrlKey: true });
443
+ // Should stay at "42", not jump back to original
444
+ expect(ta.value).toBe("42");
445
+ });
446
+
447
+ it("restores partial/invalid JSON text on undo without throwing", () => {
448
+ render(<JsonField setting={setting} />);
449
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
450
+
451
+ fireEvent.change(ta, { target: { value: '{"partial":' } });
452
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
453
+ expect(ta.value).toBe("");
454
+
455
+ fireEvent.keyDown(ta, { key: "z", ctrlKey: true });
456
+ expect(ta.value).toBe('{"partial":');
457
+ expect(
458
+ screen.queryByText(/unexpected token|expected/i)
459
+ ).toBeInTheDocument();
460
+ });
461
+
462
+ it("does not call onChange when restored text is invalid JSON", () => {
463
+ const onChange = jest.fn();
464
+ render(<JsonField setting={setting} onChange={onChange} />);
465
+ const ta = screen.getByLabelText("JSON Setting");
466
+
467
+ fireEvent.change(ta, { target: { value: '{"partial":' } });
468
+ fireEvent.click(screen.getByRole("button", { name: "Clear field" }));
469
+ onChange.mockClear();
470
+
471
+ fireEvent.keyDown(ta, { key: "z", ctrlKey: true });
472
+ expect(onChange).not.toHaveBeenCalled();
473
+ });
474
+ });
475
+
476
+ describe("clear()", () => {
477
+ it("resets text and clears parse error", () => {
478
+ const ref = React.createRef<JsonFieldHandle>();
479
+ render(<JsonField setting={setting} ref={ref} value={{ a: 1 }} />);
480
+ const ta = screen.getByLabelText("JSON Setting") as HTMLTextAreaElement;
481
+
482
+ expect(ta.value).toBe(JSON.stringify({ a: 1 }, null, 2));
483
+
484
+ fireEvent.change(ta, { target: { value: "bad json" } });
485
+ expect(
486
+ screen.queryByText(/unexpected token|expected/i)
487
+ ).toBeInTheDocument();
488
+
489
+ act(() => {
490
+ ref.current!.clear();
491
+ });
492
+
493
+ expect(ta.value).toBe("");
494
+ expect(
495
+ screen.queryByText(/unexpected token|expected/i)
496
+ ).not.toBeInTheDocument();
497
+ });
498
+
499
+ it("calls onChange with null when cleared programmatically", () => {
500
+ const onChange = jest.fn();
501
+ const ref = React.createRef<JsonFieldHandle>();
502
+ render(
503
+ <JsonField
504
+ setting={setting}
505
+ ref={ref}
506
+ value={{ a: 1 }}
507
+ onChange={onChange}
508
+ />
509
+ );
510
+
511
+ act(() => {
512
+ ref.current!.clear();
513
+ });
514
+
515
+ expect(onChange).toHaveBeenCalledWith(null);
516
+ });
517
+ });
518
+ });
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { render, screen } from "@testing-library/react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import ProtocolFormField from "../../../src/components/ProtocolFormField";
5
5
 
@@ -9,6 +9,61 @@ import ProtocolFormField from "../../../src/components/ProtocolFormField";
9
9
  // Those tests should eventually be migrated here and
10
10
  // adapted to the Jest/React Testing Library paradigm.
11
11
 
12
+ describe("ProtocolFormField — json type", () => {
13
+ const jsonSetting = {
14
+ key: "config",
15
+ label: "Config JSON",
16
+ description: "<p>JSON configuration</p>",
17
+ type: "json",
18
+ };
19
+
20
+ it("renders a textarea (not an input) for json type", () => {
21
+ const value = { enabled: true, count: 3 };
22
+ render(
23
+ <ProtocolFormField setting={jsonSetting} disabled={false} value={value} />
24
+ );
25
+ const ta = screen.getByLabelText("Config JSON") as HTMLTextAreaElement;
26
+ expect(ta.tagName).toBe("TEXTAREA");
27
+ expect(ta.value).toBe(JSON.stringify(value, null, 2));
28
+ });
29
+
30
+ it("renders empty textarea for null value", () => {
31
+ render(
32
+ <ProtocolFormField setting={jsonSetting} disabled={false} value={null} />
33
+ );
34
+ const ta = screen.getByLabelText("Config JSON") as HTMLTextAreaElement;
35
+ expect(ta.value).toBe("");
36
+ });
37
+
38
+ it("getValue() returns parsed object", () => {
39
+ const ref = React.createRef<ProtocolFormField>();
40
+ render(
41
+ <ProtocolFormField setting={jsonSetting} disabled={false} ref={ref} />
42
+ );
43
+ const ta = screen.getByLabelText("Config JSON");
44
+ fireEvent.change(ta, { target: { value: '{"x":42}' } });
45
+ expect(ref.current!.getValue()).toEqual({ x: 42 });
46
+ });
47
+
48
+ it("clear() resets the textarea", () => {
49
+ const ref = React.createRef<ProtocolFormField>();
50
+ render(
51
+ <ProtocolFormField
52
+ setting={jsonSetting}
53
+ disabled={false}
54
+ ref={ref}
55
+ value={{ x: 42 }}
56
+ />
57
+ );
58
+ const ta = screen.getByLabelText("Config JSON") as HTMLTextAreaElement;
59
+ expect(ta.value).not.toBe("");
60
+ act(() => {
61
+ ref.current!.clear();
62
+ });
63
+ expect(ta.value).toBe("");
64
+ });
65
+ });
66
+
12
67
  describe("ProtocolFormField", () => {
13
68
  it("renders date-picker setting", async () => {
14
69
  const user = userEvent.setup();