@thepalaceproject/circulation-admin 1.43.0 → 1.44.0

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
@@ -0,0 +1,493 @@
1
+ import * as React from "react";
2
+ import { render, act, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import ProtocolFormField from "../../../src/components/ProtocolFormField";
6
+
7
+ // InputList is always rendered by ProtocolFormField, which supplies the real
8
+ // `createEditableInput` and `labelAndDescription` callbacks it depends on.
9
+ // Rendering through ProtocolFormField exercises that wiring while letting us
10
+ // assert the DOM the InputList actually produces. The imperative `getValue()` /
11
+ // `clear()` API is exposed through ProtocolFormField's ref, which is how
12
+ // production calls it.
13
+
14
+ const setting = {
15
+ key: "setting",
16
+ label: "label",
17
+ description: "description",
18
+ type: "list",
19
+ };
20
+ const value = ["Thing 1", "Thing 2"];
21
+
22
+ const renderList = (
23
+ { setting: s = setting, value: v = value, ...rest }: any = {},
24
+ ref?: React.Ref<ProtocolFormField>
25
+ ) =>
26
+ render(
27
+ <ProtocolFormField
28
+ ref={ref}
29
+ setting={s}
30
+ value={v}
31
+ disabled={false}
32
+ {...rest}
33
+ />
34
+ );
35
+
36
+ // The list-item inputs live in the `ul.input-list-ul`; the "add" input lives in
37
+ // `.add-list-item-container`.
38
+ const itemInputs = (container: HTMLElement) =>
39
+ Array.from(
40
+ container.querySelectorAll<HTMLInputElement>(
41
+ "ul.input-list-ul input.form-control"
42
+ )
43
+ );
44
+ const addInput = (container: HTMLElement) =>
45
+ container.querySelector<HTMLInputElement>(
46
+ ".add-list-item-container span.add-list-item input"
47
+ );
48
+ const addButton = (container: HTMLElement) =>
49
+ container.querySelector<HTMLButtonElement>("button.add-list-item");
50
+ const removeButtons = (container: HTMLElement) =>
51
+ container.querySelectorAll("button.remove-btn");
52
+ const listItemWrappers = (container: HTMLElement) =>
53
+ container.querySelectorAll(".with-remove-button");
54
+
55
+ describe("InputList", () => {
56
+ it("renders a label and description", () => {
57
+ const { container } = renderList();
58
+ const labels = container.querySelectorAll("label");
59
+ expect(labels).toHaveLength(1);
60
+ expect(labels[0]).toHaveTextContent("label");
61
+
62
+ // The first `.description` is the one produced by labelAndDescription.
63
+ const description = container.querySelectorAll(".description")[0];
64
+ expect(description).toHaveTextContent("description");
65
+ });
66
+
67
+ it("renders a list of items", () => {
68
+ const { container } = renderList();
69
+ const inputs =
70
+ container.querySelectorAll<HTMLInputElement>("input.form-control");
71
+ // Two existing items plus the empty "add" input.
72
+ expect(inputs).toHaveLength(3);
73
+
74
+ expect(inputs[0]).toHaveValue("Thing 1");
75
+ expect(inputs[0]).toHaveAttribute("type", "text");
76
+ expect(inputs[0]).toHaveAttribute("name", "setting");
77
+
78
+ expect(inputs[1]).toHaveValue("Thing 2");
79
+ expect(inputs[1]).toHaveAttribute("type", "text");
80
+ expect(inputs[1]).toHaveAttribute("name", "setting");
81
+
82
+ expect(inputs[2]).toHaveValue("");
83
+ expect(inputs[2]).toHaveAttribute("type", "text");
84
+ expect(inputs[2]).toHaveAttribute("name", "setting");
85
+ });
86
+
87
+ it("optionally renders links", () => {
88
+ const urlBase = (itemName: string) => `admin/web/${itemName}`;
89
+ const { container } = renderList({ setting: { ...setting, urlBase } });
90
+ const links = container.querySelectorAll("a");
91
+ expect(links).toHaveLength(2);
92
+ links.forEach((link, idx) => {
93
+ expect(link).toHaveTextContent(`Thing ${idx + 1}`);
94
+ expect(link).toHaveAttribute("href", `admin/web/Thing ${idx + 1}`);
95
+ });
96
+ });
97
+
98
+ it("renders WithRemoveButton elements", () => {
99
+ const { container } = renderList();
100
+ const wrappers = listItemWrappers(container);
101
+ expect(wrappers).toHaveLength(2);
102
+ const removes = removeButtons(container);
103
+ expect(removes).toHaveLength(2);
104
+ removes.forEach((btn) => expect(btn).not.toBeDisabled());
105
+
106
+ const inputs = itemInputs(container);
107
+ expect(inputs[0]).toHaveValue("Thing 1");
108
+ expect(inputs[1]).toHaveValue("Thing 2");
109
+ });
110
+
111
+ it("optionally disables the remove buttons", () => {
112
+ const { container } = renderList({ disableButton: true });
113
+ const removes = removeButtons(container);
114
+ expect(removes).toHaveLength(2);
115
+ removes.forEach((btn) => expect(btn).toBeDisabled());
116
+ });
117
+
118
+ it("renders a button for adding a list item", () => {
119
+ const { container } = renderList();
120
+ const addContainer = container.querySelector(".add-list-item-container");
121
+ expect(addContainer).toBeInTheDocument();
122
+ expect(addInput(container)).toHaveValue("");
123
+ // The add container itself has no remove buttons.
124
+ expect(addContainer.querySelectorAll(".with-remove-button")).toHaveLength(
125
+ 0
126
+ );
127
+ expect(addButton(container)).toHaveTextContent("Add");
128
+ });
129
+
130
+ it("optionally renders a geographic tooltip with extra content", () => {
131
+ const valueWithObject = [{ "Thing 3": "extra information!" }];
132
+ const geographicSetting = { ...setting, format: "geographic" };
133
+ const { container } = renderList({
134
+ setting: geographicSetting,
135
+ value: valueWithObject,
136
+ });
137
+
138
+ const withAddOn = container.querySelectorAll(".with-add-on");
139
+ expect(withAddOn).toHaveLength(1);
140
+
141
+ const addOn = withAddOn[0].querySelectorAll(".input-group-addon");
142
+ expect(addOn).toHaveLength(1);
143
+
144
+ expect(container.querySelector("svg.locatorIcon")).toBeInTheDocument();
145
+
146
+ const toolTip = container.querySelector(".tool-tip");
147
+ expect(toolTip).toHaveClass("point-right");
148
+ expect(toolTip).toHaveTextContent("extra information!");
149
+
150
+ const input = withAddOn[0].querySelector("input");
151
+ expect(input).toHaveValue("Thing 3");
152
+ });
153
+
154
+ it("renders an autocomplete field for languages", () => {
155
+ const languageSetting = { ...setting, format: "language-code" };
156
+ const languages = {
157
+ eng: ["English"],
158
+ spa: ["Spanish", "Castilian"],
159
+ };
160
+ const { container } = renderList({
161
+ setting: languageSetting,
162
+ value: ["abc"],
163
+ additionalData: languages,
164
+ });
165
+ const languageInputs = container.querySelectorAll<HTMLInputElement>(
166
+ 'input[list="setting-autocomplete-list"]'
167
+ );
168
+ // One for the existing "abc" item, one for the empty "add" field.
169
+ expect(languageInputs).toHaveLength(2);
170
+
171
+ expect(languageInputs[0]).toHaveValue("abc");
172
+ expect(languageInputs[0]).toHaveAttribute("name", "setting");
173
+
174
+ expect(languageInputs[1]).toHaveValue("");
175
+ expect(languageInputs[1]).toHaveAttribute("name", "setting");
176
+ });
177
+
178
+ it("removes an item", async () => {
179
+ const user = userEvent.setup();
180
+ const { container } = renderList();
181
+ expect(listItemWrappers(container)).toHaveLength(2);
182
+
183
+ await user.click(removeButtons(container)[0]);
184
+
185
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(1));
186
+ });
187
+
188
+ it("adds a regular item", async () => {
189
+ const user = userEvent.setup();
190
+ const { container } = renderList();
191
+ expect(listItemWrappers(container)).toHaveLength(2);
192
+
193
+ await user.type(addInput(container), "Another thing...");
194
+ await user.click(addButton(container));
195
+
196
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
197
+ expect(addInput(container)).toHaveValue("");
198
+ });
199
+
200
+ it("adds and capitalizes an item", async () => {
201
+ const user = userEvent.setup();
202
+ // capitalize only runs when the setting has capitalize set; the geographic
203
+ // format is what makes two-letter tokens uppercase.
204
+ const capSetting = { ...setting, capitalize: true, format: "geographic" };
205
+ const { container } = renderList({ setting: capSetting, value: [] });
206
+
207
+ await user.type(addInput(container), "california");
208
+ await user.click(addButton(container));
209
+ await waitFor(() =>
210
+ expect(itemInputs(container)[0]).toHaveValue("California")
211
+ );
212
+
213
+ await user.type(addInput(container), "fl");
214
+ await user.click(addButton(container));
215
+ await waitFor(() => expect(itemInputs(container)[1]).toHaveValue("FL"));
216
+
217
+ await user.type(addInput(container), "new york city, ny");
218
+ await user.click(addButton(container));
219
+ await waitFor(() =>
220
+ expect(itemInputs(container)[2]).toHaveValue("New York City, NY")
221
+ );
222
+
223
+ expect(addInput(container)).toHaveValue("");
224
+ });
225
+
226
+ it("adds an autocompleted item", async () => {
227
+ const user = userEvent.setup();
228
+ const languageSetting = { ...setting, format: "language-code" };
229
+ const { container } = renderList({
230
+ setting: languageSetting,
231
+ value: ["abc"],
232
+ });
233
+
234
+ let autocompletes = container.querySelectorAll<HTMLInputElement>(
235
+ 'input[list="setting-autocomplete-list"]'
236
+ );
237
+ expect(autocompletes).toHaveLength(2);
238
+
239
+ await user.type(autocompletes[1], "Another language");
240
+ await user.click(addButton(container));
241
+
242
+ await waitFor(() => {
243
+ autocompletes = container.querySelectorAll<HTMLInputElement>(
244
+ 'input[list="setting-autocomplete-list"]'
245
+ );
246
+ expect(autocompletes).toHaveLength(3);
247
+ });
248
+ expect(autocompletes[2]).toHaveValue("");
249
+ });
250
+
251
+ it("does not add an empty input item", async () => {
252
+ const user = userEvent.setup();
253
+ const { container } = renderList();
254
+ expect(listItemWrappers(container)).toHaveLength(2);
255
+ // The add button is disabled while the input is blank.
256
+ expect(addButton(container)).toBeDisabled();
257
+
258
+ await user.type(addInput(container), "something new");
259
+ expect(addButton(container)).not.toBeDisabled();
260
+
261
+ await user.click(addButton(container));
262
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
263
+ // Once added, the input is cleared and the button is disabled again.
264
+ expect(addButton(container)).toBeDisabled();
265
+ });
266
+
267
+ it("optionally accepts an onChange prop", async () => {
268
+ const user = userEvent.setup();
269
+ const onChange = jest.fn();
270
+ const { container } = renderList({ onChange });
271
+ expect(onChange).not.toHaveBeenCalled();
272
+
273
+ await user.type(addInput(container), "test");
274
+ await user.click(addButton(container));
275
+ await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
276
+ expect(onChange.mock.calls[0][0]).toHaveProperty("listItems");
277
+
278
+ await user.click(removeButtons(container)[0]);
279
+ await waitFor(() => expect(onChange).toHaveBeenCalledTimes(2));
280
+ expect(onChange.mock.calls[1][0]).toHaveProperty("listItems");
281
+ });
282
+
283
+ it("optionally accepts a readOnly prop", () => {
284
+ const { container } = renderList({ readOnly: true });
285
+ const inputs = itemInputs(container);
286
+ expect(inputs[0]).toHaveAttribute("readonly");
287
+ expect(inputs[1]).toHaveAttribute("readonly");
288
+ });
289
+
290
+ it("gets the value", () => {
291
+ const ref = React.createRef<ProtocolFormField>();
292
+ renderList({}, ref);
293
+ expect(ref.current!.getValue()).toStrictEqual(value);
294
+ });
295
+
296
+ it("clears all the items", () => {
297
+ const ref = React.createRef<ProtocolFormField>();
298
+ const { container } = renderList({}, ref);
299
+ expect(listItemWrappers(container)).toHaveLength(2);
300
+
301
+ act(() => {
302
+ ref.current!.clear();
303
+ });
304
+
305
+ expect(listItemWrappers(container)).toHaveLength(0);
306
+ });
307
+
308
+ describe("dropdown menu", () => {
309
+ const menuOptions = [1, 2, 3].map((n) => (
310
+ <option value={`Option ${n}`} key={n} aria-selected={false}>
311
+ {`Option ${n}`}
312
+ </option>
313
+ ));
314
+ const menuSetting = {
315
+ ...setting,
316
+ type: "menu",
317
+ menuOptions,
318
+ description: null,
319
+ };
320
+
321
+ it("renders a dropdown menu", () => {
322
+ const { container } = renderList({ setting: menuSetting });
323
+ const menu = container.querySelectorAll("select");
324
+ expect(menu).toHaveLength(1);
325
+ const options = menu[0].querySelectorAll("option");
326
+ expect(options).toHaveLength(3);
327
+ options.forEach((option, idx) => {
328
+ expect(option).toHaveTextContent(`Option ${idx + 1}`);
329
+ });
330
+ });
331
+
332
+ it("adds an item from the menu", async () => {
333
+ const user = userEvent.setup();
334
+ const { container } = renderList({ setting: menuSetting });
335
+ expect(listItemWrappers(container)).toHaveLength(2);
336
+ // The select defaults to the first option.
337
+ expect(container.querySelector("select")).toHaveValue("Option 1");
338
+
339
+ await user.click(addButton(container));
340
+
341
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
342
+ const inputs = itemInputs(container);
343
+ expect(inputs[inputs.length - 1]).toHaveValue("Option 1");
344
+ });
345
+
346
+ it("doesn't lose options when nothing is selected", () => {
347
+ const narrowSetting = {
348
+ ...menuSetting,
349
+ default: null,
350
+ format: "narrow",
351
+ };
352
+ const { container } = renderList({
353
+ setting: narrowSetting,
354
+ value: null,
355
+ });
356
+ expect(container.querySelectorAll("option")).toHaveLength(3);
357
+ });
358
+
359
+ it("optionally eliminates already-selected options from the menu", async () => {
360
+ const user = userEvent.setup();
361
+ const { container, rerender } = renderList({ setting: menuSetting });
362
+ let options = container.querySelectorAll("option");
363
+ expect(options).toHaveLength(3);
364
+ expect(
365
+ Array.from(options).some((o) => o.textContent === "Option 1")
366
+ ).toBe(true);
367
+
368
+ // Adding an option while the format is not "narrow" leaves the menu intact.
369
+ await user.click(addButton(container));
370
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
371
+ options = container.querySelectorAll("option");
372
+ expect(options).toHaveLength(3);
373
+
374
+ // Switch to the "narrow" format, which prunes selected options.
375
+ const narrowSetting = { ...menuSetting, format: "narrow" };
376
+ rerender(
377
+ <ProtocolFormField
378
+ setting={narrowSetting}
379
+ value={value}
380
+ disabled={false}
381
+ />
382
+ );
383
+
384
+ await user.click(addButton(container));
385
+ await waitFor(() => {
386
+ options = container.querySelectorAll("option");
387
+ expect(options).toHaveLength(2);
388
+ });
389
+ expect(
390
+ Array.from(container.querySelectorAll("option")).some(
391
+ (o) => o.textContent === "Option 1"
392
+ )
393
+ ).toBe(false);
394
+ });
395
+
396
+ it("optionally renders a label", () => {
397
+ const settingWithLabel = {
398
+ ...menuSetting,
399
+ menuTitle: "Custom Menu Title",
400
+ };
401
+ const { container } = renderList({ setting: settingWithLabel });
402
+ const label = container.querySelector("select").closest("label");
403
+ expect(label).toHaveTextContent("Custom Menu Title");
404
+ });
405
+
406
+ it("optionally marks the menu as required", () => {
407
+ const { container, rerender } = renderList({ setting: menuSetting });
408
+ expect(container.querySelectorAll(".required-field")).toHaveLength(0);
409
+
410
+ const requiredSetting = {
411
+ ...menuSetting,
412
+ menuTitle: "title",
413
+ required: true,
414
+ };
415
+ rerender(
416
+ <ProtocolFormField
417
+ setting={requiredSetting}
418
+ value={value}
419
+ disabled={false}
420
+ />
421
+ );
422
+ const required = container.querySelectorAll(".required-field");
423
+ expect(required).toHaveLength(1);
424
+ expect(required[0]).toHaveTextContent("Required");
425
+ });
426
+
427
+ it("optionally renders an alternate value if there are no list items", () => {
428
+ const { container, rerender } = renderList({
429
+ setting: menuSetting,
430
+ altValue: "No list items!",
431
+ });
432
+ // There are still list items, so the placeholder isn't rendered yet.
433
+ expect(container.querySelectorAll(".input-list > span")).toHaveLength(0);
434
+
435
+ rerender(
436
+ <ProtocolFormField
437
+ setting={menuSetting}
438
+ value={[]}
439
+ altValue="No list items!"
440
+ disabled={false}
441
+ />
442
+ );
443
+ const placeholder = container.querySelectorAll(".input-list > span");
444
+ expect(placeholder).toHaveLength(1);
445
+ expect(placeholder[0]).toHaveTextContent("No list items!");
446
+ });
447
+
448
+ it("optionally renders an alternate value if all the available list items have already been added", () => {
449
+ const narrowSetting = { ...menuSetting, format: "narrow" };
450
+ const { container } = renderList({
451
+ setting: narrowSetting,
452
+ value: ["Option 1", "Option 2", "Option 3"],
453
+ onEmpty: "You've run out of options!",
454
+ });
455
+ expect(container.querySelector("select")).not.toBeInTheDocument();
456
+ const message = container.querySelector(".add-list-item-container span");
457
+ expect(message).toHaveTextContent("You've run out of options!");
458
+ });
459
+
460
+ it("renders option elements if necessary", () => {
461
+ // Without a menuOptions property, InputList builds the menu from `options`.
462
+ const options = [
463
+ { key: "key_1", label: "label_1" },
464
+ { key: "key_2", label: "label_2" },
465
+ ];
466
+ const optionsSetting = { ...menuSetting, options, menuOptions: null };
467
+ const { container } = renderList({ setting: optionsSetting });
468
+ const select = container.querySelector("select");
469
+ expect(select).toBeInTheDocument();
470
+ const opts = select.querySelectorAll("option");
471
+ expect(opts[0]).toHaveValue("key_1");
472
+ expect(opts[0]).toHaveTextContent("label_1");
473
+ expect(opts[1]).toHaveValue("key_2");
474
+ expect(opts[1]).toHaveTextContent("label_2");
475
+ });
476
+
477
+ it("renders the default values", () => {
478
+ const defaultSetting = {
479
+ ...menuSetting,
480
+ default: ["Option 1", "Option 2"],
481
+ };
482
+ const { container } = renderList({
483
+ setting: defaultSetting,
484
+ value: null,
485
+ });
486
+ const inputs = itemInputs(container);
487
+ expect(inputs[0]).toHaveValue("Option 1");
488
+ expect(inputs[1]).toHaveValue("Option 2");
489
+ // The remaining option is still available in the menu.
490
+ expect(container.querySelector("select")).toBeInTheDocument();
491
+ });
492
+ });
493
+ });
@@ -304,11 +304,7 @@ describe("InventoryReportRequestModal", () => {
304
304
  const NO_EMAIL_CONTEXT_PROVIDER_PROPS = { email: undefined };
305
305
 
306
306
  it("shows the modal when `show` is true", async () => {
307
- const {
308
- getAllByRole,
309
- queryAllByRole,
310
- rerender,
311
- } = renderWithProviders(
307
+ const { getAllByRole, queryAllByRole, rerender } = renderWithProviders(
312
308
  <InventoryReportRequestModal
313
309
  show={false}
314
310
  onHide={onHide}
@@ -385,19 +381,15 @@ describe("InventoryReportRequestModal", () => {
385
381
  let show = true;
386
382
  onHide.mockImplementation(() => (show = false));
387
383
 
388
- const {
389
- getAllByRole,
390
- getByRole,
391
- queryAllByRole,
392
- rerender,
393
- } = renderWithProviders(
394
- <InventoryReportRequestModal
395
- show={show}
396
- onHide={onHide}
397
- library={LIBRARY}
398
- />,
399
- { queryClient }
400
- );
384
+ const { getAllByRole, getByRole, queryAllByRole, rerender } =
385
+ renderWithProviders(
386
+ <InventoryReportRequestModal
387
+ show={show}
388
+ onHide={onHide}
389
+ library={LIBRARY}
390
+ />,
391
+ { queryClient }
392
+ );
401
393
  getAllByRole("dialog");
402
394
  expect(show).toBe(true);
403
395
  expect(onHide).not.toHaveBeenCalled();
@@ -420,10 +412,7 @@ describe("InventoryReportRequestModal", () => {
420
412
  });
421
413
 
422
414
  it("updates confirmation message, if report info request arrives in time", async () => {
423
- const {
424
- getByRole,
425
- rerender,
426
- } = renderWithProviders(
415
+ const { getByRole, rerender } = renderWithProviders(
427
416
  <InventoryReportRequestModal
428
417
  show={true}
429
418
  onHide={onHide}
@@ -457,10 +446,7 @@ describe("InventoryReportRequestModal", () => {
457
446
  )
458
447
  );
459
448
 
460
- const {
461
- getByRole,
462
- rerender,
463
- } = renderWithProviders(
449
+ const { getByRole, rerender } = renderWithProviders(
464
450
  <InventoryReportRequestModal
465
451
  show={true}
466
452
  onHide={onHide}
@@ -505,10 +491,7 @@ describe("InventoryReportRequestModal", () => {
505
491
  )
506
492
  );
507
493
 
508
- const {
509
- getByRole,
510
- rerender,
511
- } = renderWithProviders(
494
+ const { getByRole, rerender } = renderWithProviders(
512
495
  <InventoryReportRequestModal
513
496
  show={true}
514
497
  onHide={onHide}
@@ -538,10 +521,7 @@ describe("InventoryReportRequestModal", () => {
538
521
  it("requests report generation, if request confirmed", async () => {
539
522
  const user = userEvent.setup();
540
523
 
541
- const {
542
- getByRole,
543
- rerender,
544
- } = renderWithProviders(
524
+ const { getByRole, rerender } = renderWithProviders(
545
525
  <InventoryReportRequestModal
546
526
  show={true}
547
527
  onHide={onHide}
@@ -574,10 +554,7 @@ describe("InventoryReportRequestModal", () => {
574
554
  it("displays success message, when request is successful", async () => {
575
555
  const user = userEvent.setup();
576
556
 
577
- const {
578
- getByRole,
579
- rerender,
580
- } = renderWithProviders(
557
+ const { getByRole, rerender } = renderWithProviders(
581
558
  <InventoryReportRequestModal
582
559
  show={true}
583
560
  onHide={onHide}
@@ -615,10 +592,7 @@ describe("InventoryReportRequestModal", () => {
615
592
  );
616
593
  const user = userEvent.setup();
617
594
 
618
- const {
619
- getByRole,
620
- rerender,
621
- } = renderWithProviders(
595
+ const { getByRole, rerender } = renderWithProviders(
622
596
  <InventoryReportRequestModal
623
597
  show={true}
624
598
  onHide={onHide}
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { render, screen, act, fireEvent } from "@testing-library/react";
3
3
  import JsonField, { JsonFieldHandle } from "../../../src/components/JsonField";
4
+ import { MONOSPACE_FONT_STACK } from "../../../src/fonts";
4
5
 
5
6
  const setting = {
6
7
  key: "my_json",
@@ -472,6 +473,25 @@ describe("JsonField", () => {
472
473
  });
473
474
  });
474
475
 
476
+ describe("use_monospace_font", () => {
477
+ (
478
+ [
479
+ { id: "set", use_monospace_font: true, hasStyle: true },
480
+ { id: "absent", hasStyle: false },
481
+ ] as { id: string; use_monospace_font?: boolean; hasStyle: boolean }[]
482
+ ).forEach(({ id, use_monospace_font, hasStyle }) => {
483
+ it(id, () => {
484
+ render(<JsonField setting={{ ...setting, use_monospace_font }} />);
485
+ const ta = screen.getByLabelText("JSON Setting");
486
+ if (hasStyle) {
487
+ expect(ta).toHaveStyle({ fontFamily: MONOSPACE_FONT_STACK });
488
+ } else {
489
+ expect(ta).not.toHaveStyle({ fontFamily: MONOSPACE_FONT_STACK });
490
+ }
491
+ });
492
+ });
493
+ });
494
+
475
495
  describe("clear()", () => {
476
496
  it("resets text and clears parse error", () => {
477
497
  const ref = React.createRef<JsonFieldHandle>();