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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CLAUDE.md +28 -7
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/eslint.config.mjs +9 -1
  5. package/jest.config.js +22 -8
  6. package/package.json +6 -20
  7. package/tests/jest/actions.test.ts +962 -0
  8. package/tests/jest/components/AccountPage.test.tsx +41 -0
  9. package/tests/jest/components/AdvancedSearchBooleanFilter.test.tsx +231 -0
  10. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +93 -20
  11. package/tests/jest/components/AdvancedSearchFilter.test.tsx +142 -0
  12. package/tests/jest/components/AdvancedSearchFilterInput.test.tsx +212 -0
  13. package/tests/jest/components/AdvancedSearchFilterViewer.test.tsx +52 -0
  14. package/tests/jest/components/AdvancedSearchValueFilter.test.tsx +123 -0
  15. package/tests/jest/components/Announcement.test.tsx +76 -0
  16. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  17. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  18. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  19. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  20. package/tests/jest/components/BookDetails.test.tsx +133 -0
  21. package/tests/jest/components/BookDetailsContainer.test.tsx +116 -0
  22. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  23. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  24. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  25. package/tests/jest/components/BookEditor.test.tsx +20 -21
  26. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  27. package/tests/jest/components/CatalogServices.test.tsx +100 -0
  28. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  29. package/tests/jest/components/CirculationEventsDownloadForm.test.tsx +41 -0
  30. package/tests/jest/components/Classifications.test.tsx +166 -0
  31. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  32. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  33. package/tests/jest/components/Collections.test.tsx +335 -7
  34. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  35. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  36. package/tests/jest/components/Complaints.test.tsx +259 -0
  37. package/tests/jest/components/ConfigPage.test.tsx +58 -0
  38. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  39. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  40. package/tests/jest/components/Contributors.test.tsx +157 -0
  41. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  42. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  43. package/tests/jest/components/CustomListPage.test.tsx +57 -0
  44. package/tests/jest/components/CustomListSearch.test.tsx +275 -0
  45. package/tests/jest/components/CustomLists.test.tsx +621 -4
  46. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  47. package/tests/jest/components/CustomListsSidebar.test.tsx +251 -0
  48. package/tests/jest/components/DashboardPage.test.tsx +76 -0
  49. package/tests/jest/components/DebugAuthentication.test.tsx +1 -5
  50. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  51. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  52. package/tests/jest/components/DiagnosticsTabContainer.test.tsx +147 -0
  53. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  54. package/tests/jest/components/EditableConfigList.test.tsx +606 -9
  55. package/tests/jest/components/EditableInput.test.tsx +250 -1
  56. package/tests/jest/components/EditorField.test.tsx +95 -0
  57. package/tests/jest/components/EntryPointsContainer.test.tsx +97 -0
  58. package/tests/jest/components/EntryPointsTabs.test.tsx +88 -0
  59. package/tests/jest/components/ErrorMessage.test.tsx +97 -0
  60. package/tests/jest/components/Footer.test.tsx +141 -0
  61. package/tests/jest/components/GenreForm.test.tsx +152 -0
  62. package/tests/jest/components/Header.test.tsx +421 -0
  63. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  64. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  65. package/tests/jest/components/InputList.test.tsx +493 -0
  66. package/tests/jest/components/InventoryReportRequestModal.test.tsx +16 -42
  67. package/tests/jest/components/JsonField.test.tsx +20 -0
  68. package/tests/jest/components/Lane.test.tsx +132 -10
  69. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  70. package/tests/jest/components/LaneEditor.test.tsx +377 -13
  71. package/tests/jest/components/LanePage.test.tsx +68 -0
  72. package/tests/jest/components/Lanes.test.tsx +446 -0
  73. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  74. package/tests/jest/components/LanguageField.test.tsx +40 -0
  75. package/tests/jest/components/Libraries.test.tsx +48 -0
  76. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  77. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  78. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  79. package/tests/jest/components/LoadButton.test.tsx +49 -0
  80. package/tests/jest/components/ManagePatrons.test.tsx +73 -0
  81. package/tests/jest/components/ManagePatronsForm.test.tsx +180 -0
  82. package/tests/jest/components/ManagePatronsTabContainer.test.tsx +143 -0
  83. package/tests/jest/components/MetadataServices.test.tsx +95 -0
  84. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  85. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  86. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  87. package/tests/jest/components/PatronBlockingRulesEditor.test.tsx +3 -1
  88. package/tests/jest/components/PatronInfo.test.tsx +54 -0
  89. package/tests/jest/components/ProtocolFormField.test.tsx +479 -7
  90. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  91. package/tests/jest/components/SaveButton.test.tsx +47 -0
  92. package/tests/jest/components/SelfTestResult.test.tsx +164 -0
  93. package/tests/jest/components/SelfTests.test.tsx +601 -0
  94. package/tests/jest/components/SelfTestsCategory.test.tsx +200 -0
  95. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  96. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  97. package/tests/jest/components/ServiceWithRegistrationsEditForm.test.tsx +110 -0
  98. package/tests/jest/components/SetupPage.test.tsx +41 -0
  99. package/tests/jest/components/SingleStatListItem.test.tsx +62 -0
  100. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  101. package/tests/jest/components/Stats.test.tsx +4 -15
  102. package/tests/jest/components/TabContainer.test.tsx +68 -0
  103. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  104. package/tests/jest/components/Timestamp.test.tsx +74 -0
  105. package/tests/jest/components/ToolTip.test.tsx +46 -0
  106. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  107. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  108. package/tests/jest/components/TroubleshootingTabContainer.test.tsx +68 -0
  109. package/tests/jest/components/UpdatingLoader.test.tsx +36 -0
  110. package/tests/jest/components/WelcomePage.test.tsx +43 -0
  111. package/tests/jest/components/WithEditButton.test.tsx +55 -0
  112. package/tests/jest/components/WithRemoveButton.test.tsx +95 -0
  113. package/tests/jest/editorAdapter.test.ts +245 -0
  114. package/tests/jest/features/book.test.ts +75 -80
  115. package/tests/jest/fixtures/classificationsData.ts +45 -0
  116. package/tests/jest/fixtures/genreData.ts +811 -0
  117. package/tests/jest/index.test.tsx +50 -0
  118. package/tests/jest/models/Admin.test.ts +206 -0
  119. package/tests/jest/reducers/bookCoverPreview.test.ts +81 -0
  120. package/tests/jest/reducers/classifications.test.ts +161 -0
  121. package/tests/jest/reducers/collections.test.ts +96 -0
  122. package/tests/jest/reducers/complaints.test.ts +166 -0
  123. package/tests/jest/reducers/createFetchEditReducer.test.ts +220 -0
  124. package/tests/jest/reducers/createRegisterLibraryReducer.test.ts +51 -0
  125. package/tests/jest/reducers/customListDetails.test.ts +120 -0
  126. package/tests/jest/reducers/customListEditor.test.ts +2823 -0
  127. package/tests/jest/reducers/managePatrons.test.ts +71 -0
  128. package/tests/jest/testUtils/formDataShim.test.ts +135 -0
  129. package/tests/jest/testUtils/formDataShim.ts +112 -0
  130. package/tests/jest/utils/sharedFunctions.test.ts +121 -0
  131. package/tsconfig.json +3 -4
  132. package/testReporter.js +0 -29
  133. package/tests/jest/README.md +0 -6
@@ -0,0 +1,1240 @@
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
+ import CustomListEntriesEditor, {
6
+ CustomListEntriesEditorProps,
7
+ } from "../../../src/components/CustomListEntriesEditor";
8
+
9
+ // react-beautiful-dnd does not work in jsdom, so we do not exercise real
10
+ // pointer/keyboard drag gestures. Instead we mock the library:
11
+ // - DragDropContext captures the component's onDragStart/onDragEnd handlers so
12
+ // a test can invoke them directly with a synthetic drag "result".
13
+ // - Droppable renders its children render-prop and exposes the `isDropDisabled`
14
+ // prop it received as a DOM data attribute, so the drag-prevention behaviour
15
+ // driven by that prop stays observable.
16
+ // - Draggable simply renders its children render-prop.
17
+ type DragResult = {
18
+ draggableId?: string;
19
+ source: { droppableId: string };
20
+ destination?: { droppableId: string };
21
+ };
22
+
23
+ type DndCallbacks = {
24
+ onDragStart: (result: DragResult) => void;
25
+ onDragEnd: (result: DragResult) => void;
26
+ };
27
+
28
+ jest.mock("react-beautiful-dnd", () => ({
29
+ __esModule: true,
30
+ DragDropContext: ({ onDragStart, onDragEnd, children }) => {
31
+ (
32
+ globalThis as typeof globalThis & { __dndCallbacks?: DndCallbacks }
33
+ ).__dndCallbacks = { onDragStart, onDragEnd };
34
+ return children;
35
+ },
36
+ Droppable: ({ droppableId, isDropDisabled, children }) => (
37
+ <div
38
+ data-testid={`droppable-${droppableId}`}
39
+ data-drop-disabled={String(!!isDropDisabled)}
40
+ >
41
+ {children(
42
+ { innerRef: () => undefined, droppableProps: {}, placeholder: null },
43
+ { isDraggingOver: false }
44
+ )}
45
+ </div>
46
+ ),
47
+ Draggable: ({ children }) =>
48
+ children(
49
+ {
50
+ innerRef: () => undefined,
51
+ draggableProps: {},
52
+ dragHandleProps: {},
53
+ draggableStyle: {},
54
+ placeholder: null,
55
+ },
56
+ { isDragging: false }
57
+ ),
58
+ }));
59
+
60
+ // CatalogLink from web-opds-client needs the legacy react-router `pathFor`/
61
+ // `router` context to render. The component only distinguishes its links by
62
+ // their `bookUrl`/`collectionUrl` props and "View details" text, so mock it to
63
+ // a marker that surfaces exactly those. `renderCatalogLink` in the component
64
+ // still runs, so its coverage is preserved.
65
+ jest.mock(
66
+ "@thepalaceproject/web-opds-client/lib/components/CatalogLink",
67
+ () => ({
68
+ __esModule: true,
69
+ default: ({ bookUrl, collectionUrl, children }) => (
70
+ <div
71
+ data-testid="catalog-link"
72
+ data-book-url={bookUrl}
73
+ data-collection-url={collectionUrl}
74
+ >
75
+ {children}
76
+ </div>
77
+ ),
78
+ })
79
+ );
80
+
81
+ const getDndCallbacks = (): DndCallbacks =>
82
+ (globalThis as typeof globalThis & { __dndCallbacks: DndCallbacks })
83
+ .__dndCallbacks;
84
+
85
+ describe("CustomListEntriesEditor", () => {
86
+ // The component calls Element.scrollTo on mount/update, which jsdom lacks.
87
+ Element.prototype.scrollTo = () => {};
88
+
89
+ let addAllEntries: jest.Mock;
90
+ let addEntry: jest.Mock;
91
+ let deleteAllEntries: jest.Mock;
92
+ let deleteEntry: jest.Mock;
93
+ let loadMoreSearchResults: jest.Mock;
94
+ let loadMoreEntries: jest.Mock;
95
+ let refreshResults: jest.Mock;
96
+
97
+ const makeSearchResults = (): any => ({
98
+ id: "id",
99
+ url: "url",
100
+ title: "title",
101
+ lanes: [],
102
+ navigationLinks: [],
103
+ books: [
104
+ {
105
+ id: "1",
106
+ title: "result 1",
107
+ authors: ["author 1"],
108
+ url: "/some/url1",
109
+ language: "eng",
110
+ raw: {
111
+ $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
112
+ },
113
+ },
114
+ {
115
+ id: "2",
116
+ title: "result 2",
117
+ authors: ["author 2a", "author 2b"],
118
+ url: "/some/url2",
119
+ language: "eng",
120
+ raw: {
121
+ $: {
122
+ "schema:additionalType": {
123
+ value: "http://bib.schema.org/Audiobook",
124
+ },
125
+ },
126
+ },
127
+ },
128
+ {
129
+ id: "3",
130
+ title: "result 3",
131
+ authors: ["author 3"],
132
+ url: "/some/url3",
133
+ language: "eng",
134
+ raw: {
135
+ $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
136
+ },
137
+ },
138
+ ],
139
+ });
140
+
141
+ const makeEntries = (): any => [
142
+ {
143
+ id: "A",
144
+ title: "entry A",
145
+ authors: ["author A"],
146
+ url: "/some/urlA",
147
+ raw: {
148
+ $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
149
+ },
150
+ },
151
+ {
152
+ id: "B",
153
+ title: "entry B",
154
+ authors: ["author B1", "author B2"],
155
+ url: "/some/urlB",
156
+ raw: {
157
+ $: {
158
+ "schema:additionalType": { value: "http://bib.schema.org/Audiobook" },
159
+ },
160
+ },
161
+ },
162
+ ];
163
+
164
+ type EditorOverrides = Partial<
165
+ Omit<
166
+ CustomListEntriesEditorProps,
167
+ "loadMoreSearchResults" | "loadMoreEntries"
168
+ >
169
+ > & {
170
+ loadMoreSearchResults?: (() => void) | null;
171
+ loadMoreEntries?: (() => void) | null;
172
+ };
173
+
174
+ // Builds the element with the always-required props filled in, so each test
175
+ // only supplies what it cares about. Used both for `render` and `rerender`
176
+ // (which replaces, rather than merges, props).
177
+ const editor = (overrides: EditorOverrides = {}) => (
178
+ <CustomListEntriesEditor
179
+ isFetchingMoreCustomListEntries={false}
180
+ isFetchingSearchResults={false}
181
+ isFetchingMoreSearchResults={false}
182
+ loadMoreEntries={loadMoreEntries}
183
+ loadMoreSearchResults={loadMoreSearchResults}
184
+ {...(overrides as Partial<CustomListEntriesEditorProps>)}
185
+ />
186
+ );
187
+
188
+ const searchSection = (container: HTMLElement) =>
189
+ container.querySelector(".custom-list-search-results") as HTMLElement;
190
+
191
+ const entriesSection = (container: HTMLElement) =>
192
+ container.querySelector(".custom-list-entries") as HTMLElement;
193
+
194
+ const entriesHeadingText = (container: HTMLElement) =>
195
+ entriesSection(container).querySelector("h4")?.textContent;
196
+
197
+ beforeEach(() => {
198
+ addAllEntries = jest.fn();
199
+ addEntry = jest.fn();
200
+ deleteAllEntries = jest.fn();
201
+ deleteEntry = jest.fn();
202
+ loadMoreSearchResults = jest.fn();
203
+ loadMoreEntries = jest.fn();
204
+ refreshResults = jest.fn();
205
+ });
206
+
207
+ afterEach(() => {
208
+ document.body.classList.remove("dragging");
209
+ });
210
+
211
+ it("renders search results", () => {
212
+ const { container } = render(
213
+ editor({
214
+ entries: makeEntries(),
215
+ isOwner: true,
216
+ searchResults: makeSearchResults(),
217
+ })
218
+ );
219
+
220
+ const section = searchSection(container);
221
+ expect(section).toBeInTheDocument();
222
+
223
+ const results = section.querySelectorAll(".search-result");
224
+ expect(results).toHaveLength(3);
225
+
226
+ const scoped = within(section);
227
+ expect(scoped.getByText("result 1")).toBeInTheDocument();
228
+ expect(scoped.getByText("author 1")).toBeInTheDocument();
229
+ expect(scoped.getByText("result 2")).toBeInTheDocument();
230
+ expect(scoped.getByText("author 2a, author 2b")).toBeInTheDocument();
231
+ expect(scoped.getByText("result 3")).toBeInTheDocument();
232
+ expect(scoped.getByText("author 3")).toBeInTheDocument();
233
+ });
234
+
235
+ it("does not render search results when isOwner is false", () => {
236
+ const { container } = render(
237
+ editor({
238
+ entries: makeEntries(),
239
+ isOwner: false,
240
+ searchResults: makeSearchResults(),
241
+ })
242
+ );
243
+
244
+ expect(
245
+ container.querySelector(".custom-list-search-results")
246
+ ).not.toBeInTheDocument();
247
+ });
248
+
249
+ it("calls refreshResults when the refresh button is clicked", async () => {
250
+ const user = userEvent.setup();
251
+ render(
252
+ editor({
253
+ entries: makeEntries(),
254
+ isOwner: true,
255
+ searchResults: makeSearchResults(),
256
+ refreshResults,
257
+ })
258
+ );
259
+
260
+ await user.click(screen.getByRole("button", { name: "Refresh" }));
261
+
262
+ expect(refreshResults).toHaveBeenCalledTimes(1);
263
+ });
264
+
265
+ it("disables the refresh button when isFetchingSearchResults is true", () => {
266
+ render(
267
+ editor({
268
+ entries: makeEntries(),
269
+ isOwner: true,
270
+ searchResults: makeSearchResults(),
271
+ refreshResults,
272
+ isFetchingSearchResults: true,
273
+ })
274
+ );
275
+
276
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
277
+ });
278
+
279
+ it("enables the refresh button when isFetchingSearchResults is false", () => {
280
+ render(
281
+ editor({
282
+ entries: makeEntries(),
283
+ isOwner: true,
284
+ searchResults: makeSearchResults(),
285
+ refreshResults,
286
+ isFetchingSearchResults: false,
287
+ })
288
+ );
289
+
290
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled();
291
+ });
292
+
293
+ it("shows a loading message when isFetchingSearchResults is true", () => {
294
+ const { container } = render(
295
+ editor({
296
+ entries: makeEntries(),
297
+ isOwner: true,
298
+ searchResults: makeSearchResults(),
299
+ refreshResults,
300
+ isFetchingSearchResults: true,
301
+ })
302
+ );
303
+
304
+ const loadingIndicator = container.querySelector(".list-loading");
305
+ expect(loadingIndicator).toBeInTheDocument();
306
+ expect(loadingIndicator).toHaveTextContent("Loading");
307
+ });
308
+
309
+ it("renders a link to view each search result", () => {
310
+ const { container } = render(
311
+ editor({
312
+ entries: makeEntries(),
313
+ isOwner: true,
314
+ searchResults: makeSearchResults(),
315
+ })
316
+ );
317
+
318
+ const links = within(searchSection(container)).getAllByTestId(
319
+ "catalog-link"
320
+ );
321
+ expect(links).toHaveLength(3);
322
+
323
+ expect(links[0]).toHaveTextContent("View details");
324
+ expect(links[0]).toHaveAttribute("data-book-url", "/some/url1");
325
+ expect(links[1]).toHaveTextContent("View details");
326
+ expect(links[1]).toHaveAttribute("data-book-url", "/some/url2");
327
+ expect(links[2]).toHaveTextContent("View details");
328
+ expect(links[2]).toHaveAttribute("data-book-url", "/some/url3");
329
+ });
330
+
331
+ it("does not include the opds feed url in links to view search results", () => {
332
+ const { container } = render(
333
+ editor({
334
+ entries: makeEntries(),
335
+ isOwner: true,
336
+ opdsFeedUrl: "opdsFeedUrl",
337
+ searchResults: makeSearchResults(),
338
+ })
339
+ );
340
+
341
+ const links = within(searchSection(container)).getAllByTestId(
342
+ "catalog-link"
343
+ );
344
+
345
+ links.forEach((link) => {
346
+ expect(link).not.toHaveAttribute("data-collection-url");
347
+ });
348
+ });
349
+
350
+ it("renders an SVG icon for each search result", () => {
351
+ const { container } = render(
352
+ editor({
353
+ entries: makeEntries(),
354
+ isOwner: true,
355
+ searchResults: makeSearchResults(),
356
+ })
357
+ );
358
+
359
+ const section = searchSection(container);
360
+ expect(section.querySelectorAll(".audio-headphone-icon")).toHaveLength(1);
361
+ expect(section.querySelectorAll(".book-icon")).toHaveLength(2);
362
+ });
363
+
364
+ it("doesn't render an SVG icon for books with a bad medium value", () => {
365
+ const searchResults = makeSearchResults();
366
+ searchResults.books[2].raw["$"]["schema:additionalType"].value = "";
367
+
368
+ const { container } = render(
369
+ editor({
370
+ entries: makeEntries(),
371
+ isOwner: true,
372
+ searchResults,
373
+ })
374
+ );
375
+
376
+ const section = searchSection(container);
377
+ expect(section.querySelectorAll(".audio-headphone-icon")).toHaveLength(1);
378
+ expect(section.querySelectorAll(".book-icon")).toHaveLength(1);
379
+ });
380
+
381
+ it("renders list entries", () => {
382
+ const { container } = render(
383
+ editor({
384
+ autoUpdate: false,
385
+ entries: makeEntries(),
386
+ isOwner: true,
387
+ entryCount: 2,
388
+ })
389
+ );
390
+
391
+ const section = entriesSection(container);
392
+ expect(section).toBeInTheDocument();
393
+
394
+ const entries = section.querySelectorAll(".custom-list-entry");
395
+ expect(entries).toHaveLength(2);
396
+
397
+ const scoped = within(section);
398
+ expect(scoped.getByText("entry A")).toBeInTheDocument();
399
+ expect(scoped.getByText("author A")).toBeInTheDocument();
400
+ expect(scoped.getByText("entry B")).toBeInTheDocument();
401
+ expect(scoped.getByText("author B1, author B2")).toBeInTheDocument();
402
+
403
+ expect(entriesHeadingText(container)).toBe(
404
+ "List Entries: Displaying 1 - 2 of 2 books"
405
+ );
406
+ });
407
+
408
+ it("makes list entries read only if autoUpdate is true", () => {
409
+ const { container } = render(
410
+ editor({
411
+ autoUpdate: true,
412
+ entries: makeEntries(),
413
+ isOwner: true,
414
+ entryCount: 2,
415
+ })
416
+ );
417
+
418
+ const section = entriesSection(container);
419
+
420
+ expect(section.querySelectorAll(".droppable-header button")).toHaveLength(
421
+ 0
422
+ );
423
+ expect(section.querySelectorAll(".custom-list-entry button")).toHaveLength(
424
+ 0
425
+ );
426
+ });
427
+
428
+ it("renders an auto update status if autoUpdate is true", () => {
429
+ const base = {
430
+ entries: makeEntries(),
431
+ isOwner: true,
432
+ entryCount: 2,
433
+ autoUpdate: true,
434
+ };
435
+
436
+ const { container, rerender } = render(
437
+ editor({ ...base, autoUpdateStatus: "" })
438
+ );
439
+
440
+ const statusText = () =>
441
+ container.querySelector(".custom-list-entries .auto-update-status-name")
442
+ ?.textContent;
443
+
444
+ expect(statusText()).toBe("Status: New");
445
+
446
+ // Without a listId, the status is always "New", even if search is modified.
447
+ rerender(editor({ ...base, autoUpdateStatus: "", isSearchModified: true }));
448
+ expect(statusText()).toBe("Status: New");
449
+
450
+ rerender(
451
+ editor({
452
+ ...base,
453
+ listId: "123",
454
+ isSearchModified: false,
455
+ autoUpdateStatus: "init",
456
+ })
457
+ );
458
+ expect(statusText()).toBe("Status: Initializing");
459
+
460
+ rerender(
461
+ editor({
462
+ ...base,
463
+ listId: "123",
464
+ isSearchModified: true,
465
+ autoUpdateStatus: "init",
466
+ })
467
+ );
468
+ expect(statusText()).toBe("Status: Search criteria modified");
469
+
470
+ rerender(
471
+ editor({
472
+ ...base,
473
+ listId: "123",
474
+ isSearchModified: false,
475
+ autoUpdateStatus: "updated",
476
+ })
477
+ );
478
+ expect(statusText()).toBe("Status: Updated");
479
+
480
+ rerender(
481
+ editor({
482
+ ...base,
483
+ listId: "123",
484
+ isSearchModified: true,
485
+ autoUpdateStatus: "updated",
486
+ })
487
+ );
488
+ expect(statusText()).toBe("Status: Search criteria modified");
489
+
490
+ rerender(
491
+ editor({
492
+ ...base,
493
+ listId: "123",
494
+ isSearchModified: false,
495
+ autoUpdateStatus: "repopulate",
496
+ })
497
+ );
498
+ expect(statusText()).toBe("Status: Repopulating");
499
+
500
+ rerender(
501
+ editor({
502
+ ...base,
503
+ listId: "123",
504
+ isSearchModified: true,
505
+ autoUpdateStatus: "repopulate",
506
+ })
507
+ );
508
+ expect(statusText()).toBe("Status: Search criteria modified");
509
+
510
+ // A saved list whose autoUpdateStatus is empty is being switched to
511
+ // automatic updates.
512
+ rerender(
513
+ editor({
514
+ ...base,
515
+ listId: "123",
516
+ isSearchModified: false,
517
+ autoUpdateStatus: "",
518
+ })
519
+ );
520
+ expect(statusText()).toBe("Status: Changing to automatic");
521
+
522
+ // Any other autoUpdateStatus value is shown verbatim.
523
+ rerender(
524
+ editor({
525
+ ...base,
526
+ listId: "123",
527
+ isSearchModified: false,
528
+ autoUpdateStatus: "some-custom-status",
529
+ })
530
+ );
531
+ expect(statusText()).toBe("Status: some-custom-status");
532
+
533
+ rerender(editor({ ...base, autoUpdate: false }));
534
+ expect(
535
+ container.querySelector(".custom-list-entries .auto-update-status-name")
536
+ ).not.toBeInTheDocument();
537
+ });
538
+
539
+ it("renders a link to view each entry", () => {
540
+ const { container } = render(
541
+ editor({
542
+ entries: makeEntries(),
543
+ isOwner: true,
544
+ opdsFeedUrl: "opdsFeedUrl",
545
+ entryCount: 2,
546
+ })
547
+ );
548
+
549
+ const links = within(entriesSection(container)).getAllByTestId(
550
+ "catalog-link"
551
+ );
552
+ expect(links).toHaveLength(2);
553
+
554
+ expect(links[0]).toHaveTextContent("View details");
555
+ expect(links[0]).toHaveAttribute("data-book-url", "/some/urlA");
556
+ expect(links[1]).toHaveTextContent("View details");
557
+ expect(links[1]).toHaveAttribute("data-book-url", "/some/urlB");
558
+ });
559
+
560
+ it("includes the opds feed url in links to view entries", () => {
561
+ const { container } = render(
562
+ editor({
563
+ entries: makeEntries(),
564
+ isOwner: true,
565
+ opdsFeedUrl: "opdsFeedUrl",
566
+ entryCount: 2,
567
+ })
568
+ );
569
+
570
+ const links = within(entriesSection(container)).getAllByTestId(
571
+ "catalog-link"
572
+ );
573
+
574
+ expect(links[0]).toHaveAttribute("data-collection-url", "opdsFeedUrl");
575
+ expect(links[1]).toHaveAttribute("data-collection-url", "opdsFeedUrl");
576
+ });
577
+
578
+ it("renders an SVG icon for each entry", () => {
579
+ const { container } = render(
580
+ editor({
581
+ entries: makeEntries(),
582
+ isOwner: true,
583
+ entryCount: 2,
584
+ })
585
+ );
586
+
587
+ const section = entriesSection(container);
588
+ expect(section.querySelectorAll(".audio-headphone-icon")).toHaveLength(1);
589
+ expect(section.querySelectorAll(".book-icon")).toHaveLength(1);
590
+ });
591
+
592
+ it("doesn't include search results that are already in the entries list when autoUpdate is false", () => {
593
+ const entries = [
594
+ {
595
+ id: "1",
596
+ title: "result 1",
597
+ authors: ["author 1"],
598
+ language: "eng",
599
+ raw: {
600
+ $: {
601
+ "schema:additionalType": {
602
+ value: "http://bib.schema.org/Audiobook",
603
+ },
604
+ },
605
+ },
606
+ },
607
+ ];
608
+
609
+ const { container } = render(
610
+ editor({
611
+ autoUpdate: false,
612
+ searchResults: makeSearchResults(),
613
+ entries: entries as any,
614
+ isOwner: true,
615
+ entryCount: 2,
616
+ })
617
+ );
618
+
619
+ const section = searchSection(container);
620
+ expect(section).toBeInTheDocument();
621
+
622
+ const results = section.querySelectorAll(".search-result");
623
+ expect(results).toHaveLength(2);
624
+
625
+ const scoped = within(section);
626
+ expect(scoped.getByText("result 2")).toBeInTheDocument();
627
+ expect(scoped.getByText("author 2a, author 2b")).toBeInTheDocument();
628
+ expect(scoped.getByText("result 3")).toBeInTheDocument();
629
+ expect(scoped.getByText("author 3")).toBeInTheDocument();
630
+ });
631
+
632
+ it("shows all search results, even ones that are in the entries list, when autoUpdate is true", () => {
633
+ const entries = [
634
+ {
635
+ id: "1",
636
+ title: "result 1",
637
+ authors: ["author 1"],
638
+ language: "eng",
639
+ raw: {
640
+ $: {
641
+ "schema:additionalType": {
642
+ value: "http://bib.schema.org/Audiobook",
643
+ },
644
+ },
645
+ },
646
+ },
647
+ ];
648
+
649
+ const { container } = render(
650
+ editor({
651
+ autoUpdate: true,
652
+ searchResults: makeSearchResults(),
653
+ entries: entries as any,
654
+ isOwner: true,
655
+ entryCount: 2,
656
+ })
657
+ );
658
+
659
+ const section = searchSection(container);
660
+ expect(section).toBeInTheDocument();
661
+
662
+ const results = section.querySelectorAll(".search-result");
663
+ expect(results).toHaveLength(3);
664
+
665
+ const scoped = within(section);
666
+ expect(scoped.getByText("result 1")).toBeInTheDocument();
667
+ expect(scoped.getByText("author 1")).toBeInTheDocument();
668
+ expect(scoped.getByText("result 2")).toBeInTheDocument();
669
+ expect(scoped.getByText("author 2a, author 2b")).toBeInTheDocument();
670
+ expect(scoped.getByText("result 3")).toBeInTheDocument();
671
+ expect(scoped.getByText("author 3")).toBeInTheDocument();
672
+ });
673
+
674
+ // The following drag-and-drop tests invoke the DragDropContext's captured
675
+ // onDragStart/onDragEnd handlers directly with a synthetic drag result, since
676
+ // react-beautiful-dnd cannot perform real drags in jsdom (see the mock above).
677
+
678
+ it("prevents dragging within search results", () => {
679
+ render(
680
+ editor({
681
+ entries: makeEntries(),
682
+ isOwner: true,
683
+ searchResults: makeSearchResults(),
684
+ })
685
+ );
686
+
687
+ // Simulate starting a drag from search results.
688
+ act(() => {
689
+ getDndCallbacks().onDragStart({
690
+ draggableId: "1",
691
+ source: { droppableId: "search-results" },
692
+ });
693
+ });
694
+
695
+ // The search-results droppable refuses drops (no reordering within results)...
696
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
697
+ "data-drop-disabled",
698
+ "true"
699
+ );
700
+ // ...while list entries becomes a valid drop target — which only flips
701
+ // because handleDragStart recorded the drag source (it was disabled before).
702
+ expect(screen.getByTestId("droppable-custom-list-entries")).toHaveAttribute(
703
+ "data-drop-disabled",
704
+ "false"
705
+ );
706
+ });
707
+
708
+ it("prevents dragging within list entries", () => {
709
+ render(
710
+ editor({
711
+ entries: makeEntries(),
712
+ isOwner: true,
713
+ entryCount: 2,
714
+ })
715
+ );
716
+
717
+ // Simulate starting a drag from list entries.
718
+ act(() => {
719
+ getDndCallbacks().onDragStart({
720
+ draggableId: "A",
721
+ source: { droppableId: "custom-list-entries" },
722
+ });
723
+ });
724
+
725
+ // List entries refuses drops within itself...
726
+ expect(screen.getByTestId("droppable-custom-list-entries")).toHaveAttribute(
727
+ "data-drop-disabled",
728
+ "true"
729
+ );
730
+ // ...while search results becomes a valid drop target — which only flips
731
+ // because handleDragStart recorded the drag source (it was disabled before).
732
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
733
+ "data-drop-disabled",
734
+ "false"
735
+ );
736
+ });
737
+
738
+ it("calls addEntry when a book is dragged from search results to list entries", () => {
739
+ render(
740
+ editor({
741
+ entries: makeEntries(),
742
+ isOwner: true,
743
+ searchResults: makeSearchResults(),
744
+ entryCount: 2,
745
+ addEntry,
746
+ })
747
+ );
748
+
749
+ // Simulate starting a drag from search results: the entries droppable now
750
+ // accepts drops.
751
+ act(() => {
752
+ getDndCallbacks().onDragStart({
753
+ draggableId: "1",
754
+ source: { droppableId: "search-results" },
755
+ });
756
+ });
757
+
758
+ expect(screen.getByTestId("droppable-custom-list-entries")).toHaveAttribute(
759
+ "data-drop-disabled",
760
+ "false"
761
+ );
762
+
763
+ // Simulate dropping on the entries.
764
+ act(() => {
765
+ getDndCallbacks().onDragEnd({
766
+ draggableId: "1",
767
+ source: { droppableId: "search-results" },
768
+ destination: { droppableId: "custom-list-entries" },
769
+ });
770
+ });
771
+
772
+ expect(addEntry).toHaveBeenCalledTimes(1);
773
+ expect(addEntry).toHaveBeenCalledWith("1");
774
+ });
775
+
776
+ it("shows a message in place of search results when dragging from list entries", () => {
777
+ render(
778
+ editor({
779
+ entries: makeEntries(),
780
+ isOwner: true,
781
+ })
782
+ );
783
+
784
+ // Simulate starting a drag from list entries.
785
+ act(() => {
786
+ getDndCallbacks().onDragStart({
787
+ draggableId: "A",
788
+ source: { droppableId: "custom-list-entries" },
789
+ });
790
+ });
791
+
792
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
793
+ "data-drop-disabled",
794
+ "false"
795
+ );
796
+ expect(screen.getByText(/here to remove/)).toBeInTheDocument();
797
+
798
+ // If the drop occurs outside a droppable (no destination), the message goes
799
+ // away and the search-results droppable is disabled again.
800
+ act(() => {
801
+ getDndCallbacks().onDragEnd({
802
+ draggableId: "A",
803
+ source: { droppableId: "custom-list-entries" },
804
+ });
805
+ });
806
+
807
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
808
+ "data-drop-disabled",
809
+ "true"
810
+ );
811
+ expect(screen.queryByText(/here to remove/)).not.toBeInTheDocument();
812
+ });
813
+
814
+ it("calls deleteEntry when a book is dragged from list entries to search results", () => {
815
+ render(
816
+ editor({
817
+ entries: makeEntries(),
818
+ isOwner: true,
819
+ searchResults: makeSearchResults(),
820
+ deleteEntry,
821
+ })
822
+ );
823
+
824
+ // Simulate starting a drag from list entries: the search-results droppable
825
+ // now accepts drops (to remove).
826
+ act(() => {
827
+ getDndCallbacks().onDragStart({
828
+ draggableId: "A",
829
+ source: { droppableId: "custom-list-entries" },
830
+ });
831
+ });
832
+
833
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
834
+ "data-drop-disabled",
835
+ "false"
836
+ );
837
+
838
+ // Simulate dropping on the search results.
839
+ act(() => {
840
+ getDndCallbacks().onDragEnd({
841
+ draggableId: "A",
842
+ source: { droppableId: "custom-list-entries" },
843
+ destination: { droppableId: "search-results" },
844
+ });
845
+ });
846
+
847
+ expect(deleteEntry).toHaveBeenCalledTimes(1);
848
+ expect(deleteEntry).toHaveBeenCalledWith("A");
849
+ });
850
+
851
+ it("calls addEntry when the Add to List button is clicked on a search result", async () => {
852
+ const user = userEvent.setup();
853
+ const { container } = render(
854
+ editor({
855
+ entries: makeEntries(),
856
+ isOwner: true,
857
+ searchResults: makeSearchResults(),
858
+ entryCount: 2,
859
+ addEntry,
860
+ })
861
+ );
862
+
863
+ expect(entriesHeadingText(container)).toBe(
864
+ "List Entries: Displaying 1 - 2 of 2 books"
865
+ );
866
+
867
+ const addButtons = within(searchSection(container)).getAllByRole("button", {
868
+ name: /Add to list/,
869
+ });
870
+
871
+ await user.click(addButtons[0]);
872
+
873
+ expect(addEntry).toHaveBeenCalledTimes(1);
874
+ expect(addEntry).toHaveBeenCalledWith("1");
875
+ });
876
+
877
+ it("hides the Add to List buttons when autoUpdate is true", () => {
878
+ const { container } = render(
879
+ editor({
880
+ autoUpdate: true,
881
+ entries: makeEntries(),
882
+ isOwner: true,
883
+ searchResults: makeSearchResults(),
884
+ entryCount: 2,
885
+ addEntry,
886
+ })
887
+ );
888
+
889
+ expect(
890
+ within(searchSection(container)).queryByRole("button", {
891
+ name: /Add to list/,
892
+ })
893
+ ).not.toBeInTheDocument();
894
+ });
895
+
896
+ it("calls deleteEntry when the Remove from List button is clicked on a list entry", async () => {
897
+ const user = userEvent.setup();
898
+ const { container } = render(
899
+ editor({
900
+ entries: makeEntries(),
901
+ isOwner: true,
902
+ searchResults: makeSearchResults(),
903
+ entryCount: 2,
904
+ deleteEntry,
905
+ })
906
+ );
907
+
908
+ expect(entriesHeadingText(container)).toBe(
909
+ "List Entries: Displaying 1 - 2 of 2 books"
910
+ );
911
+
912
+ const deleteButtons = within(entriesSection(container)).getAllByRole(
913
+ "button",
914
+ { name: /Remove from list/ }
915
+ );
916
+
917
+ await user.click(deleteButtons[0]);
918
+
919
+ expect(deleteEntry).toHaveBeenCalledTimes(1);
920
+ expect(deleteEntry).toHaveBeenCalledWith("A");
921
+ });
922
+
923
+ it("does not render Remove from List buttons on a list entries when isOwner is false", () => {
924
+ const { container } = render(
925
+ editor({
926
+ entries: makeEntries(),
927
+ isOwner: false,
928
+ searchResults: makeSearchResults(),
929
+ entryCount: 2,
930
+ deleteEntry,
931
+ })
932
+ );
933
+
934
+ expect(
935
+ entriesSection(container).querySelectorAll(".links button")
936
+ ).toHaveLength(0);
937
+ });
938
+
939
+ it("does not render the Add All to List button when there are no search results", () => {
940
+ const { container } = render(
941
+ editor({
942
+ entries: makeEntries(),
943
+ isOwner: true,
944
+ })
945
+ );
946
+
947
+ expect(container.querySelector(".add-all-button")).not.toBeInTheDocument();
948
+ });
949
+
950
+ it("does not render the Add All to List button when autoUpdate is true", () => {
951
+ const { container } = render(
952
+ editor({
953
+ autoUpdate: true,
954
+ entries: makeEntries(),
955
+ isOwner: true,
956
+ searchResults: makeSearchResults(),
957
+ })
958
+ );
959
+
960
+ expect(container.querySelector(".add-all-button")).not.toBeInTheDocument();
961
+ });
962
+
963
+ it("does not render the Add All to List button when isOwner is false", () => {
964
+ const { container } = render(
965
+ editor({
966
+ entries: makeEntries(),
967
+ isOwner: false,
968
+ searchResults: makeSearchResults(),
969
+ })
970
+ );
971
+
972
+ expect(container.querySelector(".add-all-button")).not.toBeInTheDocument();
973
+ });
974
+
975
+ it("calls addAllEntries when the Add All to List button is clicked", async () => {
976
+ const user = userEvent.setup();
977
+ const { container } = render(
978
+ editor({
979
+ entries: makeEntries(),
980
+ isOwner: true,
981
+ searchResults: makeSearchResults(),
982
+ entryCount: 2,
983
+ addAllEntries,
984
+ })
985
+ );
986
+
987
+ expect(entriesHeadingText(container)).toBe(
988
+ "List Entries: Displaying 1 - 2 of 2 books"
989
+ );
990
+
991
+ await user.click(screen.getByRole("button", { name: /Add all to list/ }));
992
+
993
+ expect(addAllEntries).toHaveBeenCalledTimes(1);
994
+ });
995
+
996
+ it("does not render the Delete all button when there are no entries", () => {
997
+ const { container } = render(
998
+ editor({
999
+ entries: [],
1000
+ isOwner: true,
1001
+ searchResults: makeSearchResults(),
1002
+ })
1003
+ );
1004
+
1005
+ expect(
1006
+ container.querySelector(".delete-all-button")
1007
+ ).not.toBeInTheDocument();
1008
+ });
1009
+
1010
+ it("does not render the Delete all button when isOwner is false", () => {
1011
+ const { container } = render(
1012
+ editor({
1013
+ entries: makeEntries(),
1014
+ isOwner: false,
1015
+ searchResults: makeSearchResults(),
1016
+ })
1017
+ );
1018
+
1019
+ expect(
1020
+ container.querySelector(".delete-all-button")
1021
+ ).not.toBeInTheDocument();
1022
+ });
1023
+
1024
+ it("calls deleteAllEntries when the Delete all button is clicked", async () => {
1025
+ const user = userEvent.setup();
1026
+ const { container } = render(
1027
+ editor({
1028
+ entries: makeEntries(),
1029
+ isOwner: true,
1030
+ entryCount: 2,
1031
+ deleteAllEntries,
1032
+ })
1033
+ );
1034
+
1035
+ expect(entriesHeadingText(container)).toBe(
1036
+ "List Entries: Displaying 1 - 2 of 2 books"
1037
+ );
1038
+
1039
+ await user.click(screen.getByRole("button", { name: "Delete" }));
1040
+
1041
+ expect(deleteAllEntries).toHaveBeenCalledTimes(1);
1042
+ });
1043
+
1044
+ it("hides the Load More button in search results when loadMoreSearchResults is null", () => {
1045
+ const { container } = render(
1046
+ editor({
1047
+ entries: makeEntries(),
1048
+ isOwner: true,
1049
+ searchResults: makeSearchResults(),
1050
+ loadMoreSearchResults: null,
1051
+ })
1052
+ );
1053
+
1054
+ expect(
1055
+ searchSection(container).querySelector(".load-more-button")
1056
+ ).not.toBeInTheDocument();
1057
+ });
1058
+
1059
+ it("hides the Load More button in search results when isFetchingSearchResults is true", () => {
1060
+ const { container } = render(
1061
+ editor({
1062
+ entries: makeEntries(),
1063
+ isOwner: true,
1064
+ searchResults: makeSearchResults(),
1065
+ isFetchingSearchResults: true,
1066
+ })
1067
+ );
1068
+
1069
+ expect(
1070
+ searchSection(container).querySelector(".load-more-button")
1071
+ ).not.toBeInTheDocument();
1072
+ });
1073
+
1074
+ it("hides Load More button in list entries when loadMoreEntries is null", () => {
1075
+ const { container } = render(
1076
+ editor({
1077
+ entries: makeEntries(),
1078
+ isOwner: true,
1079
+ searchResults: makeSearchResults(),
1080
+ loadMoreEntries: null,
1081
+ })
1082
+ );
1083
+
1084
+ expect(
1085
+ entriesSection(container).querySelector(".load-more-button")
1086
+ ).not.toBeInTheDocument();
1087
+ });
1088
+
1089
+ it("disables the Load More button when loading more search results", () => {
1090
+ const { container, rerender } = render(
1091
+ editor({
1092
+ entries: makeEntries(),
1093
+ isOwner: true,
1094
+ searchResults: makeSearchResults(),
1095
+ })
1096
+ );
1097
+
1098
+ let button = searchSection(container).querySelector(".load-more-button");
1099
+ expect(button).toBeInTheDocument();
1100
+ expect(button).not.toBeDisabled();
1101
+
1102
+ rerender(
1103
+ editor({
1104
+ entries: makeEntries(),
1105
+ isOwner: true,
1106
+ searchResults: makeSearchResults(),
1107
+ isFetchingMoreSearchResults: true,
1108
+ })
1109
+ );
1110
+
1111
+ button = searchSection(container).querySelector(".load-more-button");
1112
+ expect(button).toBeInTheDocument();
1113
+ expect(button).toBeDisabled();
1114
+ });
1115
+
1116
+ it("disables the Load More button when loading more list entries", () => {
1117
+ const { container, rerender } = render(
1118
+ editor({
1119
+ entries: makeEntries(),
1120
+ isOwner: true,
1121
+ })
1122
+ );
1123
+
1124
+ let button = entriesSection(container).querySelector(".load-more-button");
1125
+ expect(button).toBeInTheDocument();
1126
+ expect(button).not.toBeDisabled();
1127
+
1128
+ rerender(
1129
+ editor({
1130
+ entries: makeEntries(),
1131
+ isOwner: true,
1132
+ isFetchingMoreCustomListEntries: true,
1133
+ })
1134
+ );
1135
+
1136
+ button = entriesSection(container).querySelector(".load-more-button");
1137
+ expect(button).toBeInTheDocument();
1138
+ expect(button).toBeDisabled();
1139
+ });
1140
+
1141
+ it("calls loadMoreSearchResults when the Load More button is clicked in search results", async () => {
1142
+ const user = userEvent.setup();
1143
+ const { container } = render(
1144
+ editor({
1145
+ entries: makeEntries(),
1146
+ isOwner: true,
1147
+ searchResults: makeSearchResults(),
1148
+ })
1149
+ );
1150
+
1151
+ await user.click(
1152
+ searchSection(container).querySelector(".load-more-button") as HTMLElement
1153
+ );
1154
+
1155
+ expect(loadMoreSearchResults).toHaveBeenCalledTimes(1);
1156
+ });
1157
+
1158
+ it("calls loadMoreEntries when the Load More button is clicked in list entries", async () => {
1159
+ const user = userEvent.setup();
1160
+ const { container } = render(
1161
+ editor({
1162
+ entries: makeEntries(),
1163
+ isOwner: true,
1164
+ })
1165
+ );
1166
+
1167
+ await user.click(
1168
+ entriesSection(container).querySelector(
1169
+ ".load-more-button"
1170
+ ) as HTMLElement
1171
+ );
1172
+
1173
+ expect(loadMoreEntries).toHaveBeenCalledTimes(1);
1174
+ });
1175
+
1176
+ it("should properly display the count of list entries", () => {
1177
+ const searchResults = makeSearchResults();
1178
+
1179
+ const { container, rerender } = render(
1180
+ editor({
1181
+ entries: makeEntries(),
1182
+ isOwner: true,
1183
+ searchResults,
1184
+ entryCount: 2,
1185
+ })
1186
+ );
1187
+
1188
+ expect(entriesHeadingText(container)).toBe(
1189
+ "List Entries: Displaying 1 - 2 of 2 books"
1190
+ );
1191
+
1192
+ rerender(
1193
+ editor({
1194
+ entries: makeEntries().slice(0, 1),
1195
+ isOwner: true,
1196
+ searchResults,
1197
+ entryCount: 1,
1198
+ })
1199
+ );
1200
+ expect(entriesHeadingText(container)).toBe(
1201
+ "List Entries: Displaying 1 - 1 of 1 book"
1202
+ );
1203
+
1204
+ rerender(
1205
+ editor({
1206
+ entries: [],
1207
+ isOwner: true,
1208
+ searchResults,
1209
+ entryCount: 0,
1210
+ })
1211
+ );
1212
+ expect(entriesHeadingText(container)).toBe(
1213
+ "List Entries: No books in this list"
1214
+ );
1215
+
1216
+ rerender(
1217
+ editor({
1218
+ entries: [],
1219
+ isOwner: true,
1220
+ searchResults,
1221
+ entryCount: 12,
1222
+ })
1223
+ );
1224
+ expect(entriesHeadingText(container)).toBe(
1225
+ "List Entries: Displaying 0 - 0 of 12 books"
1226
+ );
1227
+
1228
+ rerender(
1229
+ editor({
1230
+ entries: makeEntries(),
1231
+ isOwner: true,
1232
+ searchResults,
1233
+ entryCount: 12,
1234
+ })
1235
+ );
1236
+ expect(entriesHeadingText(container)).toBe(
1237
+ "List Entries: Displaying 1 - 2 of 12 books"
1238
+ );
1239
+ });
1240
+ });