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

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,1092 @@
1
+ import * as React from "react";
2
+ import { installFormDataShim, FormDataShim } from "../testUtils/formDataShim";
3
+ import { render, within } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+
6
+ import ServiceEditForm, {
7
+ ServiceEditFormProps,
8
+ ServiceEditFormState,
9
+ } from "../../../src/components/ServiceEditForm";
10
+ import NeighborhoodAnalyticsForm from "../../../src/components/NeighborhoodAnalyticsForm";
11
+ import { ServicesData } from "../../../src/interfaces";
12
+ // The reusable-components `Form` builds `new FormData(formElement)` on
13
+ // submit, which the unit jsdom env's undici FormData rejects; install the
14
+ // shared shim that reads the form's successful controls.
15
+ installFormDataShim();
16
+ describe("ServiceEditForm", () => {
17
+ // ServiceEditForm is a generic class; expose a concrete constructor type so
18
+ // JSX works without generic inference.
19
+ const TestServiceEditForm: new (
20
+ props: ServiceEditFormProps<ServicesData>
21
+ ) => React.Component<
22
+ ServiceEditFormProps<ServicesData>,
23
+ ServiceEditFormState
24
+ > = ServiceEditForm;
25
+
26
+ let save: jest.Mock;
27
+ const urlBase = "/services";
28
+
29
+ const serviceData = {
30
+ id: 2,
31
+ protocol: "protocol 1",
32
+ settings: {
33
+ text_setting: "text setting",
34
+ select_setting: "option2",
35
+ },
36
+ libraries: [
37
+ {
38
+ short_name: "nypl",
39
+ library_text_setting: "library text setting",
40
+ library_select_setting: "option4",
41
+ },
42
+ ],
43
+ };
44
+ const parentProtocol = {
45
+ name: "protocol 3",
46
+ label: "protocol 3 label",
47
+ settings: [{ key: "parent_setting", label: "parent label" }],
48
+ child_settings: [{ key: "child_setting", label: "child label" }],
49
+ library_settings: [],
50
+ };
51
+ const protocolsData = [
52
+ {
53
+ name: "protocol 1",
54
+ label: "protocol 1 label",
55
+ description: "protocol 1 description",
56
+ sitewide: false,
57
+ settings: [
58
+ { key: "text_setting", label: "text label", optional: true },
59
+ {
60
+ key: "select_setting",
61
+ label: "select label",
62
+ type: "select",
63
+ options: [
64
+ { key: "option1", label: "option 1" },
65
+ { key: "option2", label: "option 2" },
66
+ ],
67
+ },
68
+ ],
69
+ library_settings: [
70
+ {
71
+ key: "library_text_setting",
72
+ label: "library text label",
73
+ optional: true,
74
+ },
75
+ {
76
+ key: "library_select_setting",
77
+ label: "library select label",
78
+ type: "select",
79
+ options: [
80
+ { key: "option3", label: "option 3" },
81
+ { key: "option4", label: "option 4" },
82
+ ],
83
+ },
84
+ ],
85
+ },
86
+ {
87
+ name: "protocol 2",
88
+ label: "protocol 2 label",
89
+ description: "protocol 2 description",
90
+ sitewide: true,
91
+ settings: [
92
+ { key: "text_setting", label: "text label" },
93
+ { key: "protocol2_setting", label: "protocol2 label" },
94
+ ],
95
+ library_settings: [],
96
+ },
97
+ {
98
+ name: "protocol with instructions",
99
+ label: "instructions label",
100
+ description: "click for instructions",
101
+ instructions: "Instructions!",
102
+ sitewide: false,
103
+ settings: [],
104
+ library_settings: [],
105
+ },
106
+ parentProtocol,
107
+ ];
108
+ const allLibraries = [
109
+ { short_name: "nypl", name: "New York Public Library" },
110
+ { short_name: "bpl", name: "Brooklyn Public Library" },
111
+ ];
112
+ const servicesData = {
113
+ services: [serviceData],
114
+ protocols: protocolsData,
115
+ allLibraries: allLibraries,
116
+ };
117
+
118
+ // Renders the form with the standard set of props; `props` overrides them.
119
+ const renderForm = (
120
+ props: Partial<ServiceEditFormProps<ServicesData>> = {}
121
+ ) =>
122
+ render(
123
+ <TestServiceEditForm
124
+ disabled={false}
125
+ data={servicesData}
126
+ save={save}
127
+ urlBase={urlBase}
128
+ listDataKey="services"
129
+ {...props}
130
+ />
131
+ );
132
+
133
+ // Re-renders (keeping the same instance) with the standard props + overrides.
134
+ const rerenderForm = (
135
+ rerender: (el: React.ReactElement) => void,
136
+ props: Partial<ServiceEditFormProps<ServicesData>> = {}
137
+ ) =>
138
+ rerender(
139
+ <TestServiceEditForm
140
+ disabled={false}
141
+ data={servicesData}
142
+ save={save}
143
+ urlBase={urlBase}
144
+ listDataKey="services"
145
+ {...props}
146
+ />
147
+ );
148
+
149
+ beforeEach(() => {
150
+ save = jest.fn();
151
+ });
152
+
153
+ describe("rendering", () => {
154
+ it("renders hidden id", () => {
155
+ const { container, rerender } = renderForm();
156
+ expect(container.querySelector('input[name="id"]')).toBeNull();
157
+
158
+ rerenderForm(rerender, { item: serviceData });
159
+ const input = container.querySelector(
160
+ 'input[name="id"]'
161
+ ) as HTMLInputElement;
162
+ expect(input.type).toBe("hidden");
163
+ expect(input.value).toBe("2");
164
+ });
165
+
166
+ it("renders protocol", () => {
167
+ // Starts with the first protocol in the list, and is editable (all
168
+ // protocols available as options).
169
+ const { container, unmount } = renderForm();
170
+ let select = container.querySelector(
171
+ 'select[name="protocol"]'
172
+ ) as HTMLSelectElement;
173
+ expect(select.value).toBe("protocol 1");
174
+ let options = select.querySelectorAll("option");
175
+ expect(options).toHaveLength(4);
176
+ expect(options[0]).toHaveTextContent("protocol 1 label");
177
+ expect(options[1]).toHaveTextContent("protocol 2 label");
178
+ expect(options[2]).toHaveTextContent("instructions label");
179
+ expect(
180
+ within(container).getByText("protocol 1 description")
181
+ ).toBeInTheDocument();
182
+
183
+ // When editing an existing service, the protocol can't be changed, so
184
+ // there is only a single option.
185
+ unmount();
186
+ const { container: c2 } = renderForm({ item: serviceData });
187
+ select = c2.querySelector('select[name="protocol"]') as HTMLSelectElement;
188
+ expect(select.value).toBe("protocol 1");
189
+ options = select.querySelectorAll("option");
190
+ expect(options).toHaveLength(1);
191
+ expect(options[0]).toHaveTextContent("protocol 1 label");
192
+ expect(
193
+ within(c2).getByText("protocol 1 description")
194
+ ).toBeInTheDocument();
195
+ });
196
+
197
+ it("renders protocol fields", () => {
198
+ const { container, unmount } = renderForm();
199
+ expect(
200
+ (
201
+ container.querySelector(
202
+ 'input[name="text_setting"]'
203
+ ) as HTMLInputElement
204
+ ).value
205
+ ).toBe("");
206
+ expect(
207
+ container.querySelector('select[name="select_setting"]')
208
+ ).toBeInTheDocument();
209
+ expect(container.querySelector('[name="protocol2_setting"]')).toBeNull();
210
+
211
+ unmount();
212
+ const { container: c2 } = renderForm({ item: serviceData });
213
+ expect(
214
+ (c2.querySelector('input[name="text_setting"]') as HTMLInputElement)
215
+ .value
216
+ ).toBe("text setting");
217
+ expect(
218
+ (c2.querySelector('select[name="select_setting"]') as HTMLSelectElement)
219
+ .value
220
+ ).toBe("option2");
221
+ expect(c2.querySelector('[name="protocol2_setting"]')).toBeNull();
222
+ });
223
+
224
+ it("renders a collapsible component", async () => {
225
+ const user = userEvent.setup();
226
+ const { container } = renderForm();
227
+ await user.selectOptions(
228
+ container.querySelector('select[name="protocol"]') as HTMLSelectElement,
229
+ "protocol with instructions"
230
+ );
231
+
232
+ const panels = container.querySelectorAll(".panel");
233
+ expect(panels).toHaveLength(3);
234
+ expect(panels[0].querySelector(".panel-title")).toHaveTextContent(
235
+ "click for instructions"
236
+ );
237
+ expect(panels[0].querySelector(".panel-body")).toHaveTextContent(
238
+ "Instructions!"
239
+ );
240
+ });
241
+
242
+ it("doesn't render parent dropdown for protocol with no child settings", () => {
243
+ const { container } = renderForm();
244
+ expect(container.querySelector('select[name="parent_id"]')).toBeNull();
245
+ });
246
+
247
+ it("doesn't render parent dropdown when there are no available parents", () => {
248
+ const newService = Object.assign({}, serviceData, {
249
+ protocol: "protocol 3",
250
+ });
251
+ const { container } = renderForm({ item: newService });
252
+ expect(container.querySelector('select[name="parent_id"]')).toBeNull();
253
+ });
254
+
255
+ it("renders parent dropdown for protocol with child settings and available parents", () => {
256
+ const parentService = Object.assign({}, serviceData, {
257
+ protocol: "protocol 3",
258
+ name: "Parent",
259
+ });
260
+ const childService = Object.assign({}, serviceData, {
261
+ protocol: "protocol 3",
262
+ id: 3,
263
+ name: "Child",
264
+ });
265
+ const servicesDataWithParent = Object.assign({}, servicesData, {
266
+ services: [parentService, childService],
267
+ });
268
+
269
+ const { container, unmount } = renderForm({
270
+ data: servicesDataWithParent,
271
+ item: childService,
272
+ });
273
+ let select = container.querySelector(
274
+ 'select[name="parent_id"]'
275
+ ) as HTMLSelectElement;
276
+ expect(select).toBeInTheDocument();
277
+ // No parent selected, so the "None" option (value "") is active.
278
+ expect(select.value).toBe("");
279
+ let options = select.querySelectorAll("option");
280
+ expect(options).toHaveLength(2);
281
+ expect(options[0]).toHaveTextContent("None");
282
+ expect(options[1]).toHaveTextContent("Parent");
283
+
284
+ unmount();
285
+ const childWithParent = Object.assign({}, childService, {
286
+ parent_id: parentService.id,
287
+ });
288
+ const { container: c2 } = renderForm({
289
+ data: servicesDataWithParent,
290
+ item: childWithParent,
291
+ });
292
+ select = c2.querySelector(
293
+ 'select[name="parent_id"]'
294
+ ) as HTMLSelectElement;
295
+ expect(select).toBeInTheDocument();
296
+ expect(select.value).toBe("2");
297
+ options = select.querySelectorAll("option");
298
+ expect(options).toHaveLength(2);
299
+ expect(options[0]).toHaveTextContent("None");
300
+ expect(options[1]).toHaveTextContent("Parent");
301
+ });
302
+
303
+ it("renders protocol fields for child", () => {
304
+ const parentService = Object.assign({}, serviceData, {
305
+ protocol: "protocol 3",
306
+ name: "Parent",
307
+ });
308
+ const childNoParent = Object.assign({}, serviceData, {
309
+ protocol: "protocol 3",
310
+ id: 3,
311
+ name: "Child",
312
+ settings: { ...serviceData.settings },
313
+ });
314
+ const servicesDataWithParent = Object.assign({}, servicesData, {
315
+ services: [parentService, childNoParent],
316
+ });
317
+
318
+ // Editing a "parent" service (no parent selected) shows the parent
319
+ // settings, not the child settings.
320
+ const { container, rerender, unmount } = renderForm({
321
+ data: servicesDataWithParent,
322
+ item: childNoParent,
323
+ });
324
+ expect(
325
+ (
326
+ container.querySelector(
327
+ 'input[name="parent_setting"]'
328
+ ) as HTMLInputElement
329
+ ).value
330
+ ).toBe("");
331
+ expect(container.querySelector('input[name="child_setting"]')).toBeNull();
332
+
333
+ rerenderForm(rerender, {
334
+ data: servicesDataWithParent,
335
+ item: {
336
+ ...childNoParent,
337
+ settings: {
338
+ ...childNoParent.settings,
339
+ parent_setting: "parent setting",
340
+ },
341
+ },
342
+ });
343
+ expect(
344
+ (
345
+ container.querySelector(
346
+ 'input[name="parent_setting"]'
347
+ ) as HTMLInputElement
348
+ ).value
349
+ ).toBe("parent setting");
350
+
351
+ // Once the service has a parent, only the child settings are shown.
352
+ unmount();
353
+ const childWithParent = {
354
+ ...childNoParent,
355
+ parent_id: parentService.id,
356
+ settings: { ...serviceData.settings },
357
+ };
358
+ const { container: c2, rerender: rerender2 } = renderForm({
359
+ data: servicesDataWithParent,
360
+ item: childWithParent,
361
+ });
362
+ expect(c2.querySelector('input[name="parent_setting"]')).toBeNull();
363
+ expect(
364
+ (c2.querySelector('input[name="child_setting"]') as HTMLInputElement)
365
+ .value
366
+ ).toBe("");
367
+
368
+ rerenderForm(rerender2, {
369
+ data: servicesDataWithParent,
370
+ item: {
371
+ ...childWithParent,
372
+ settings: {
373
+ ...childWithParent.settings,
374
+ child_setting: "child setting",
375
+ },
376
+ },
377
+ });
378
+ expect(
379
+ (c2.querySelector('input[name="child_setting"]') as HTMLInputElement)
380
+ .value
381
+ ).toBe("child setting");
382
+ });
383
+
384
+ it("doesn't render libraries for sitewide protocol without library settings", () => {
385
+ const servicesDataSitewide = Object.assign({}, servicesData, {
386
+ protocols: [servicesData.protocols[1]],
387
+ });
388
+
389
+ const { container, unmount } = renderForm({ data: servicesDataSitewide });
390
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
391
+
392
+ unmount();
393
+ const serviceDataSitewide = Object.assign({}, servicesData, {
394
+ libraries: [],
395
+ protocol: "",
396
+ });
397
+ const { container: c2 } = renderForm({
398
+ data: servicesDataSitewide,
399
+ item: serviceDataSitewide,
400
+ });
401
+ expect(c2.querySelectorAll(".with-remove-button")).toHaveLength(0);
402
+ });
403
+
404
+ it("renders libraries for a sitewide protocol with library settings", () => {
405
+ const servicesDataSitewide = Object.assign({}, servicesData, {
406
+ protocols: [
407
+ Object.assign({}, servicesData.protocols[1], { sitewide: true }),
408
+ ],
409
+ });
410
+
411
+ const { container } = renderForm({
412
+ data: servicesDataSitewide,
413
+ item: serviceData,
414
+ });
415
+ const libraries = container.querySelectorAll(".with-remove-button");
416
+ expect(libraries).toHaveLength(1);
417
+ expect(libraries[0]).toHaveTextContent("New York Public Library");
418
+ });
419
+
420
+ it("renders removable and editable libraries", () => {
421
+ const { container, unmount } = renderForm();
422
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
423
+
424
+ unmount();
425
+ const { container: c2 } = renderForm({ item: serviceData });
426
+ const libraries = c2.querySelectorAll(".with-remove-button");
427
+ expect(libraries).toHaveLength(1);
428
+ const editable = c2.querySelectorAll(".with-edit-button");
429
+ expect(editable).toHaveLength(1);
430
+ expect(editable[0]).toHaveTextContent("New York Public Library");
431
+ });
432
+
433
+ it("renders removable but not editable libraries", () => {
434
+ const { container, unmount } = renderForm();
435
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
436
+
437
+ unmount();
438
+ const newServiceData = Object.assign({}, serviceData, {
439
+ protocol: "protocol 3",
440
+ });
441
+ const { container: c2 } = renderForm({ item: newServiceData });
442
+ const libraries = c2.querySelectorAll(".with-remove-button");
443
+ expect(libraries).toHaveLength(1);
444
+ // protocol 3 has no library settings, so there is no edit button.
445
+ expect(c2.querySelectorAll(".with-edit-button")).toHaveLength(0);
446
+ expect(libraries[0]).toHaveTextContent("New York Public Library");
447
+ });
448
+
449
+ it("doesn't render library add dropdown for sitewide protocol", () => {
450
+ const servicesDataSitewide = Object.assign({}, servicesData, {
451
+ protocols: [servicesData.protocols[1]],
452
+ });
453
+
454
+ const { container, unmount } = renderForm({ data: servicesDataSitewide });
455
+ expect(container.querySelector('select[name="add-library"]')).toBeNull();
456
+
457
+ unmount();
458
+ const serviceDataSitewide = Object.assign({}, servicesData, {
459
+ libraries: [],
460
+ protocol: "",
461
+ });
462
+ const { container: c2 } = renderForm({
463
+ data: servicesDataSitewide,
464
+ item: serviceDataSitewide,
465
+ });
466
+ expect(c2.querySelector('select[name="add-library"]')).toBeNull();
467
+ });
468
+
469
+ it("renders library add dropdown for a sitewide protocol with library settings", () => {
470
+ const servicesDataSitewide = Object.assign({}, servicesData, {
471
+ protocols: [
472
+ Object.assign({}, servicesData.protocols[1], { sitewide: true }),
473
+ ],
474
+ });
475
+
476
+ const { container } = renderForm({
477
+ data: servicesDataSitewide,
478
+ item: serviceData,
479
+ });
480
+ expect(
481
+ container.querySelector('select[name="add-library"]')
482
+ ).toBeInTheDocument();
483
+ });
484
+
485
+ it("renders library add dropdown for a non-sitewide protocol", () => {
486
+ const { container, unmount } = renderForm();
487
+ let select = container.querySelector(
488
+ 'select[name="add-library"]'
489
+ ) as HTMLSelectElement;
490
+ expect(within(container).getByText("Add Library")).toBeInTheDocument();
491
+
492
+ let options = select.querySelectorAll("option");
493
+ expect(options).toHaveLength(3);
494
+ expect((options[0] as HTMLOptionElement).value).toBe("none");
495
+ expect((options[1] as HTMLOptionElement).value).toBe("nypl");
496
+ expect((options[2] as HTMLOptionElement).value).toBe("bpl");
497
+
498
+ // No library selected yet, so no per-library settings are rendered.
499
+ expect(
500
+ container.querySelector('input[name="library_text_setting"]')
501
+ ).toBeNull();
502
+ expect(
503
+ container.querySelector('select[name="library_select_setting"]')
504
+ ).toBeNull();
505
+
506
+ // When editing an existing service, the already-associated library
507
+ // (nypl) is not offered in the add dropdown.
508
+ unmount();
509
+ const { container: c2 } = renderForm({ item: serviceData });
510
+ select = c2.querySelector(
511
+ 'select[name="add-library"]'
512
+ ) as HTMLSelectElement;
513
+ expect(within(c2).getByText("Add Library")).toBeInTheDocument();
514
+ options = select.querySelectorAll("option");
515
+ expect(options).toHaveLength(2);
516
+ expect((options[0] as HTMLOptionElement).value).toBe("none");
517
+ expect((options[1] as HTMLOptionElement).value).toBe("bpl");
518
+ });
519
+
520
+ it("renders neighborhood analytics form for a relevant patron auth protocol", () => {
521
+ const { container, rerender } = renderForm();
522
+ expect(
523
+ within(container).queryByText(/Patron Neighborhood Analytics/)
524
+ ).not.toBeInTheDocument();
525
+
526
+ const patronAuthProtocol = {
527
+ ...protocolsData[0],
528
+ ...{ settings: [{ key: "neighborhood_mode", options: [] }] },
529
+ };
530
+ rerenderForm(rerender, {
531
+ extraFormKey: "neighborhood_mode",
532
+ extraFormSection: NeighborhoodAnalyticsForm,
533
+ // The pared-down protocol setting omits `label`, so the data is cast to
534
+ // `any` to bypass the prop type.
535
+ data: {
536
+ ...servicesData,
537
+ ...{ protocols: [patronAuthProtocol] },
538
+ } as any,
539
+ });
540
+ // The ": Disabled" suffix is unique to the panel title (the form's
541
+ // visually-hidden legend has the bare heading text too).
542
+ expect(
543
+ within(container).getByText(/Patron Neighborhood Analytics: Disabled/)
544
+ ).toBeInTheDocument();
545
+ });
546
+
547
+ it("renders neighborhood analytics form for a relevant analytics protocol", () => {
548
+ const { container, rerender } = renderForm();
549
+ expect(
550
+ within(container).queryByText(/Patron Neighborhood Analytics/)
551
+ ).not.toBeInTheDocument();
552
+
553
+ const analyticsProtocol = {
554
+ ...protocolsData[0],
555
+ ...{ settings: [{ key: "location_source", options: [] }] },
556
+ };
557
+ rerenderForm(rerender, {
558
+ extraFormKey: "location_source",
559
+ extraFormSection: NeighborhoodAnalyticsForm,
560
+ // The pared-down protocol setting omits `label`, so the data is cast to
561
+ // `any` to bypass the prop type.
562
+ data: { ...servicesData, ...{ protocols: [analyticsProtocol] } } as any,
563
+ });
564
+ expect(
565
+ within(container).getByText(/Patron Neighborhood Analytics: Disabled/)
566
+ ).toBeInTheDocument();
567
+ });
568
+
569
+ it("has a save button", () => {
570
+ const { container } = renderForm();
571
+ // The form's submit button (the collapsible panel headers are also
572
+ // <button>s, so this is the only reusable-components Button).
573
+ expect(
574
+ within(container).getByRole("button", { name: "Submit" })
575
+ ).toBeInTheDocument();
576
+ });
577
+ });
578
+
579
+ describe("behavior", () => {
580
+ it("changes fields and description when protocol changes", async () => {
581
+ const user = userEvent.setup();
582
+ const { container } = renderForm();
583
+
584
+ // Select a library so the library settings are shown.
585
+ await user.selectOptions(
586
+ container.querySelector(
587
+ 'select[name="add-library"]'
588
+ ) as HTMLSelectElement,
589
+ "nypl"
590
+ );
591
+
592
+ expect(
593
+ container.querySelector('input[name="text_setting"]')
594
+ ).toBeInTheDocument();
595
+ expect(
596
+ container.querySelector('select[name="select_setting"]')
597
+ ).toBeInTheDocument();
598
+ expect(
599
+ container.querySelector('input[name="library_text_setting"]')
600
+ ).toBeInTheDocument();
601
+ expect(
602
+ container.querySelector('select[name="library_select_setting"]')
603
+ ).toBeInTheDocument();
604
+ expect(container.querySelector('[name="protocol2_setting"]')).toBeNull();
605
+ expect(
606
+ within(container).getByText("protocol 1 description")
607
+ ).toBeInTheDocument();
608
+
609
+ // Switch to protocol 2.
610
+ await user.selectOptions(
611
+ container.querySelector('select[name="protocol"]') as HTMLSelectElement,
612
+ "protocol 2"
613
+ );
614
+ expect(
615
+ container.querySelector('input[name="text_setting"]')
616
+ ).toBeInTheDocument();
617
+ expect(
618
+ container.querySelector('select[name="select_setting"]')
619
+ ).toBeNull();
620
+ // protocol 2 is sitewide with no library settings, so the whole
621
+ // libraries section (and its fields) disappears.
622
+ expect(
623
+ container.querySelector('input[name="library_text_setting"]')
624
+ ).toBeNull();
625
+ expect(
626
+ container.querySelector('select[name="library_select_setting"]')
627
+ ).toBeNull();
628
+ expect(
629
+ container.querySelector('input[name="protocol2_setting"]')
630
+ ).toBeInTheDocument();
631
+ expect(
632
+ within(container).getByText("protocol 2 description")
633
+ ).toBeInTheDocument();
634
+
635
+ // Switch back to protocol 1.
636
+ await user.selectOptions(
637
+ container.querySelector('select[name="protocol"]') as HTMLSelectElement,
638
+ "protocol 1"
639
+ );
640
+ expect(
641
+ container.querySelector('input[name="text_setting"]')
642
+ ).toBeInTheDocument();
643
+ expect(
644
+ container.querySelector('select[name="select_setting"]')
645
+ ).toBeInTheDocument();
646
+ expect(
647
+ container.querySelector('input[name="library_text_setting"]')
648
+ ).toBeInTheDocument();
649
+ expect(
650
+ container.querySelector('select[name="library_select_setting"]')
651
+ ).toBeInTheDocument();
652
+ expect(container.querySelector('[name="protocol2_setting"]')).toBeNull();
653
+ expect(
654
+ within(container).getByText("protocol 1 description")
655
+ ).toBeInTheDocument();
656
+ });
657
+
658
+ it("changes fields when parent changes", async () => {
659
+ const user = userEvent.setup();
660
+ const parentService = Object.assign({}, serviceData, {
661
+ protocol: "protocol 3",
662
+ name: "Parent",
663
+ });
664
+ const servicesDataWithParent = Object.assign({}, servicesData, {
665
+ services: [parentService],
666
+ protocols: [parentProtocol],
667
+ });
668
+ const { container } = renderForm({ data: servicesDataWithParent });
669
+
670
+ expect(
671
+ container.querySelector('input[name="parent_setting"]')
672
+ ).toBeInTheDocument();
673
+ expect(container.querySelector('input[name="child_setting"]')).toBeNull();
674
+
675
+ // Selecting a parent switches to the child settings.
676
+ await user.selectOptions(
677
+ container.querySelector(
678
+ 'select[name="parent_id"]'
679
+ ) as HTMLSelectElement,
680
+ "2"
681
+ );
682
+ expect(
683
+ container.querySelector('input[name="parent_setting"]')
684
+ ).toBeNull();
685
+ expect(
686
+ container.querySelector('input[name="child_setting"]')
687
+ ).toBeInTheDocument();
688
+
689
+ // Clearing the parent switches back to the parent settings.
690
+ await user.selectOptions(
691
+ container.querySelector(
692
+ 'select[name="parent_id"]'
693
+ ) as HTMLSelectElement,
694
+ ""
695
+ );
696
+ expect(
697
+ container.querySelector('input[name="parent_setting"]')
698
+ ).toBeInTheDocument();
699
+ expect(container.querySelector('input[name="child_setting"]')).toBeNull();
700
+ });
701
+
702
+ it("adds a library with settings", async () => {
703
+ const user = userEvent.setup();
704
+ const { container } = renderForm();
705
+
706
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
707
+ expect(
708
+ container.querySelector('input[name="library_text_setting"]')
709
+ ).toBeNull();
710
+ expect(
711
+ container.querySelector('select[name="library_select_setting"]')
712
+ ).toBeNull();
713
+
714
+ const addLibrarySelect = () =>
715
+ container.querySelector(
716
+ 'select[name="add-library"]'
717
+ ) as HTMLSelectElement;
718
+
719
+ // Selecting a library reveals its settings fields...
720
+ await user.selectOptions(addLibrarySelect(), "bpl");
721
+ expect(
722
+ container.querySelector('input[name="library_text_setting"]')
723
+ ).toBeInTheDocument();
724
+ expect(
725
+ container.querySelector('select[name="library_select_setting"]')
726
+ ).toBeInTheDocument();
727
+
728
+ // ...and deselecting hides them again.
729
+ await user.selectOptions(addLibrarySelect(), "none");
730
+ expect(
731
+ container.querySelector('input[name="library_text_setting"]')
732
+ ).toBeNull();
733
+ expect(
734
+ container.querySelector('select[name="library_select_setting"]')
735
+ ).toBeNull();
736
+
737
+ // Re-select and fill out the settings.
738
+ await user.selectOptions(addLibrarySelect(), "bpl");
739
+ const textInput = container.querySelector(
740
+ 'input[name="library_text_setting"]'
741
+ ) as HTMLInputElement;
742
+ await user.clear(textInput);
743
+ await user.type(textInput, "library text");
744
+ await user.selectOptions(
745
+ container.querySelector(
746
+ 'select[name="library_select_setting"]'
747
+ ) as HTMLSelectElement,
748
+ "option4"
749
+ );
750
+
751
+ // The buttons live inside a collapsed (aria-hidden) panel, so click them
752
+ // by class rather than by accessible role.
753
+ await user.click(
754
+ container.querySelector("button.left-align") as HTMLElement
755
+ );
756
+
757
+ // The library is added, showing its name plus Delete and Edit controls.
758
+ const libraries = container.querySelectorAll(".with-remove-button");
759
+ expect(libraries).toHaveLength(1);
760
+ expect(libraries[0]).toHaveTextContent("Brooklyn Public Library");
761
+ expect(libraries[0]).toHaveTextContent("Delete");
762
+ expect(libraries[0]).toHaveTextContent("Edit");
763
+
764
+ // Expanding the library shows the settings that were saved with it,
765
+ // proving the added library retained its short_name and settings.
766
+ await user.click(
767
+ (libraries[0] as HTMLElement).querySelector(
768
+ "button.edit-btn"
769
+ ) as HTMLElement
770
+ );
771
+ const editSection = container.querySelector(
772
+ ".edit-library-settings"
773
+ ) as HTMLElement;
774
+ expect(
775
+ (
776
+ editSection.querySelector(
777
+ 'input[name="library_text_setting"]'
778
+ ) as HTMLInputElement
779
+ ).value
780
+ ).toBe("library text");
781
+ expect(
782
+ (
783
+ editSection.querySelector(
784
+ 'select[name="library_select_setting"]'
785
+ ) as HTMLSelectElement
786
+ ).value
787
+ ).toBe("option4");
788
+ // Collapse the edit section again.
789
+ await user.click(
790
+ (libraries[0] as HTMLElement).querySelector(
791
+ "button.edit-btn"
792
+ ) as HTMLElement
793
+ );
794
+
795
+ // Selecting a different library resets the add-new-library settings.
796
+ await user.selectOptions(addLibrarySelect(), "nypl");
797
+ expect(
798
+ (
799
+ container.querySelector(
800
+ 'input[name="library_text_setting"]'
801
+ ) as HTMLInputElement
802
+ ).value
803
+ ).toBe("");
804
+ expect(
805
+ (
806
+ container.querySelector(
807
+ 'select[name="library_select_setting"]'
808
+ ) as HTMLSelectElement
809
+ ).value
810
+ ).toBe("option3");
811
+ });
812
+
813
+ it("removes a library", async () => {
814
+ const user = userEvent.setup();
815
+ const { container } = renderForm({ item: serviceData });
816
+
817
+ let libraries = container.querySelectorAll(".with-remove-button");
818
+ expect(libraries).toHaveLength(1);
819
+ expect(libraries[0]).toHaveTextContent("New York Public Library");
820
+
821
+ // The remove button lives inside a collapsed (aria-hidden) panel, so
822
+ // click it by class rather than by accessible role.
823
+ await user.click(
824
+ (libraries[0] as HTMLElement).querySelector(
825
+ "button.remove-btn"
826
+ ) as HTMLElement
827
+ );
828
+
829
+ libraries = container.querySelectorAll(".with-remove-button");
830
+ expect(libraries).toHaveLength(0);
831
+ });
832
+
833
+ it("edits a library", async () => {
834
+ const user = userEvent.setup();
835
+ const { container } = renderForm({ item: serviceData });
836
+
837
+ const libraryItem = container.querySelector(
838
+ ".with-remove-button"
839
+ ) as HTMLElement;
840
+ expect(libraryItem).toHaveTextContent("New York Public Library");
841
+
842
+ // The edit button lives inside a collapsed (aria-hidden) panel, so click
843
+ // it by class rather than by accessible role.
844
+ const clickEdit = () =>
845
+ user.click(libraryItem.querySelector("button.edit-btn") as HTMLElement);
846
+
847
+ // Open the edit settings; they start with the library's current values.
848
+ await clickEdit();
849
+ let editSection = container.querySelector(
850
+ ".edit-library-settings"
851
+ ) as HTMLElement;
852
+ expect(editSection).toBeInTheDocument();
853
+ let textInput = editSection.querySelector(
854
+ 'input[name="library_text_setting"]'
855
+ ) as HTMLInputElement;
856
+ let selectInput = editSection.querySelector(
857
+ 'select[name="library_select_setting"]'
858
+ ) as HTMLSelectElement;
859
+ expect(textInput.value).toBe("library text setting");
860
+ expect(selectInput.value).toBe("option4");
861
+
862
+ // Change the values but collapse without saving.
863
+ await user.clear(textInput);
864
+ await user.type(textInput, "new library text");
865
+ await user.selectOptions(selectInput, "option3");
866
+ await clickEdit();
867
+ expect(container.querySelector(".edit-library-settings")).toBeNull();
868
+
869
+ // Reopening shows the original (unsaved) values.
870
+ await clickEdit();
871
+ editSection = container.querySelector(
872
+ ".edit-library-settings"
873
+ ) as HTMLElement;
874
+ textInput = editSection.querySelector(
875
+ 'input[name="library_text_setting"]'
876
+ ) as HTMLInputElement;
877
+ selectInput = editSection.querySelector(
878
+ 'select[name="library_select_setting"]'
879
+ ) as HTMLSelectElement;
880
+ expect(textInput.value).toBe("library text setting");
881
+ expect(selectInput.value).toBe("option4");
882
+
883
+ // This time change the values and click Save.
884
+ await user.clear(textInput);
885
+ await user.type(textInput, "new library text");
886
+ await user.selectOptions(selectInput, "option3");
887
+ await user.click(
888
+ editSection.querySelector("button.edit-library") as HTMLElement
889
+ );
890
+
891
+ // The library remains (short_name unchanged) and reopening the editor
892
+ // shows the newly saved values.
893
+ const libraries = container.querySelectorAll(".with-remove-button");
894
+ expect(libraries).toHaveLength(1);
895
+ await clickEdit();
896
+ editSection = container.querySelector(
897
+ ".edit-library-settings"
898
+ ) as HTMLElement;
899
+ expect(
900
+ (
901
+ editSection.querySelector(
902
+ 'input[name="library_text_setting"]'
903
+ ) as HTMLInputElement
904
+ ).value
905
+ ).toBe("new library text");
906
+ expect(
907
+ (
908
+ editSection.querySelector(
909
+ 'select[name="library_select_setting"]'
910
+ ) as HTMLSelectElement
911
+ ).value
912
+ ).toBe("option3");
913
+ });
914
+
915
+ it("calls save when the form is submitted", async () => {
916
+ // The reusable Form wires submission solely through its submit button's
917
+ // onClick, so clicking it is the only way to submit; assert the resulting
918
+ // save receives the built form data.
919
+ const user = userEvent.setup();
920
+ const { container } = renderForm();
921
+
922
+ await user.click(
923
+ within(container).getByRole("button", { name: "Submit" })
924
+ );
925
+ expect(save).toHaveBeenCalledTimes(1);
926
+ expect(save.mock.calls[0][0]).toBeInstanceOf(FormDataShim);
927
+ });
928
+
929
+ it("calls save on submit even if there is a collapsible panel", async () => {
930
+ const user = userEvent.setup();
931
+ const { container } = renderForm();
932
+
933
+ await user.selectOptions(
934
+ container.querySelector('select[name="protocol"]') as HTMLSelectElement,
935
+ "protocol with instructions"
936
+ );
937
+ expect(container.querySelectorAll(".panel")).toHaveLength(3);
938
+
939
+ await user.click(
940
+ within(container).getByRole("button", { name: "Submit" })
941
+ );
942
+ expect(save).toHaveBeenCalledTimes(1);
943
+ });
944
+
945
+ it("calls handleData", async () => {
946
+ // handleData has no direct DOM effect; observe it via the side effect it
947
+ // performs on submit — appending the JSON-encoded libraries to the data
948
+ // passed to `save`.
949
+ const user = userEvent.setup();
950
+ const { container } = renderForm({ item: serviceData });
951
+
952
+ await user.click(
953
+ within(container).getByRole("button", { name: "Submit" })
954
+ );
955
+
956
+ expect(save).toHaveBeenCalledTimes(1);
957
+ const formData = save.mock.calls[0][0];
958
+ expect(formData.get("libraries")).toBe(
959
+ JSON.stringify(serviceData.libraries)
960
+ );
961
+ });
962
+
963
+ it("submits data", async () => {
964
+ const user = userEvent.setup();
965
+ const { container } = renderForm({ item: serviceData });
966
+
967
+ await user.click(
968
+ within(container).getByRole("button", { name: "Submit" })
969
+ );
970
+
971
+ expect(save).toHaveBeenCalledTimes(1);
972
+ const formData = save.mock.calls[0][0];
973
+ expect(formData.get("id")).toBe("2");
974
+ expect(formData.get("protocol")).toBe("protocol 1");
975
+ expect(formData.get("text_setting")).toBe("text setting");
976
+ expect(formData.get("select_setting")).toBe("option2");
977
+ expect(formData.get("libraries")).toBe(
978
+ JSON.stringify(serviceData.libraries)
979
+ );
980
+ });
981
+
982
+ it("clears the form", async () => {
983
+ const user = userEvent.setup();
984
+ const { container, rerender } = renderForm();
985
+
986
+ const nameInput = () =>
987
+ container.querySelector('input[name="name"]') as HTMLInputElement;
988
+ await user.type(nameInput(), "new service");
989
+ expect(nameInput().value).toBe("new service");
990
+
991
+ // A successful response (no error) clears the form fields.
992
+ rerenderForm(rerender, { responseBody: "new service" });
993
+ expect(nameInput().value).toBe("");
994
+ });
995
+
996
+ it("doesn't clear the form if there's an error message", async () => {
997
+ const user = userEvent.setup();
998
+ const { container, rerender } = renderForm();
999
+
1000
+ const nameInput = () =>
1001
+ container.querySelector('input[name="name"]') as HTMLInputElement;
1002
+ await user.type(nameInput(), "new service");
1003
+ expect(nameInput().value).toBe("new service");
1004
+
1005
+ // A response accompanied by a fetch error must NOT clear the form.
1006
+ // `fetchError` is read by the component but is not a declared prop, so it
1007
+ // is passed via an untyped props object.
1008
+ const propsWithError: any = {
1009
+ disabled: false,
1010
+ data: servicesData,
1011
+ save,
1012
+ urlBase,
1013
+ listDataKey: "services",
1014
+ responseBody: "new service",
1015
+ fetchError: "ERROR",
1016
+ };
1017
+ rerender(<TestServiceEditForm {...propsWithError} />);
1018
+ expect(nameInput().value).toBe("new service");
1019
+ });
1020
+ });
1021
+
1022
+ describe("library removal confirmation", () => {
1023
+ // The base class's `isLibraryRemovalPermitted` always returns true and is a
1024
+ // documented override hook. Rather than stubbing a live instance method
1025
+ // (impossible in RTL), subclass the form to control the return value and
1026
+ // assert the observable outcome of clicking the real remove button.
1027
+ class AllowRemovalServiceEditForm extends ServiceEditForm<ServicesData> {
1028
+ isLibraryRemovalPermitted(): boolean {
1029
+ return true;
1030
+ }
1031
+ }
1032
+ class BlockRemovalServiceEditForm extends ServiceEditForm<ServicesData> {
1033
+ isLibraryRemovalPermitted(): boolean {
1034
+ return false;
1035
+ }
1036
+ }
1037
+
1038
+ const commonProps = () => ({
1039
+ disabled: false,
1040
+ data: servicesData,
1041
+ save,
1042
+ item: serviceData,
1043
+ urlBase,
1044
+ listDataKey: "services",
1045
+ });
1046
+
1047
+ const clickRemove = async (container: HTMLElement) => {
1048
+ const user = userEvent.setup();
1049
+ // The remove button lives inside a collapsed (aria-hidden) panel, so
1050
+ // click it by class rather than by accessible role.
1051
+ const removeButton = container.querySelector(
1052
+ ".with-remove-button button.remove-btn"
1053
+ ) as HTMLElement;
1054
+ await user.click(removeButton);
1055
+ };
1056
+
1057
+ it("should allow removal by default", async () => {
1058
+ // Base class: removal permitted by default.
1059
+ const { container } = render(<TestServiceEditForm {...commonProps()} />);
1060
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(1);
1061
+
1062
+ await clickRemove(container);
1063
+
1064
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
1065
+ });
1066
+
1067
+ it("should allow remove if isLibraryRemovalPermitted returns true", async () => {
1068
+ const { container } = render(
1069
+ <AllowRemovalServiceEditForm {...commonProps()} />
1070
+ );
1071
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(1);
1072
+
1073
+ await clickRemove(container);
1074
+
1075
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
1076
+ });
1077
+
1078
+ it("should not allow remove if isLibraryRemovalPermitted returns false", async () => {
1079
+ const { container } = render(
1080
+ <BlockRemovalServiceEditForm {...commonProps()} />
1081
+ );
1082
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(1);
1083
+
1084
+ await clickRemove(container);
1085
+
1086
+ // Removal is blocked: the library remains.
1087
+ const libraries = container.querySelectorAll(".with-remove-button");
1088
+ expect(libraries).toHaveLength(1);
1089
+ expect(libraries[0]).toHaveTextContent("New York Public Library");
1090
+ });
1091
+ });
1092
+ });