@thepalaceproject/circulation-admin 1.43.0-post.9 → 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 (73) hide show
  1. package/CLAUDE.md +22 -10
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/jest.config.js +11 -44
  5. package/package.json +1 -5
  6. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +111 -0
  7. package/tests/jest/components/Announcement.test.tsx +76 -0
  8. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  9. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  10. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  11. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  12. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  13. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  14. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  15. package/tests/jest/components/BookEditor.test.tsx +20 -21
  16. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  17. package/tests/jest/components/CatalogServices.test.tsx +38 -1
  18. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  19. package/tests/jest/components/Classifications.test.tsx +166 -0
  20. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  21. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  22. package/tests/jest/components/Collections.test.tsx +335 -7
  23. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  24. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  25. package/tests/jest/components/Complaints.test.tsx +259 -0
  26. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  27. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  28. package/tests/jest/components/Contributors.test.tsx +157 -0
  29. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  30. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  31. package/tests/jest/components/CustomLists.test.tsx +621 -4
  32. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  33. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  34. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  35. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  36. package/tests/jest/components/EditableConfigList.test.tsx +598 -7
  37. package/tests/jest/components/EditableInput.test.tsx +250 -1
  38. package/tests/jest/components/EditorField.test.tsx +95 -0
  39. package/tests/jest/components/GenreForm.test.tsx +152 -0
  40. package/tests/jest/components/Header.test.tsx +421 -0
  41. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  42. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  43. package/tests/jest/components/InputList.test.tsx +493 -0
  44. package/tests/jest/components/Lane.test.tsx +132 -10
  45. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  46. package/tests/jest/components/LaneEditor.test.tsx +377 -9
  47. package/tests/jest/components/Lanes.test.tsx +446 -0
  48. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  49. package/tests/jest/components/Libraries.test.tsx +48 -0
  50. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  51. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  52. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  53. package/tests/jest/components/MetadataServices.test.tsx +37 -1
  54. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  55. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  56. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  57. package/tests/jest/components/ProtocolFormField.test.tsx +443 -7
  58. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  59. package/tests/jest/components/SelfTests.test.tsx +601 -0
  60. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  61. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  62. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  63. package/tests/jest/components/Stats.test.tsx +0 -7
  64. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  65. package/tests/jest/components/ToolTip.test.tsx +46 -0
  66. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  67. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  68. package/tests/jest/features/book.test.ts +75 -80
  69. package/tests/jest/fixtures/classificationsData.ts +45 -0
  70. package/tests/jest/fixtures/genreData.ts +811 -0
  71. package/tests/jest/README.md +0 -6
  72. package/tests/jest/legacy-setup.ts +0 -13
  73. package/tests/jest/legacyJsdomEnvironment.js +0 -49
@@ -0,0 +1,450 @@
1
+ import * as React from "react";
2
+ import { render, screen, within, act } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ // Capture the component's onDragStart/onDragEnd while still rendering the real
6
+ // render-prop DOM (so the button/column tests below are unaffected). A real drag
7
+ // can't run in jsdom, so the drag-handler tests call the captured callbacks
8
+ // directly.
9
+ let dndCallbacks: { onDragStart?: any; onDragEnd?: any } = {};
10
+ jest.mock("react-beautiful-dnd", () => ({
11
+ __esModule: true,
12
+ DragDropContext: ({ onDragStart, onDragEnd, children }: any) => {
13
+ dndCallbacks = { onDragStart, onDragEnd };
14
+ return children;
15
+ },
16
+ Droppable: ({ droppableId, isDropDisabled, children }: any) => (
17
+ <div
18
+ data-testid={`droppable-${droppableId}`}
19
+ data-drop-disabled={String(!!isDropDisabled)}
20
+ >
21
+ {children(
22
+ { droppableProps: {}, innerRef: () => undefined, placeholder: null },
23
+ { isDraggingOver: false }
24
+ )}
25
+ </div>
26
+ ),
27
+ Draggable: ({ children }: any) =>
28
+ children(
29
+ {
30
+ draggableProps: {},
31
+ dragHandleProps: {},
32
+ innerRef: () => undefined,
33
+ draggableStyle: {},
34
+ placeholder: null,
35
+ },
36
+ { isDragging: false }
37
+ ),
38
+ }));
39
+
40
+ import LaneCustomListsEditor from "../../../src/components/LaneCustomListsEditor";
41
+ import { CustomListData } from "../../../src/interfaces";
42
+
43
+ // NOTE ON DRAG-AND-DROP COVERAGE
44
+ // ------------------------------
45
+ // This component uses `react-beautiful-dnd`. A real drag/drop cannot be
46
+ // simulated in jsdom, so the drag paths are exercised by invoking the captured
47
+ // onDragStart/onDragEnd callbacks and asserting the observable result:
48
+ // - Adding a list (drop from available to current) and removing one (drop from
49
+ // current to available) update the selection; both are also reachable via the
50
+ // "Add to lane" / "Remove from lane" buttons and are tested below.
51
+ // - onDragStart toggles each Droppable's `isDropDisabled` (surfaced by the test
52
+ // mock as `data-drop-disabled`) and, when dragging from the current lists,
53
+ // shows the "remove them from the lane" message over the available lists.
54
+ // - `reset()` / `getCustomListIds()` form the imperative API that LaneEditor
55
+ // drives through a ref in production; they are tested through a ref below.
56
+
57
+ const allCustomListsData: CustomListData[] = [
58
+ { id: 1, name: "list 1", entry_count: 0, is_owner: true, is_shared: false },
59
+ { id: 2, name: "list 2", entry_count: 2, is_owner: true, is_shared: false },
60
+ { id: 3, name: "list 3", entry_count: 0, is_owner: true, is_shared: false },
61
+ ];
62
+
63
+ const sharedList: CustomListData = {
64
+ id: 4,
65
+ name: "list 4",
66
+ entry_count: 0,
67
+ is_owner: false,
68
+ is_shared: true,
69
+ };
70
+
71
+ const shareTitle = /shared by another library/i;
72
+
73
+ const availableLists = (container: HTMLElement) =>
74
+ Array.from(
75
+ container.querySelectorAll<HTMLElement>(".available-lists .available-list")
76
+ );
77
+ const currentLists = (container: HTMLElement) =>
78
+ Array.from(
79
+ container.querySelectorAll<HTMLElement>(".current-lists .current-list")
80
+ );
81
+
82
+ describe("LaneCustomListsEditor", () => {
83
+ it("renders available lists", () => {
84
+ const filteredCustomListsData = [
85
+ allCustomListsData[0],
86
+ allCustomListsData[2],
87
+ ];
88
+
89
+ const { container, rerender } = render(
90
+ <LaneCustomListsEditor
91
+ allCustomLists={allCustomListsData}
92
+ customListIds={[]}
93
+ filter="owned"
94
+ filteredCustomLists={filteredCustomListsData}
95
+ />
96
+ );
97
+
98
+ expect(container.querySelector(".available-lists")).toBeInTheDocument();
99
+
100
+ let lists = availableLists(container);
101
+ expect(lists).toHaveLength(2);
102
+ expect(lists[0]).toHaveTextContent("list 1");
103
+ expect(lists[0]).toHaveTextContent("Items in list: 0");
104
+ expect(lists[1]).toHaveTextContent("list 3");
105
+ expect(lists[1]).toHaveTextContent("Items in list: 0");
106
+
107
+ // With list 1 already in the lane, it drops out of the available column.
108
+ rerender(
109
+ <LaneCustomListsEditor
110
+ allCustomLists={allCustomListsData}
111
+ customListIds={[1]}
112
+ filter="owned"
113
+ filteredCustomLists={filteredCustomListsData}
114
+ />
115
+ );
116
+
117
+ lists = availableLists(container);
118
+ expect(lists).toHaveLength(1);
119
+ expect(lists[0]).toHaveTextContent("list 3");
120
+ expect(lists[0]).toHaveTextContent("Items in list: 0");
121
+ });
122
+
123
+ it("renders a share icon on available lists that are not owned by the current library", () => {
124
+ const sharedCustomListsData = [...allCustomListsData, sharedList];
125
+
126
+ const { container } = render(
127
+ <LaneCustomListsEditor
128
+ allCustomLists={sharedCustomListsData}
129
+ customListIds={[]}
130
+ filter=""
131
+ filteredCustomLists={sharedCustomListsData}
132
+ />
133
+ );
134
+
135
+ const lists = availableLists(container);
136
+ expect(lists).toHaveLength(4);
137
+
138
+ expect(within(lists[0]).queryByTitle(shareTitle)).toBeNull();
139
+ expect(within(lists[1]).queryByTitle(shareTitle)).toBeNull();
140
+ expect(within(lists[2]).queryByTitle(shareTitle)).toBeNull();
141
+ expect(within(lists[3]).queryByTitle(shareTitle)).not.toBeNull();
142
+ });
143
+
144
+ it("renders filter select", async () => {
145
+ const user = userEvent.setup();
146
+ const changeFilter = jest.fn();
147
+
148
+ const { container } = render(
149
+ <LaneCustomListsEditor
150
+ allCustomLists={allCustomListsData}
151
+ customListIds={[]}
152
+ filter="owned"
153
+ filteredCustomLists={allCustomListsData}
154
+ changeFilter={changeFilter}
155
+ />
156
+ );
157
+
158
+ const select = container.querySelector<HTMLSelectElement>(
159
+ 'select[name="filter"]'
160
+ );
161
+ expect(select).toHaveValue("owned");
162
+
163
+ const options = within(select).getAllByRole("option");
164
+ expect(options).toHaveLength(3);
165
+ expect(options[0]).toHaveValue("");
166
+ expect(options[1]).toHaveValue("owned");
167
+ expect(options[2]).toHaveValue("shared-in");
168
+
169
+ await user.selectOptions(select, "shared-in");
170
+
171
+ expect(changeFilter).toHaveBeenCalledTimes(1);
172
+ expect(changeFilter).toHaveBeenCalledWith("shared-in");
173
+ });
174
+
175
+ it("renders current lists", () => {
176
+ const { container, rerender } = render(
177
+ <LaneCustomListsEditor
178
+ allCustomLists={allCustomListsData}
179
+ customListIds={[]}
180
+ filter=""
181
+ filteredCustomLists={allCustomListsData}
182
+ />
183
+ );
184
+
185
+ expect(container.querySelector(".current-lists")).toBeInTheDocument();
186
+ expect(currentLists(container)).toHaveLength(0);
187
+
188
+ rerender(
189
+ <LaneCustomListsEditor
190
+ allCustomLists={allCustomListsData}
191
+ customListIds={[2, 3]}
192
+ filter=""
193
+ filteredCustomLists={allCustomListsData}
194
+ />
195
+ );
196
+
197
+ const lists = currentLists(container);
198
+ expect(lists).toHaveLength(2);
199
+ expect(lists[0]).toHaveTextContent("list 2");
200
+ expect(lists[0]).toHaveTextContent("Items in list: 2");
201
+ expect(lists[1]).toHaveTextContent("list 3");
202
+ expect(lists[1]).toHaveTextContent("Items in list: 0");
203
+ });
204
+
205
+ it("renders a share icon on current lists that are not owned by the current library", () => {
206
+ const sharedCustomListsData = [...allCustomListsData, sharedList];
207
+
208
+ const { container } = render(
209
+ <LaneCustomListsEditor
210
+ allCustomLists={sharedCustomListsData}
211
+ customListIds={[3, 4]}
212
+ filter=""
213
+ filteredCustomLists={sharedCustomListsData}
214
+ />
215
+ );
216
+
217
+ const lists = currentLists(container);
218
+ expect(lists).toHaveLength(2);
219
+
220
+ expect(within(lists[0]).queryByTitle(shareTitle)).toBeNull();
221
+ expect(within(lists[1]).queryByTitle(shareTitle)).not.toBeNull();
222
+ });
223
+
224
+ it("adds a list to the lane when the add button is clicked", async () => {
225
+ const user = userEvent.setup();
226
+ const onUpdate = jest.fn();
227
+
228
+ const { container } = render(
229
+ <LaneCustomListsEditor
230
+ allCustomLists={allCustomListsData}
231
+ customListIds={[2]}
232
+ filter=""
233
+ filteredCustomLists={allCustomListsData}
234
+ onUpdate={onUpdate}
235
+ />
236
+ );
237
+
238
+ // Available column starts with list 1 and list 3 (list 2 is in the lane).
239
+ const available = container.querySelector<HTMLElement>(".available-lists");
240
+ const addButtons = within(available).getAllByRole("button", {
241
+ name: /add to lane/i,
242
+ });
243
+ expect(addButtons).toHaveLength(2);
244
+
245
+ // Add list 1 (the first available list).
246
+ await user.click(addButtons[0]);
247
+
248
+ expect(onUpdate).toHaveBeenCalledTimes(1);
249
+ expect(onUpdate.mock.calls[0][0]).toEqual(expect.arrayContaining([1, 2]));
250
+
251
+ // The item now appears in the current lists.
252
+ const nowCurrent = currentLists(container);
253
+ expect(nowCurrent).toHaveLength(2);
254
+ expect(nowCurrent[0]).toHaveTextContent("list 1");
255
+ expect(nowCurrent[1]).toHaveTextContent("list 2");
256
+ });
257
+
258
+ it("removes a list from the lane when the remove button is clicked", async () => {
259
+ const user = userEvent.setup();
260
+ const onUpdate = jest.fn();
261
+
262
+ const { container, rerender } = render(
263
+ <LaneCustomListsEditor
264
+ allCustomLists={allCustomListsData}
265
+ customListIds={[1, 2]}
266
+ filter=""
267
+ filteredCustomLists={allCustomListsData}
268
+ onUpdate={onUpdate}
269
+ />
270
+ );
271
+
272
+ const current = container.querySelector<HTMLElement>(".current-lists");
273
+ const removeButtons = within(current).getAllByRole("button", {
274
+ name: /remove from lane/i,
275
+ });
276
+ expect(removeButtons).toHaveLength(2);
277
+
278
+ // Remove list 1 (the first current list).
279
+ await user.click(removeButtons[0]);
280
+
281
+ expect(onUpdate).toHaveBeenCalledTimes(1);
282
+ expect(onUpdate.mock.calls[0][0]).toEqual([2]);
283
+
284
+ // Reflect the parent's updated ids; the removed list is gone.
285
+ rerender(
286
+ <LaneCustomListsEditor
287
+ allCustomLists={allCustomListsData}
288
+ customListIds={onUpdate.mock.calls[0][0]}
289
+ filter=""
290
+ filteredCustomLists={allCustomListsData}
291
+ onUpdate={onUpdate}
292
+ />
293
+ );
294
+
295
+ const nowCurrent = currentLists(container);
296
+ expect(nowCurrent).toHaveLength(1);
297
+ expect(nowCurrent[0]).toHaveTextContent("list 2");
298
+ });
299
+
300
+ it("exposes reset()/getCustomListIds() as the imperative API LaneEditor drives via a ref", () => {
301
+ const onUpdate = jest.fn();
302
+ const ref = React.createRef<LaneCustomListsEditor>();
303
+
304
+ const { rerender } = render(
305
+ <LaneCustomListsEditor
306
+ ref={ref}
307
+ allCustomLists={allCustomListsData}
308
+ customListIds={[2]}
309
+ filter=""
310
+ filteredCustomLists={allCustomListsData}
311
+ onUpdate={onUpdate}
312
+ />
313
+ );
314
+
315
+ expect(ref.current.getCustomListIds()).toEqual([2]);
316
+
317
+ // reset(ids) notifies the parent with the supplied original ids.
318
+ ref.current.reset([1]);
319
+ expect(onUpdate).toHaveBeenCalledTimes(1);
320
+ expect(onUpdate).toHaveBeenCalledWith([1]);
321
+
322
+ rerender(
323
+ <LaneCustomListsEditor
324
+ ref={ref}
325
+ allCustomLists={allCustomListsData}
326
+ customListIds={[1]}
327
+ filter=""
328
+ filteredCustomLists={allCustomListsData}
329
+ onUpdate={onUpdate}
330
+ />
331
+ );
332
+
333
+ expect(ref.current.getCustomListIds()).toEqual([1]);
334
+ });
335
+ });
336
+
337
+ // The drag handlers (onDragStart/onDragEnd), exercised by calling the captured
338
+ // react-beautiful-dnd callbacks directly.
339
+ describe("LaneCustomListsEditor - drag handlers", () => {
340
+ // Factory rather than a shared const: the component's add()/remove() mutate
341
+ // customListIds in place, so each test needs its own fresh array.
342
+ const makeProps = () => ({
343
+ allCustomLists: allCustomListsData,
344
+ filteredCustomLists: allCustomListsData,
345
+ customListIds: [2],
346
+ onUpdate: undefined as any,
347
+ });
348
+
349
+ it("adds a list when dragged from available to current", () => {
350
+ const onUpdate = jest.fn();
351
+ render(<LaneCustomListsEditor {...makeProps()} onUpdate={onUpdate} />);
352
+
353
+ act(() => {
354
+ dndCallbacks.onDragStart({ source: { droppableId: "available-lists" } });
355
+ dndCallbacks.onDragEnd({
356
+ draggableId: "1",
357
+ source: { droppableId: "available-lists" },
358
+ destination: { droppableId: "current-lists" },
359
+ });
360
+ });
361
+
362
+ // list 2 was already selected; dragging list 1 in adds it.
363
+ expect(onUpdate).toHaveBeenCalledWith([2, 1]);
364
+ expect(document.body.classList.contains("dragging")).toBe(false);
365
+ });
366
+
367
+ it("removes a list when dragged from current to available", () => {
368
+ const onUpdate = jest.fn();
369
+ render(<LaneCustomListsEditor {...makeProps()} onUpdate={onUpdate} />);
370
+
371
+ act(() => {
372
+ dndCallbacks.onDragEnd({
373
+ draggableId: 2,
374
+ source: { droppableId: "current-lists" },
375
+ destination: { droppableId: "available-lists" },
376
+ });
377
+ });
378
+
379
+ // list 2 was the only selection; dragging it out clears the list.
380
+ expect(onUpdate).toHaveBeenCalledWith([]);
381
+ });
382
+
383
+ it("does nothing when dropped outside a list (no destination)", () => {
384
+ const onUpdate = jest.fn();
385
+ render(<LaneCustomListsEditor {...makeProps()} onUpdate={onUpdate} />);
386
+
387
+ act(() => {
388
+ dndCallbacks.onDragEnd({
389
+ draggableId: "1",
390
+ source: { droppableId: "available-lists" },
391
+ destination: null,
392
+ });
393
+ });
394
+
395
+ expect(onUpdate).not.toHaveBeenCalled();
396
+ });
397
+
398
+ it("disables dropping within the available lists while dragging from available", () => {
399
+ render(<LaneCustomListsEditor {...makeProps()} onUpdate={jest.fn()} />);
400
+
401
+ act(() => {
402
+ dndCallbacks.onDragStart({ source: { droppableId: "available-lists" } });
403
+ });
404
+
405
+ expect(screen.getByTestId("droppable-available-lists")).toHaveAttribute(
406
+ "data-drop-disabled",
407
+ "true"
408
+ );
409
+ });
410
+
411
+ it("disables dropping within the current lists while dragging from current", () => {
412
+ render(<LaneCustomListsEditor {...makeProps()} onUpdate={jest.fn()} />);
413
+
414
+ act(() => {
415
+ dndCallbacks.onDragStart({ source: { droppableId: "current-lists" } });
416
+ });
417
+
418
+ expect(screen.getByTestId("droppable-current-lists")).toHaveAttribute(
419
+ "data-drop-disabled",
420
+ "true"
421
+ );
422
+ });
423
+
424
+ it("shows a removal message over the available lists while dragging from current, and clears it on drop", () => {
425
+ render(<LaneCustomListsEditor {...makeProps()} onUpdate={jest.fn()} />);
426
+ const available = () => screen.getByTestId("droppable-available-lists");
427
+ const removalMessage = () =>
428
+ screen.queryByText("Drag lists here to remove them from the lane.");
429
+
430
+ // Before any drag: the available lists reject drops and show no remove zone.
431
+ expect(available()).toHaveAttribute("data-drop-disabled", "true");
432
+ expect(removalMessage()).not.toBeInTheDocument();
433
+
434
+ act(() => {
435
+ dndCallbacks.onDragStart({ source: { droppableId: "current-lists" } });
436
+ });
437
+
438
+ // Dragging from the current lists turns the available column into a remove zone.
439
+ expect(available()).toHaveAttribute("data-drop-disabled", "false");
440
+ expect(removalMessage()).toBeInTheDocument();
441
+
442
+ act(() => {
443
+ dndCallbacks.onDragEnd({ source: { droppableId: "current-lists" } });
444
+ });
445
+
446
+ // Dropping (even outside a list) resets the remove zone.
447
+ expect(available()).toHaveAttribute("data-drop-disabled", "true");
448
+ expect(removalMessage()).not.toBeInTheDocument();
449
+ });
450
+ });