@thepalaceproject/circulation-admin 1.43.0-post.14 → 1.43.0-post.16

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.
@@ -1,19 +1,18 @@
1
1
  import * as React from "react";
2
- import { fireEvent } from "@testing-library/react";
2
+ import { installFormDataShim } from "../testUtils/formDataShim";
3
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
3
5
  import { Collections } from "../../../src/components/Collections";
6
+ import CollectionsConnected from "../../../src/components/Collections";
4
7
  import renderWithContext from "../testUtils/renderWithContext";
8
+ import { renderWithProviders } from "../testUtils/withProviders";
9
+ import buildStore from "../../../src/store";
5
10
  import {
6
11
  CollectionsData,
7
12
  ConfigurationSettings,
8
13
  } from "../../../src/interfaces";
9
14
  import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
10
15
 
11
- // NB: This adds tests to the already existing tests in:
12
- // - `src/components/__tests__/Collections-test.tsx`.
13
- //
14
- // Those tests should eventually be migrated here and
15
- // adapted to the Jest/React Testing Library paradigm.
16
-
17
16
  describe("Collections - associated library disclosure", () => {
18
17
  // ── Shared fixtures ───────────────────────────────────────────────────────
19
18
 
@@ -219,3 +218,332 @@ describe("Collections - associated library disclosure", () => {
219
218
  );
220
219
  });
221
220
  });
221
+
222
+ // These exercise behavior the disclosure tests above do not: the connect()
223
+ // wiring (mapStateToProps / mapDispatchToProps, which only run when the CONNECTED
224
+ // default export mounts), the marked-for-deletion list item, the
225
+ // confirm-before-disassociating-libraries behavior, the
226
+ // getChildContext().registerLibrary closure, the on-mount registrations fetch,
227
+ // and the delete flow.
228
+
229
+ describe("Collections - connected wiring, deletion, and registration", () => {
230
+ const allLibraries = [
231
+ { short_name: "nypl", name: "NYPL", uuid: "uuid-nypl" },
232
+ ];
233
+
234
+ const sysAdminConfig: Partial<ConfigurationSettings> = {
235
+ csrfToken: "",
236
+ featureFlags: defaultFeatureFlags,
237
+ roles: [{ role: "system" }],
238
+ };
239
+
240
+ afterEach(() => {
241
+ jest.restoreAllMocks();
242
+ });
243
+
244
+ describe("connected default export", () => {
245
+ // A loaded `libraries` slice persists through the on-mount collections fetch
246
+ // (the libraries reducer ignores that action), so mapStateToProps takes its
247
+ // `data.allLibraries` branch on every render.
248
+ const loadedLibrariesState = {
249
+ data: { libraries: allLibraries },
250
+ isFetching: false,
251
+ isEditing: false,
252
+ fetchError: null,
253
+ formError: null,
254
+ isLoaded: true,
255
+ responseBody: null,
256
+ successMessage: null,
257
+ };
258
+
259
+ const stubFetch = (body: unknown) =>
260
+ jest.spyOn(globalThis, "fetch").mockImplementation(
261
+ async () =>
262
+ new Response(JSON.stringify(body), {
263
+ status: 200,
264
+ headers: { "Content-Type": "application/json" },
265
+ })
266
+ );
267
+
268
+ it("wires mapStateToProps/mapDispatchToProps and marks a collection for deletion", async () => {
269
+ const listData = {
270
+ collections: [
271
+ { id: 2, protocol: "test protocol", name: "ODL" },
272
+ {
273
+ id: 3,
274
+ protocol: "test protocol",
275
+ name: "Enki",
276
+ marked_for_deletion: true,
277
+ },
278
+ ],
279
+ protocols: [{ name: "test protocol", label: "TP", settings: [] }],
280
+ };
281
+ stubFetch(listData);
282
+ const store = buildStore({
283
+ editor: { libraries: loadedLibrariesState },
284
+ } as any);
285
+
286
+ const { container } = renderWithProviders(
287
+ <CollectionsConnected csrfToken="token" />,
288
+ {
289
+ reduxProviderProps: { store },
290
+ appConfigSettings: sysAdminConfig,
291
+ }
292
+ );
293
+
294
+ // The list appears once the connected component's on-mount fetch resolves
295
+ // and mapStateToProps feeds the fetched data back in as props.
296
+ expect(
297
+ await screen.findByRole("heading", { level: 3, name: "ODL" })
298
+ ).toBeInTheDocument();
299
+
300
+ // The non-deleted collection has edit and delete controls.
301
+ const editLink = container.querySelector("a.edit-item");
302
+ expect(editLink.getAttribute("href")).toBe(
303
+ "/admin/web/config/collections/edit/2"
304
+ );
305
+
306
+ // The deleted collection renders a special item with no edit/delete
307
+ // controls and an explanatory message.
308
+ const deleted = container.querySelector(".deleted-collection");
309
+ expect(deleted).not.toBeNull();
310
+ expect(deleted).toHaveTextContent("Enki");
311
+ expect(deleted).toHaveTextContent(/cannot be edited/);
312
+ expect(deleted.querySelector("a.edit-item")).toBeNull();
313
+ expect(deleted.querySelector("button.delete-item")).toBeNull();
314
+ });
315
+ });
316
+
317
+ describe("edit mode: registration and additional content", () => {
318
+ // The reusable-components `Form` builds `new FormData(formElement)` on
319
+ // submit, which the unit jsdom env's undici FormData rejects; install the
320
+ // shared shim that reads the form's successful controls.
321
+ installFormDataShim();
322
+ it("fetches registrations on mount, renders task controls, and registers a library on click", async () => {
323
+ const user = userEvent.setup();
324
+ const registerLibrary = jest.fn().mockResolvedValue(undefined);
325
+ const fetchLibraryRegistrations = jest.fn().mockResolvedValue(undefined);
326
+ renderWithContext(
327
+ <Collections
328
+ data={
329
+ {
330
+ collections: [
331
+ {
332
+ id: 7,
333
+ protocol: "OPDS Import",
334
+ name: "An OPDS Collection",
335
+ libraries: [],
336
+ },
337
+ ],
338
+ protocols: [
339
+ {
340
+ name: "OPDS Import",
341
+ supports_registration: true,
342
+ supports_import: true,
343
+ settings: [],
344
+ library_settings: [],
345
+ },
346
+ ],
347
+ allLibraries,
348
+ } as any
349
+ }
350
+ editOrCreate="edit"
351
+ identifier="7"
352
+ fetchData={jest.fn()}
353
+ editItem={jest.fn().mockResolvedValue(undefined)}
354
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
355
+ registerLibrary={registerLibrary}
356
+ fetchLibraryRegistrations={fetchLibraryRegistrations}
357
+ importCollection={jest.fn().mockResolvedValue(undefined)}
358
+ reapCollection={jest.fn().mockResolvedValue(undefined)}
359
+ csrfToken="token"
360
+ isFetching={false}
361
+ />,
362
+ sysAdminConfig
363
+ );
364
+
365
+ // componentDidMount fetches the registrations once. (CollectionEditForm's
366
+ // renderAdditionalContent also runs here, since the edited collection has
367
+ // an id, injecting the import/reap task panels into the form.)
368
+ expect(fetchLibraryRegistrations).toHaveBeenCalledTimes(1);
369
+
370
+ // Registering a library goes through getChildContext().registerLibrary.
371
+ expect(registerLibrary).not.toHaveBeenCalled();
372
+ await user.click(screen.getByRole("button", { name: "Register" }));
373
+
374
+ expect(registerLibrary).toHaveBeenCalledTimes(1);
375
+ const formData = registerLibrary.mock.calls[0][0];
376
+ expect(formData.get("library_short_name")).toBe("nypl");
377
+ expect(formData.get("collection_id")).toBe("7");
378
+
379
+ // After a successful registration, the child context refetches.
380
+ await waitFor(() =>
381
+ expect(fetchLibraryRegistrations).toHaveBeenCalledTimes(2)
382
+ );
383
+ });
384
+ });
385
+
386
+ describe("confirm before disassociating libraries", () => {
387
+ const initialLibraries = [
388
+ { short_name: "palace", name: "Palace" },
389
+ { short_name: "another-library", name: "Another Library" },
390
+ ];
391
+
392
+ const renderEditForm = (
393
+ libraries: Array<{ short_name: string }>,
394
+ libraryMeta: Array<{ short_name: string; name?: string }>
395
+ ) =>
396
+ renderWithContext(
397
+ <Collections
398
+ data={
399
+ {
400
+ collections: [
401
+ {
402
+ id: 7,
403
+ protocol: "OPDS Import",
404
+ name: "An OPDS Collection",
405
+ libraries,
406
+ },
407
+ ],
408
+ protocols: [
409
+ { name: "OPDS Import", settings: [], library_settings: [] },
410
+ ],
411
+ allLibraries: libraryMeta,
412
+ } as any
413
+ }
414
+ editOrCreate="edit"
415
+ identifier="7"
416
+ fetchData={jest.fn()}
417
+ editItem={jest.fn().mockResolvedValue(undefined)}
418
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
419
+ registerLibrary={jest.fn().mockResolvedValue(undefined)}
420
+ importCollection={jest.fn().mockResolvedValue(undefined)}
421
+ reapCollection={jest.fn().mockResolvedValue(undefined)}
422
+ csrfToken="token"
423
+ isFetching={false}
424
+ />,
425
+ sysAdminConfig
426
+ );
427
+
428
+ it("removes the library when the confirmation is accepted", () => {
429
+ const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(true);
430
+ const { container } = renderEditForm(
431
+ [...initialLibraries],
432
+ [...initialLibraries]
433
+ );
434
+
435
+ // One remove button per associated library.
436
+ expect(container.querySelectorAll("button.remove-btn")).toHaveLength(2);
437
+
438
+ fireEvent.click(container.querySelectorAll("button.remove-btn")[0]);
439
+
440
+ // The prompt names the (full) library being removed.
441
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
442
+ expect(confirmSpy.mock.calls[0][0]).toBe(
443
+ 'Disassociating library "Palace" from this collection will ' +
444
+ "remove all loans and holds for its patrons. Do you wish to continue?"
445
+ );
446
+
447
+ // The association (and its remove button) is gone.
448
+ expect(container.querySelectorAll("button.remove-btn")).toHaveLength(1);
449
+ });
450
+
451
+ it("keeps the library when the confirmation is canceled", () => {
452
+ jest.spyOn(window, "confirm").mockReturnValue(false);
453
+ const { container } = renderEditForm(
454
+ [...initialLibraries],
455
+ [...initialLibraries]
456
+ );
457
+
458
+ expect(container.querySelectorAll("button.remove-btn")).toHaveLength(2);
459
+ fireEvent.click(container.querySelectorAll("button.remove-btn")[0]);
460
+ // Nothing was removed.
461
+ expect(container.querySelectorAll("button.remove-btn")).toHaveLength(2);
462
+ });
463
+
464
+ it("uses the library short_name in the prompt when the full name is unavailable", () => {
465
+ const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(false);
466
+ // The library is not present in allLibraries, so getLibrary() returns null.
467
+ const { container } = renderEditForm([{ short_name: "orphan" }], []);
468
+
469
+ fireEvent.click(container.querySelector("button.remove-btn"));
470
+
471
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
472
+ expect(confirmSpy.mock.calls[0][0]).toBe(
473
+ 'Disassociating library "orphan" from this collection will ' +
474
+ "remove all loans and holds for its patrons. Do you wish to continue?"
475
+ );
476
+ });
477
+ });
478
+
479
+ describe("create mode", () => {
480
+ it("renders the create form without import/reap task panels", () => {
481
+ // In create mode there is no saved collection, so CollectionEditForm's
482
+ // renderAdditionalContent short-circuits and injects no task panels.
483
+ renderWithContext(
484
+ <Collections
485
+ data={
486
+ {
487
+ collections: [],
488
+ protocols: [
489
+ { name: "OPDS Import", settings: [], library_settings: [] },
490
+ ],
491
+ allLibraries,
492
+ } as any
493
+ }
494
+ editOrCreate="create"
495
+ fetchData={jest.fn()}
496
+ editItem={jest.fn().mockResolvedValue(undefined)}
497
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
498
+ registerLibrary={jest.fn().mockResolvedValue(undefined)}
499
+ importCollection={jest.fn().mockResolvedValue(undefined)}
500
+ reapCollection={jest.fn().mockResolvedValue(undefined)}
501
+ csrfToken="token"
502
+ isFetching={false}
503
+ />,
504
+ sysAdminConfig
505
+ );
506
+
507
+ expect(
508
+ screen.getByRole("heading", { name: "Create a new collection" })
509
+ ).toBeInTheDocument();
510
+ });
511
+ });
512
+
513
+ describe("deletion", () => {
514
+ it("deletes a collection and refetches after confirmation", async () => {
515
+ const deleteItem = jest.fn().mockResolvedValue(undefined);
516
+ const fetchData = jest.fn();
517
+ const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(true);
518
+
519
+ const { container } = renderWithContext(
520
+ <Collections
521
+ data={
522
+ {
523
+ collections: [{ id: 1, protocol: "p", name: "My Collection" }],
524
+ protocols: [],
525
+ allLibraries,
526
+ } as any
527
+ }
528
+ fetchData={fetchData}
529
+ editItem={jest.fn().mockResolvedValue(undefined)}
530
+ deleteItem={deleteItem}
531
+ registerLibrary={jest.fn().mockResolvedValue(undefined)}
532
+ importCollection={jest.fn().mockResolvedValue(undefined)}
533
+ reapCollection={jest.fn().mockResolvedValue(undefined)}
534
+ csrfToken="token"
535
+ isFetching={false}
536
+ />,
537
+ sysAdminConfig
538
+ );
539
+
540
+ fireEvent.click(container.querySelector("button.delete-item"));
541
+
542
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
543
+ await waitFor(() => expect(deleteItem).toHaveBeenCalledWith(1));
544
+ // fetchData is called once on mount; the post-delete refetch is the second
545
+ // call, so assert the count rather than mere invocation.
546
+ await waitFor(() => expect(fetchData).toHaveBeenCalledTimes(2));
547
+ });
548
+ });
549
+ });
@@ -1,19 +1,18 @@
1
1
  import * as React from "react";
2
- import { fireEvent } from "@testing-library/react";
2
+ import { installFormDataShim } from "../testUtils/formDataShim";
3
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
3
5
  import { DiscoveryServices } from "../../../src/components/DiscoveryServices";
6
+ import DiscoveryServicesConnected from "../../../src/components/DiscoveryServices";
4
7
  import renderWithContext from "../testUtils/renderWithContext";
8
+ import { renderWithProviders } from "../testUtils/withProviders";
9
+ import buildStore from "../../../src/store";
5
10
  import {
6
11
  ConfigurationSettings,
7
12
  DiscoveryServicesData,
8
13
  } from "../../../src/interfaces";
9
14
  import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
10
15
 
11
- // NB: This adds tests to the already existing tests in:
12
- // - `src/components/__tests__/DiscoveryServices-test.tsx`.
13
- //
14
- // Those tests should eventually be migrated here and
15
- // adapted to the Jest/React Testing Library paradigm.
16
-
17
16
  describe("DiscoveryServices - registered library disclosure", () => {
18
17
  // ── Shared fixtures ───────────────────────────────────────────────────────
19
18
 
@@ -543,3 +542,151 @@ describe("DiscoveryServices - registered library disclosure", () => {
543
542
  expect(container.querySelectorAll(".associated-items")).toHaveLength(0);
544
543
  });
545
544
  });
545
+
546
+ // These exercise behavior the disclosure tests above do not: the connect()
547
+ // wiring (mapStateToProps / mapDispatchToProps, which only run when the CONNECTED
548
+ // default export mounts) and the getChildContext().registerLibrary closure
549
+ // (which only runs when a library is registered from the edit form).
550
+
551
+ describe("DiscoveryServices - connected wiring and registration", () => {
552
+ const allLibraries = [
553
+ { short_name: "nypl", name: "NYPL", uuid: "uuid-nypl" },
554
+ ];
555
+
556
+ const sysAdminConfig: Partial<ConfigurationSettings> = {
557
+ csrfToken: "",
558
+ featureFlags: defaultFeatureFlags,
559
+ roles: [{ role: "system" }],
560
+ };
561
+
562
+ // A loaded `libraries` slice persists through the on-mount discovery fetches
563
+ // (the libraries reducer ignores those actions), so mapStateToProps takes its
564
+ // `data.allLibraries` branch on every render.
565
+ const loadedLibrariesState = {
566
+ data: { libraries: allLibraries },
567
+ isFetching: false,
568
+ isEditing: false,
569
+ fetchError: null,
570
+ formError: null,
571
+ isLoaded: true,
572
+ responseBody: null,
573
+ successMessage: null,
574
+ };
575
+
576
+ /**
577
+ * Stub `fetch` so the panel's on-mount fetches resolve with `body`. A fresh
578
+ * Response per call avoids "body already used" when several fetches run.
579
+ */
580
+ const stubFetch = (body: unknown) =>
581
+ jest.spyOn(globalThis, "fetch").mockImplementation(
582
+ async () =>
583
+ new Response(JSON.stringify(body), {
584
+ status: 200,
585
+ headers: { "Content-Type": "application/json" },
586
+ })
587
+ );
588
+
589
+ afterEach(() => {
590
+ jest.restoreAllMocks();
591
+ });
592
+
593
+ describe("connected default export", () => {
594
+ it("wires mapStateToProps/mapDispatchToProps: fetches on mount and renders the list", async () => {
595
+ const listData = {
596
+ discovery_services: [
597
+ { id: 2, protocol: "test protocol", name: "Test Discovery" },
598
+ ],
599
+ protocols: [{ name: "test protocol", label: "TP", settings: [] }],
600
+ };
601
+ stubFetch(listData);
602
+ // A fresh store, preloaded with a loaded libraries slice, isolates this
603
+ // test and exercises mapStateToProps' allLibraries branch.
604
+ const store = buildStore({
605
+ editor: { libraries: loadedLibrariesState },
606
+ } as any);
607
+
608
+ const { container } = renderWithProviders(
609
+ <DiscoveryServicesConnected csrfToken="token" />,
610
+ { reduxProviderProps: { store } }
611
+ );
612
+
613
+ // The list appears once the connected component's on-mount fetch resolves
614
+ // and mapStateToProps feeds the fetched data back in as props.
615
+ expect(
616
+ await screen.findByRole("heading", { level: 3, name: "Test Discovery" })
617
+ ).toBeInTheDocument();
618
+
619
+ const editLink = container.querySelector("a.edit-item");
620
+ expect(editLink).not.toBeNull();
621
+ expect(editLink.getAttribute("href")).toBe(
622
+ "/admin/web/config/discovery/edit/2"
623
+ );
624
+ });
625
+ });
626
+
627
+ describe("registerLibrary child context (edit mode)", () => {
628
+ // The reusable-components `Form` builds `new FormData(formElement)` on
629
+ // submit, which the unit jsdom env's undici FormData rejects; install the
630
+ // shared shim that reads the form's successful controls.
631
+ installFormDataShim();
632
+ const renderEditMode = (
633
+ registerLibrary: jest.Mock,
634
+ fetchLibraryRegistrations: jest.Mock
635
+ ) =>
636
+ renderWithContext(
637
+ <DiscoveryServices
638
+ data={
639
+ {
640
+ discovery_services: [
641
+ { id: "2", protocol: "test protocol", name: "Test Discovery" },
642
+ ],
643
+ protocols: [
644
+ {
645
+ name: "test protocol",
646
+ supports_registration: true,
647
+ settings: [],
648
+ library_settings: [],
649
+ },
650
+ ],
651
+ allLibraries,
652
+ } as any
653
+ }
654
+ editOrCreate="edit"
655
+ identifier="2"
656
+ fetchData={jest.fn()}
657
+ editItem={jest.fn().mockResolvedValue(undefined)}
658
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
659
+ registerLibrary={registerLibrary}
660
+ fetchLibraryRegistrations={fetchLibraryRegistrations}
661
+ csrfToken="token"
662
+ isFetching={false}
663
+ />,
664
+ sysAdminConfig
665
+ );
666
+
667
+ it("fetches library registrations on mount, and registers a library (building the right FormData) and refetches on click", async () => {
668
+ const user = userEvent.setup();
669
+ const registerLibrary = jest.fn().mockResolvedValue(undefined);
670
+ const fetchLibraryRegistrations = jest.fn().mockResolvedValue(undefined);
671
+ renderEditMode(registerLibrary, fetchLibraryRegistrations);
672
+
673
+ // getChildContext supplies registerLibrary to the edit form; the edit form
674
+ // surfaces a "Register" button for each library.
675
+ expect(fetchLibraryRegistrations).toHaveBeenCalledTimes(1);
676
+ expect(registerLibrary).not.toHaveBeenCalled();
677
+
678
+ await user.click(screen.getByRole("button", { name: "Register" }));
679
+
680
+ expect(registerLibrary).toHaveBeenCalledTimes(1);
681
+ const formData = registerLibrary.mock.calls[0][0];
682
+ expect(formData.get("library_short_name")).toBe("nypl");
683
+ expect(formData.get("integration_id")).toBe("2");
684
+ expect(formData.get("registration_stage")).toBe("testing");
685
+
686
+ // After a successful registration, the child context refetches.
687
+ await waitFor(() =>
688
+ expect(fetchLibraryRegistrations).toHaveBeenCalledTimes(2)
689
+ );
690
+ });
691
+ });
692
+ });