@thepalaceproject/circulation-admin 1.43.0 → 1.44.0-post.3

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.
Files changed (133) hide show
  1. package/CLAUDE.md +28 -7
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/eslint.config.mjs +9 -1
  5. package/jest.config.js +22 -8
  6. package/package.json +6 -20
  7. package/tests/jest/actions.test.ts +962 -0
  8. package/tests/jest/components/AccountPage.test.tsx +41 -0
  9. package/tests/jest/components/AdvancedSearchBooleanFilter.test.tsx +231 -0
  10. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +93 -20
  11. package/tests/jest/components/AdvancedSearchFilter.test.tsx +142 -0
  12. package/tests/jest/components/AdvancedSearchFilterInput.test.tsx +212 -0
  13. package/tests/jest/components/AdvancedSearchFilterViewer.test.tsx +52 -0
  14. package/tests/jest/components/AdvancedSearchValueFilter.test.tsx +123 -0
  15. package/tests/jest/components/Announcement.test.tsx +76 -0
  16. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  17. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  18. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  19. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  20. package/tests/jest/components/BookDetails.test.tsx +133 -0
  21. package/tests/jest/components/BookDetailsContainer.test.tsx +116 -0
  22. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  23. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  24. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  25. package/tests/jest/components/BookEditor.test.tsx +20 -21
  26. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  27. package/tests/jest/components/CatalogServices.test.tsx +100 -0
  28. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  29. package/tests/jest/components/CirculationEventsDownloadForm.test.tsx +41 -0
  30. package/tests/jest/components/Classifications.test.tsx +166 -0
  31. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  32. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  33. package/tests/jest/components/Collections.test.tsx +335 -7
  34. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  35. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  36. package/tests/jest/components/Complaints.test.tsx +259 -0
  37. package/tests/jest/components/ConfigPage.test.tsx +58 -0
  38. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  39. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  40. package/tests/jest/components/Contributors.test.tsx +157 -0
  41. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  42. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  43. package/tests/jest/components/CustomListPage.test.tsx +57 -0
  44. package/tests/jest/components/CustomListSearch.test.tsx +275 -0
  45. package/tests/jest/components/CustomLists.test.tsx +621 -4
  46. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  47. package/tests/jest/components/CustomListsSidebar.test.tsx +251 -0
  48. package/tests/jest/components/DashboardPage.test.tsx +76 -0
  49. package/tests/jest/components/DebugAuthentication.test.tsx +1 -5
  50. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  51. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  52. package/tests/jest/components/DiagnosticsTabContainer.test.tsx +147 -0
  53. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  54. package/tests/jest/components/EditableConfigList.test.tsx +606 -9
  55. package/tests/jest/components/EditableInput.test.tsx +250 -1
  56. package/tests/jest/components/EditorField.test.tsx +95 -0
  57. package/tests/jest/components/EntryPointsContainer.test.tsx +97 -0
  58. package/tests/jest/components/EntryPointsTabs.test.tsx +88 -0
  59. package/tests/jest/components/ErrorMessage.test.tsx +97 -0
  60. package/tests/jest/components/Footer.test.tsx +141 -0
  61. package/tests/jest/components/GenreForm.test.tsx +152 -0
  62. package/tests/jest/components/Header.test.tsx +421 -0
  63. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  64. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  65. package/tests/jest/components/InputList.test.tsx +493 -0
  66. package/tests/jest/components/InventoryReportRequestModal.test.tsx +16 -42
  67. package/tests/jest/components/JsonField.test.tsx +20 -0
  68. package/tests/jest/components/Lane.test.tsx +132 -10
  69. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  70. package/tests/jest/components/LaneEditor.test.tsx +377 -13
  71. package/tests/jest/components/LanePage.test.tsx +68 -0
  72. package/tests/jest/components/Lanes.test.tsx +446 -0
  73. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  74. package/tests/jest/components/LanguageField.test.tsx +40 -0
  75. package/tests/jest/components/Libraries.test.tsx +48 -0
  76. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  77. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  78. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  79. package/tests/jest/components/LoadButton.test.tsx +49 -0
  80. package/tests/jest/components/ManagePatrons.test.tsx +73 -0
  81. package/tests/jest/components/ManagePatronsForm.test.tsx +180 -0
  82. package/tests/jest/components/ManagePatronsTabContainer.test.tsx +143 -0
  83. package/tests/jest/components/MetadataServices.test.tsx +95 -0
  84. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  85. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  86. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  87. package/tests/jest/components/PatronBlockingRulesEditor.test.tsx +3 -1
  88. package/tests/jest/components/PatronInfo.test.tsx +54 -0
  89. package/tests/jest/components/ProtocolFormField.test.tsx +479 -7
  90. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  91. package/tests/jest/components/SaveButton.test.tsx +47 -0
  92. package/tests/jest/components/SelfTestResult.test.tsx +164 -0
  93. package/tests/jest/components/SelfTests.test.tsx +601 -0
  94. package/tests/jest/components/SelfTestsCategory.test.tsx +200 -0
  95. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  96. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  97. package/tests/jest/components/ServiceWithRegistrationsEditForm.test.tsx +110 -0
  98. package/tests/jest/components/SetupPage.test.tsx +41 -0
  99. package/tests/jest/components/SingleStatListItem.test.tsx +62 -0
  100. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  101. package/tests/jest/components/Stats.test.tsx +4 -15
  102. package/tests/jest/components/TabContainer.test.tsx +68 -0
  103. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  104. package/tests/jest/components/Timestamp.test.tsx +74 -0
  105. package/tests/jest/components/ToolTip.test.tsx +46 -0
  106. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  107. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  108. package/tests/jest/components/TroubleshootingTabContainer.test.tsx +68 -0
  109. package/tests/jest/components/UpdatingLoader.test.tsx +36 -0
  110. package/tests/jest/components/WelcomePage.test.tsx +43 -0
  111. package/tests/jest/components/WithEditButton.test.tsx +55 -0
  112. package/tests/jest/components/WithRemoveButton.test.tsx +95 -0
  113. package/tests/jest/editorAdapter.test.ts +245 -0
  114. package/tests/jest/features/book.test.ts +75 -80
  115. package/tests/jest/fixtures/classificationsData.ts +45 -0
  116. package/tests/jest/fixtures/genreData.ts +811 -0
  117. package/tests/jest/index.test.tsx +50 -0
  118. package/tests/jest/models/Admin.test.ts +206 -0
  119. package/tests/jest/reducers/bookCoverPreview.test.ts +81 -0
  120. package/tests/jest/reducers/classifications.test.ts +161 -0
  121. package/tests/jest/reducers/collections.test.ts +96 -0
  122. package/tests/jest/reducers/complaints.test.ts +166 -0
  123. package/tests/jest/reducers/createFetchEditReducer.test.ts +220 -0
  124. package/tests/jest/reducers/createRegisterLibraryReducer.test.ts +51 -0
  125. package/tests/jest/reducers/customListDetails.test.ts +120 -0
  126. package/tests/jest/reducers/customListEditor.test.ts +2823 -0
  127. package/tests/jest/reducers/managePatrons.test.ts +71 -0
  128. package/tests/jest/testUtils/formDataShim.test.ts +135 -0
  129. package/tests/jest/testUtils/formDataShim.ts +112 -0
  130. package/tests/jest/utils/sharedFunctions.test.ts +121 -0
  131. package/tsconfig.json +3 -4
  132. package/testReporter.js +0 -29
  133. package/tests/jest/README.md +0 -6
@@ -1,13 +1,15 @@
1
1
  import * as React from "react";
2
- import { render, screen, fireEvent, act } from "@testing-library/react";
2
+ import {
3
+ render,
4
+ screen,
5
+ fireEvent,
6
+ act,
7
+ within,
8
+ waitFor,
9
+ } from "@testing-library/react";
3
10
  import userEvent from "@testing-library/user-event";
4
11
  import ProtocolFormField from "../../../src/components/ProtocolFormField";
5
-
6
- // NB: This file adds / duplicates existing tests from:
7
- // - `src/components/__tests__/ProtocolFormField-test.tsx`.
8
- //
9
- // Those tests should eventually be migrated here and
10
- // adapted to the Jest/React Testing Library paradigm.
12
+ import { MONOSPACE_FONT_STACK } from "../../../src/fonts";
11
13
 
12
14
  describe("ProtocolFormField — json type", () => {
13
15
  const jsonSetting = {
@@ -64,6 +66,41 @@ describe("ProtocolFormField — json type", () => {
64
66
  });
65
67
  });
66
68
 
69
+ describe("ProtocolFormField — use_monospace_font", () => {
70
+ (
71
+ [
72
+ { id: "text-input", use_monospace_font: true, hasStyle: true },
73
+ {
74
+ id: "textarea",
75
+ type: "textarea",
76
+ use_monospace_font: true,
77
+ hasStyle: true,
78
+ },
79
+ { id: "absent", hasStyle: false },
80
+ ] as {
81
+ id: string;
82
+ type?: string;
83
+ use_monospace_font?: boolean;
84
+ hasStyle: boolean;
85
+ }[]
86
+ ).forEach(({ id, type, use_monospace_font, hasStyle }) => {
87
+ it(id, () => {
88
+ render(
89
+ <ProtocolFormField
90
+ setting={{ key: "f", label: "My Field", type, use_monospace_font }}
91
+ disabled={false}
92
+ />
93
+ );
94
+ const el = screen.getByLabelText("My Field");
95
+ if (hasStyle) {
96
+ expect(el).toHaveStyle({ fontFamily: MONOSPACE_FONT_STACK });
97
+ } else {
98
+ expect(el).not.toHaveStyle({ fontFamily: MONOSPACE_FONT_STACK });
99
+ }
100
+ });
101
+ });
102
+ });
103
+
67
104
  describe("ProtocolFormField", () => {
68
105
  it("renders date-picker setting", async () => {
69
106
  const user = userEvent.setup();
@@ -90,3 +127,438 @@ describe("ProtocolFormField", () => {
90
127
  expect(input.value).toBe(testDate);
91
128
  });
92
129
  });
130
+
131
+ // These render each setting `type` and assert the rendered control via the DOM,
132
+ // reading uncontrolled values through the component's real ref-based `getValue()`
133
+ // / `clear()` API (the same API production calls), like the json-type tests above.
134
+ describe("ProtocolFormField — setting types", () => {
135
+ const baseSetting = {
136
+ key: "setting",
137
+ label: "label",
138
+ description: "<p>description</p>",
139
+ };
140
+
141
+ const menuOptions = ["A", "B", "C"].map((x) => (
142
+ <option key={x} value={x} aria-selected={false}>
143
+ {x}
144
+ </option>
145
+ ));
146
+
147
+ it("renders a text setting and reads/clears its value via ref", () => {
148
+ const ref = React.createRef<ProtocolFormField>();
149
+ render(
150
+ <ProtocolFormField
151
+ setting={baseSetting}
152
+ disabled={false}
153
+ value="test"
154
+ ref={ref}
155
+ />
156
+ );
157
+ const input = screen.getByLabelText("label") as HTMLInputElement;
158
+ expect(input.tagName).toBe("INPUT");
159
+ expect(input.value).toBe("test");
160
+ expect(ref.current!.getValue()).toBe("test");
161
+
162
+ act(() => {
163
+ ref.current!.clear();
164
+ });
165
+ expect(input.value).toBe("");
166
+ });
167
+
168
+ it("shows an (Optional) prefix on the description of a non-required field", () => {
169
+ const { container } = render(
170
+ <ProtocolFormField setting={baseSetting} disabled={false} />
171
+ );
172
+ expect(container.querySelector(".description")).toHaveTextContent(
173
+ "(Optional) description"
174
+ );
175
+ });
176
+
177
+ it("renders a setting with a default value", () => {
178
+ render(
179
+ <ProtocolFormField
180
+ setting={{ ...baseSetting, default: "default" }}
181
+ disabled={false}
182
+ />
183
+ );
184
+ expect((screen.getByLabelText("label") as HTMLInputElement).value).toBe(
185
+ "default"
186
+ );
187
+ });
188
+
189
+ it("renders a number setting", () => {
190
+ render(
191
+ <ProtocolFormField
192
+ setting={{ ...baseSetting, type: "number" }}
193
+ disabled={false}
194
+ value="42"
195
+ />
196
+ );
197
+ expect((screen.getByLabelText("label") as HTMLInputElement).value).toBe(
198
+ "42"
199
+ );
200
+ });
201
+
202
+ it("renders a textarea setting", () => {
203
+ render(
204
+ <ProtocolFormField
205
+ setting={{ ...baseSetting, type: "textarea" }}
206
+ disabled={false}
207
+ value="test"
208
+ />
209
+ );
210
+ const ta = screen.getByLabelText("label") as HTMLTextAreaElement;
211
+ expect(ta.tagName).toBe("TEXTAREA");
212
+ expect(ta.value).toBe("test");
213
+ });
214
+
215
+ it("renders a select setting with its options", () => {
216
+ render(
217
+ <ProtocolFormField
218
+ setting={{
219
+ ...baseSetting,
220
+ type: "select",
221
+ options: [
222
+ { key: "option1", label: "option 1" },
223
+ { key: "option2", label: "option 2" },
224
+ ],
225
+ }}
226
+ disabled={false}
227
+ />
228
+ );
229
+ const select = screen.getByLabelText("label") as HTMLSelectElement;
230
+ const options = within(select).getAllByRole(
231
+ "option"
232
+ ) as HTMLOptionElement[];
233
+ expect(options).toHaveLength(2);
234
+ expect(options[0].value).toBe("option1");
235
+ expect(options[0]).toHaveTextContent("option 1");
236
+ expect(options[1].value).toBe("option2");
237
+ expect(options[1]).toHaveTextContent("option 2");
238
+ });
239
+
240
+ it("renders a randomizable setting whose button generates a 32-char value", async () => {
241
+ const user = userEvent.setup();
242
+ const ref = React.createRef<ProtocolFormField>();
243
+ render(
244
+ <ProtocolFormField
245
+ setting={{ ...baseSetting, randomizable: true }}
246
+ disabled={false}
247
+ ref={ref}
248
+ />
249
+ );
250
+ const button = screen.getByRole("button", { name: /random/i });
251
+ await user.click(button);
252
+ const value = ref.current!.getValue();
253
+ expect(value).toHaveLength(32);
254
+ });
255
+
256
+ it("renders an image setting with a preview img", () => {
257
+ const { container } = render(
258
+ <ProtocolFormField
259
+ setting={{ ...baseSetting, type: "image" }}
260
+ disabled={false}
261
+ value="image data"
262
+ />
263
+ );
264
+ const img = container.querySelector("img");
265
+ expect(img).not.toBeNull();
266
+ expect(img!.getAttribute("src")).toBe("image data");
267
+ });
268
+
269
+ it("renders a color-picker setting with its label and default value", () => {
270
+ const { container } = render(
271
+ <ProtocolFormField
272
+ setting={{ ...baseSetting, type: "color-picker", default: "#aaaaaa" }}
273
+ disabled={false}
274
+ />
275
+ );
276
+ expect(screen.getByText("label")).toBeInTheDocument();
277
+ const hidden = container.querySelector(
278
+ 'input[type="hidden"]'
279
+ ) as HTMLInputElement;
280
+ expect(hidden.value).toBe("#aaaaaa");
281
+ });
282
+
283
+ it("renders a list setting and reads its value via ref", () => {
284
+ const ref = React.createRef<ProtocolFormField>();
285
+ render(
286
+ <ProtocolFormField
287
+ setting={{ ...baseSetting, type: "list" }}
288
+ disabled={false}
289
+ value={["item 1", "item 2"]}
290
+ ref={ref}
291
+ />
292
+ );
293
+ // The InputList renders an "Add" button.
294
+ expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument();
295
+ expect(ref.current!.getValue()).toEqual(["item 1", "item 2"]);
296
+ });
297
+
298
+ it("optionally renders instructions for a list setting", () => {
299
+ const { container } = render(
300
+ <ProtocolFormField
301
+ setting={{
302
+ ...baseSetting,
303
+ type: "list",
304
+ instructions: "<ul><li>Step 1</li></ul>",
305
+ }}
306
+ disabled={false}
307
+ />
308
+ );
309
+ const instructions = container.querySelector(".well");
310
+ expect(instructions).not.toBeNull();
311
+ expect(instructions).toHaveClass("description");
312
+ expect(instructions).toHaveTextContent("Step 1");
313
+ });
314
+
315
+ it("renders a menu setting as a select", () => {
316
+ const ref = React.createRef<ProtocolFormField>();
317
+ const { container } = render(
318
+ <ProtocolFormField
319
+ setting={{ ...baseSetting, type: "menu", menuOptions }}
320
+ disabled={false}
321
+ value={[]}
322
+ altValue="Alternate"
323
+ ref={ref}
324
+ />
325
+ );
326
+ expect(container.querySelector("select")).not.toBeNull();
327
+ expect(ref.current!.getValue()).toEqual([]);
328
+ // With no items selected, the InputList shows the alt text in their place.
329
+ expect(screen.getByText("Alternate")).toBeInTheDocument();
330
+ });
331
+
332
+ it("renders a hidden setting inside an invisible wrapper and reads its value via ref", () => {
333
+ const ref = React.createRef<ProtocolFormField>();
334
+ render(
335
+ <ProtocolFormField
336
+ setting={{ ...baseSetting, hidden: true }}
337
+ disabled={false}
338
+ value="test"
339
+ ref={ref}
340
+ />
341
+ );
342
+ // The hidden element is wrapped in a visually-hidden div.
343
+ const wrapper = screen.getByLabelText("label").closest("div[style]");
344
+ expect(wrapper).toHaveStyle({ visibility: "hidden" });
345
+ expect(ref.current!.getValue()).toBe("test");
346
+ });
347
+
348
+ it("reads the value of a hidden list setting via ref", () => {
349
+ const ref = React.createRef<ProtocolFormField>();
350
+ render(
351
+ <ProtocolFormField
352
+ setting={{ ...baseSetting, hidden: true, type: "list" }}
353
+ disabled={false}
354
+ value={["item 1", "item 2"]}
355
+ ref={ref}
356
+ />
357
+ );
358
+ expect(ref.current!.getValue()).toEqual(["item 1", "item 2"]);
359
+ });
360
+
361
+ it("resolves a hidden select value: first option, then default, then explicit value", () => {
362
+ const options = [
363
+ { key: "option1", label: "option 1" },
364
+ { key: "option2", label: "option 2" },
365
+ { key: "option3", label: "option 3" },
366
+ ];
367
+ const selectValue = (extra: Record<string, unknown>, value?: string) => {
368
+ const ref = React.createRef<ProtocolFormField>();
369
+ render(
370
+ <ProtocolFormField
371
+ setting={
372
+ {
373
+ ...baseSetting,
374
+ hidden: true,
375
+ type: "select",
376
+ options,
377
+ ...extra,
378
+ } as any
379
+ }
380
+ disabled={false}
381
+ value={value}
382
+ ref={ref}
383
+ />
384
+ );
385
+ return ref.current!.getValue();
386
+ };
387
+
388
+ // No value and no default -> the first option.
389
+ expect(selectValue({})).toBe("option1");
390
+ // A default with no value -> the default.
391
+ expect(selectValue({ default: "option3" })).toBe("option3");
392
+ // An explicit value -> that value, overriding the default.
393
+ expect(selectValue({ default: "option3" }, "option2")).toBe("option2");
394
+ });
395
+
396
+ // Hidden settings render the same element inside an invisible wrapper, so each
397
+ // variant must still surface its value through its ref (elementRef for the
398
+ // scalar inputs, colorPickerRef for the color picker, inputListRef for the
399
+ // menu; hidden text and hidden list are covered above).
400
+ it.each([
401
+ {
402
+ name: "date-picker",
403
+ extra: { type: "date-picker" },
404
+ value: "2020-06-01",
405
+ },
406
+ { name: "number", extra: { type: "number" }, value: "42" },
407
+ { name: "textarea", extra: { type: "textarea" }, value: "some text" },
408
+ { name: "color-picker", extra: { type: "color-picker" }, value: "#123456" },
409
+ { name: "randomizable", extra: { randomizable: true }, value: "test" },
410
+ { name: "menu", extra: { type: "menu", menuOptions }, value: ["A"] },
411
+ ])(
412
+ "reads a hidden $name setting's value back via its ref",
413
+ ({ extra, value }) => {
414
+ const ref = React.createRef<ProtocolFormField>();
415
+ render(
416
+ <ProtocolFormField
417
+ setting={{ ...baseSetting, hidden: true, ...extra } as any}
418
+ disabled={false}
419
+ value={value as any}
420
+ ref={ref}
421
+ />
422
+ );
423
+ expect(ref.current!.getValue()).toEqual(value);
424
+ }
425
+ );
426
+
427
+ it("falls back to the setting's default for a hidden setting with no value", () => {
428
+ const ref = React.createRef<ProtocolFormField>();
429
+ render(
430
+ <ProtocolFormField
431
+ setting={
432
+ { ...baseSetting, hidden: true, default: "the default" } as any
433
+ }
434
+ disabled={false}
435
+ ref={ref}
436
+ />
437
+ );
438
+ expect(ref.current!.getValue()).toBe("the default");
439
+ });
440
+
441
+ it("does not submit a stale value through a hidden image setting", () => {
442
+ const ref = React.createRef<ProtocolFormField>();
443
+ const { container } = render(
444
+ <ProtocolFormField
445
+ setting={{ ...baseSetting, hidden: true, type: "image" } as any}
446
+ disabled={false}
447
+ value="data:image/png;base64,abc"
448
+ ref={ref}
449
+ />
450
+ );
451
+ // An image setting is a file input whose value is forced to undefined, so it
452
+ // submits nothing; the current value is surfaced as a preview instead.
453
+ expect(ref.current!.getValue()).toBe("");
454
+ expect(container.querySelector("img")).toHaveAttribute(
455
+ "src",
456
+ "data:image/png;base64,abc"
457
+ );
458
+ });
459
+
460
+ it("forwards onChange to the underlying EditableInput", async () => {
461
+ const user = userEvent.setup();
462
+ const onChange = jest.fn();
463
+ render(
464
+ <ProtocolFormField
465
+ setting={baseSetting}
466
+ disabled={false}
467
+ onChange={onChange}
468
+ />
469
+ );
470
+ await user.type(screen.getByLabelText("label"), "x");
471
+ expect(onChange).toHaveBeenCalled();
472
+ });
473
+
474
+ it("forwards onChange to the InputList (a list setting reports add/remove)", async () => {
475
+ const user = userEvent.setup();
476
+ const onChange = jest.fn();
477
+ render(
478
+ <ProtocolFormField
479
+ setting={{ ...baseSetting, type: "list" }}
480
+ disabled={false}
481
+ value={[]}
482
+ onChange={onChange}
483
+ />
484
+ );
485
+ await user.type(screen.getByRole("textbox"), "new item");
486
+ await user.click(screen.getByRole("button", { name: /add/i }));
487
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
488
+ });
489
+
490
+ it("forwards readOnly to the InputList (list item inputs are read-only)", () => {
491
+ render(
492
+ <ProtocolFormField
493
+ setting={{ ...baseSetting, type: "list" }}
494
+ disabled={false}
495
+ value={["item 1"]}
496
+ readOnly
497
+ />
498
+ );
499
+ expect(
500
+ (screen.getByDisplayValue("item 1") as HTMLInputElement).readOnly
501
+ ).toBe(true);
502
+ });
503
+
504
+ // A `menu` setting also goes through InputList, but its add-control is built by
505
+ // InputList.renderMenu(), which does not restate onChange/readOnly — it relies
506
+ // on them arriving through createEditableInput. That is a different path from
507
+ // the `list` add-control above, so it needs its own coverage.
508
+ it("forwards onChange to the InputList menu control", async () => {
509
+ const user = userEvent.setup();
510
+ const onChange = jest.fn();
511
+ render(
512
+ <ProtocolFormField
513
+ setting={{ ...baseSetting, type: "menu", menuOptions } as any}
514
+ disabled={false}
515
+ value={[]}
516
+ onChange={onChange}
517
+ />
518
+ );
519
+
520
+ await user.selectOptions(screen.getByRole("combobox"), "B");
521
+
522
+ expect(onChange).toHaveBeenCalled();
523
+ });
524
+
525
+ it("forwards readOnly to the InputList menu control", () => {
526
+ const onChange = jest.fn();
527
+ render(
528
+ <ProtocolFormField
529
+ setting={{ ...baseSetting, type: "menu", menuOptions } as any}
530
+ disabled={false}
531
+ value={[]}
532
+ readOnly
533
+ onChange={onChange}
534
+ />
535
+ );
536
+
537
+ // A <select> ignores the readonly attribute, so dispatch the change directly:
538
+ // the component's own readOnly guard is what must stop onChange from firing.
539
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "B" } });
540
+
541
+ expect(onChange).not.toHaveBeenCalled();
542
+ });
543
+
544
+ it("forwards disableButton to the InputList (the Add button is disabled)", () => {
545
+ // Use a menu: for the other types InputList also disables Add whenever its
546
+ // text input is empty, which would mask whether disableButton was forwarded.
547
+ const renderMenu = (extra: Record<string, unknown> = {}) =>
548
+ render(
549
+ <ProtocolFormField
550
+ setting={{ ...baseSetting, type: "menu", menuOptions } as any}
551
+ disabled={false}
552
+ value={[]}
553
+ {...extra}
554
+ />
555
+ );
556
+
557
+ const { unmount } = renderMenu();
558
+ expect(screen.getByRole("button", { name: /add/i })).not.toBeDisabled();
559
+ unmount();
560
+
561
+ renderMenu({ disableButton: true });
562
+ expect(screen.getByRole("button", { name: /add/i })).toBeDisabled();
563
+ });
564
+ });