@thepalaceproject/circulation-admin 1.43.0 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CLAUDE.md +28 -7
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/eslint.config.mjs +9 -1
  5. package/jest.config.js +22 -8
  6. package/package.json +6 -20
  7. package/tests/jest/actions.test.ts +962 -0
  8. package/tests/jest/components/AccountPage.test.tsx +41 -0
  9. package/tests/jest/components/AdvancedSearchBooleanFilter.test.tsx +231 -0
  10. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +93 -20
  11. package/tests/jest/components/AdvancedSearchFilter.test.tsx +142 -0
  12. package/tests/jest/components/AdvancedSearchFilterInput.test.tsx +212 -0
  13. package/tests/jest/components/AdvancedSearchFilterViewer.test.tsx +52 -0
  14. package/tests/jest/components/AdvancedSearchValueFilter.test.tsx +123 -0
  15. package/tests/jest/components/Announcement.test.tsx +76 -0
  16. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  17. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  18. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  19. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  20. package/tests/jest/components/BookDetails.test.tsx +133 -0
  21. package/tests/jest/components/BookDetailsContainer.test.tsx +116 -0
  22. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  23. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  24. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  25. package/tests/jest/components/BookEditor.test.tsx +20 -21
  26. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  27. package/tests/jest/components/CatalogServices.test.tsx +100 -0
  28. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  29. package/tests/jest/components/CirculationEventsDownloadForm.test.tsx +41 -0
  30. package/tests/jest/components/Classifications.test.tsx +166 -0
  31. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  32. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  33. package/tests/jest/components/Collections.test.tsx +335 -7
  34. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  35. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  36. package/tests/jest/components/Complaints.test.tsx +259 -0
  37. package/tests/jest/components/ConfigPage.test.tsx +58 -0
  38. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  39. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  40. package/tests/jest/components/Contributors.test.tsx +157 -0
  41. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  42. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  43. package/tests/jest/components/CustomListPage.test.tsx +57 -0
  44. package/tests/jest/components/CustomListSearch.test.tsx +275 -0
  45. package/tests/jest/components/CustomLists.test.tsx +621 -4
  46. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  47. package/tests/jest/components/CustomListsSidebar.test.tsx +251 -0
  48. package/tests/jest/components/DashboardPage.test.tsx +76 -0
  49. package/tests/jest/components/DebugAuthentication.test.tsx +1 -5
  50. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  51. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  52. package/tests/jest/components/DiagnosticsTabContainer.test.tsx +147 -0
  53. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  54. package/tests/jest/components/EditableConfigList.test.tsx +606 -9
  55. package/tests/jest/components/EditableInput.test.tsx +250 -1
  56. package/tests/jest/components/EditorField.test.tsx +95 -0
  57. package/tests/jest/components/EntryPointsContainer.test.tsx +97 -0
  58. package/tests/jest/components/EntryPointsTabs.test.tsx +88 -0
  59. package/tests/jest/components/ErrorMessage.test.tsx +97 -0
  60. package/tests/jest/components/Footer.test.tsx +141 -0
  61. package/tests/jest/components/GenreForm.test.tsx +152 -0
  62. package/tests/jest/components/Header.test.tsx +421 -0
  63. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  64. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  65. package/tests/jest/components/InputList.test.tsx +493 -0
  66. package/tests/jest/components/InventoryReportRequestModal.test.tsx +16 -42
  67. package/tests/jest/components/JsonField.test.tsx +20 -0
  68. package/tests/jest/components/Lane.test.tsx +132 -10
  69. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  70. package/tests/jest/components/LaneEditor.test.tsx +377 -13
  71. package/tests/jest/components/LanePage.test.tsx +68 -0
  72. package/tests/jest/components/Lanes.test.tsx +446 -0
  73. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  74. package/tests/jest/components/LanguageField.test.tsx +40 -0
  75. package/tests/jest/components/Libraries.test.tsx +48 -0
  76. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  77. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  78. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  79. package/tests/jest/components/LoadButton.test.tsx +49 -0
  80. package/tests/jest/components/ManagePatrons.test.tsx +73 -0
  81. package/tests/jest/components/ManagePatronsForm.test.tsx +180 -0
  82. package/tests/jest/components/ManagePatronsTabContainer.test.tsx +143 -0
  83. package/tests/jest/components/MetadataServices.test.tsx +95 -0
  84. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  85. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  86. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  87. package/tests/jest/components/PatronBlockingRulesEditor.test.tsx +3 -1
  88. package/tests/jest/components/PatronInfo.test.tsx +54 -0
  89. package/tests/jest/components/ProtocolFormField.test.tsx +479 -7
  90. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  91. package/tests/jest/components/SaveButton.test.tsx +47 -0
  92. package/tests/jest/components/SelfTestResult.test.tsx +164 -0
  93. package/tests/jest/components/SelfTests.test.tsx +601 -0
  94. package/tests/jest/components/SelfTestsCategory.test.tsx +200 -0
  95. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  96. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  97. package/tests/jest/components/ServiceWithRegistrationsEditForm.test.tsx +110 -0
  98. package/tests/jest/components/SetupPage.test.tsx +41 -0
  99. package/tests/jest/components/SingleStatListItem.test.tsx +62 -0
  100. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  101. package/tests/jest/components/Stats.test.tsx +4 -15
  102. package/tests/jest/components/TabContainer.test.tsx +68 -0
  103. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  104. package/tests/jest/components/Timestamp.test.tsx +74 -0
  105. package/tests/jest/components/ToolTip.test.tsx +46 -0
  106. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  107. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  108. package/tests/jest/components/TroubleshootingTabContainer.test.tsx +68 -0
  109. package/tests/jest/components/UpdatingLoader.test.tsx +36 -0
  110. package/tests/jest/components/WelcomePage.test.tsx +43 -0
  111. package/tests/jest/components/WithEditButton.test.tsx +55 -0
  112. package/tests/jest/components/WithRemoveButton.test.tsx +95 -0
  113. package/tests/jest/editorAdapter.test.ts +245 -0
  114. package/tests/jest/features/book.test.ts +75 -80
  115. package/tests/jest/fixtures/classificationsData.ts +45 -0
  116. package/tests/jest/fixtures/genreData.ts +811 -0
  117. package/tests/jest/index.test.tsx +50 -0
  118. package/tests/jest/models/Admin.test.ts +206 -0
  119. package/tests/jest/reducers/bookCoverPreview.test.ts +81 -0
  120. package/tests/jest/reducers/classifications.test.ts +161 -0
  121. package/tests/jest/reducers/collections.test.ts +96 -0
  122. package/tests/jest/reducers/complaints.test.ts +166 -0
  123. package/tests/jest/reducers/createFetchEditReducer.test.ts +220 -0
  124. package/tests/jest/reducers/createRegisterLibraryReducer.test.ts +51 -0
  125. package/tests/jest/reducers/customListDetails.test.ts +120 -0
  126. package/tests/jest/reducers/customListEditor.test.ts +2823 -0
  127. package/tests/jest/reducers/managePatrons.test.ts +71 -0
  128. package/tests/jest/testUtils/formDataShim.test.ts +135 -0
  129. package/tests/jest/testUtils/formDataShim.ts +112 -0
  130. package/tests/jest/utils/sharedFunctions.test.ts +121 -0
  131. package/tsconfig.json +3 -4
  132. package/testReporter.js +0 -29
  133. package/tests/jest/README.md +0 -6
@@ -1,18 +1,15 @@
1
1
  import * as React from "react";
2
- import { fireEvent } from "@testing-library/react";
2
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
3
4
  import {
4
5
  EditableConfigList,
5
6
  EditFormProps,
7
+ AdditionalContentProps,
6
8
  } from "../../../src/components/EditableConfigList";
7
9
  import renderWithContext from "../testUtils/renderWithContext";
8
10
  import { ConfigurationSettings } from "../../../src/interfaces";
9
11
  import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
10
-
11
- // NB: This adds tests to the already existing tests in:
12
- // - `src/components/__tests__/EditableConfigList-test.tsx`.
13
- //
14
- // Those tests should eventually be migrated here and
15
- // adapted to the Jest/React Testing Library paradigm.
12
+ import * as navigate from "../../../src/utils/navigate";
16
13
 
17
14
  describe("EditableConfigList - library association disclosure", () => {
18
15
  // ── Test doubles ──────────────────────────────────────────────────────────
@@ -383,7 +380,11 @@ describe("EditableConfigList - library association disclosure", () => {
383
380
  it("falls back to short_name when allLibraries is absent from the data", () => {
384
381
  const { container } = renderWithContext(
385
382
  <TestServiceList
386
- data={{ services: [{ id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] }] }}
383
+ data={{
384
+ services: [
385
+ { id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] },
386
+ ],
387
+ }}
387
388
  fetchData={jest.fn()}
388
389
  editItem={jest.fn().mockResolvedValue(undefined)}
389
390
  deleteItem={jest.fn().mockResolvedValue(undefined)}
@@ -393,7 +394,603 @@ describe("EditableConfigList - library association disclosure", () => {
393
394
  config
394
395
  );
395
396
  fireEvent.click(container.querySelector(".association-toggle"));
396
- expect(container.querySelector(".associated-items li").textContent).toBe("nypl");
397
+ expect(container.querySelector(".associated-items li").textContent).toBe(
398
+ "nypl"
399
+ );
397
400
  });
398
401
  });
399
402
  });
403
+
404
+ // These exercise the abstract base class through concrete test subclasses,
405
+ // asserting observable DOM instead of reaching into instances.
406
+ describe("EditableConfigList - base class via test subclasses", () => {
407
+ interface Thing {
408
+ id: number;
409
+ label: string;
410
+ level?: number;
411
+ }
412
+
413
+ interface Things {
414
+ things: Thing[];
415
+ }
416
+
417
+ // Closure-controlled permission flags.
418
+ let canCreate: boolean;
419
+ let canDelete: boolean;
420
+ let canEdit: boolean;
421
+
422
+ // A test EditForm that surfaces the props the base class passes to it, so
423
+ // prop-only assertions can be checked against the rendered DOM.
424
+ class ThingEditForm extends React.Component<EditFormProps<Things, Thing>> {
425
+ render(): JSX.Element {
426
+ const { disabled, save, item, listDataKey, adminLevel } = this.props;
427
+ return (
428
+ <div className="thing-edit-form">
429
+ <span data-testid="ef-disabled">{String(disabled)}</span>
430
+ <span data-testid="ef-has-save">{String(!!save)}</span>
431
+ <span data-testid="ef-datakey">{listDataKey}</span>
432
+ <span data-testid="ef-item">{item ? String(item.id) : "none"}</span>
433
+ <span data-testid="ef-adminlevel">{String(adminLevel)}</span>
434
+ <button
435
+ type="button"
436
+ data-testid="ef-save"
437
+ onClick={() => save && save(new FormData())}
438
+ >
439
+ submit form
440
+ </button>
441
+ </div>
442
+ );
443
+ }
444
+ }
445
+
446
+ class ThingAdditionalContent extends React.Component<
447
+ AdditionalContentProps<Things, Thing>
448
+ > {
449
+ render(): JSX.Element {
450
+ return (
451
+ <div
452
+ className="additional-content"
453
+ data-item-id={String((this.props.item as any)?.id)}
454
+ data-csrf={this.props.csrfToken}
455
+ >
456
+ Test Additional Content
457
+ </div>
458
+ );
459
+ }
460
+ }
461
+
462
+ // Uses the base implementations of canCreate/canDelete/canEdit.
463
+ class BaseThingList extends EditableConfigList<Things, Thing> {
464
+ EditForm = ThingEditForm;
465
+ listDataKey = "things";
466
+ itemTypeName = "thing";
467
+ urlBase = "/admin/things/";
468
+ identifierKey = "id";
469
+ labelKey = "label";
470
+ }
471
+
472
+ // Overrides permission checks from the closure flags.
473
+ class ThingEditableConfigList extends BaseThingList {
474
+ label(item): string {
475
+ return "test " + super.label(item);
476
+ }
477
+ canCreate() {
478
+ return canCreate;
479
+ }
480
+ canDelete() {
481
+ return canDelete;
482
+ }
483
+ canEdit() {
484
+ return canEdit;
485
+ }
486
+ }
487
+
488
+ class OneThingEditableConfigList extends ThingEditableConfigList {
489
+ limitOne = true;
490
+ }
491
+
492
+ class ThingAdditionalContentEditableConfigList extends ThingEditableConfigList {
493
+ AdditionalContent = ThingAdditionalContent;
494
+ }
495
+
496
+ class CapsThingList extends BaseThingList {
497
+ itemTypeName = "CDN";
498
+ }
499
+
500
+ class ThingWithSelfTests extends ThingEditableConfigList {
501
+ links = {
502
+ info: (
503
+ <>
504
+ Self-tests for the things have been moved to{" "}
505
+ <a href="/admin/web/troubleshooting/self-tests/thingServices">
506
+ the troubleshooting page
507
+ </a>
508
+ .
509
+ </>
510
+ ),
511
+ footer: (
512
+ <>
513
+ Problems with your things? Please visit{" "}
514
+ <a href="/admin/web/troubleshooting/self-tests/thingServices">
515
+ the troubleshooting page
516
+ </a>
517
+ .
518
+ </>
519
+ ),
520
+ };
521
+ }
522
+
523
+ const thingData: Thing = { id: 5, label: "label" };
524
+ const thingsData: Things = { things: [thingData] };
525
+
526
+ let fetchData: jest.Mock;
527
+ let editItem: jest.Mock;
528
+ let deleteItem: jest.Mock;
529
+
530
+ const systemConfig: Partial<ConfigurationSettings> = {
531
+ csrfToken: "token",
532
+ featureFlags: defaultFeatureFlags,
533
+ roles: [{ role: "system", library: "nypl" }],
534
+ };
535
+
536
+ const configWithRoles = (
537
+ roles: Partial<ConfigurationSettings>["roles"]
538
+ ): Partial<ConfigurationSettings> => ({
539
+ csrfToken: "token",
540
+ featureFlags: defaultFeatureFlags,
541
+ roles,
542
+ });
543
+
544
+ const baseProps = (overrides: Record<string, any> = {}) => ({
545
+ data: thingsData,
546
+ fetchData,
547
+ editItem,
548
+ deleteItem,
549
+ csrfToken: "token",
550
+ isFetching: false,
551
+ ...overrides,
552
+ });
553
+
554
+ beforeEach(() => {
555
+ fetchData = jest.fn();
556
+ editItem = jest.fn().mockResolvedValue(undefined);
557
+ deleteItem = jest.fn().mockResolvedValue(undefined);
558
+ canCreate = true;
559
+ canDelete = true;
560
+ canEdit = true;
561
+ jest.spyOn(window, "scrollTo").mockImplementation(() => undefined);
562
+ });
563
+
564
+ afterEach(() => {
565
+ jest.restoreAllMocks();
566
+ });
567
+
568
+ it("shows an error message if there's a problem loading the list", () => {
569
+ const { container, rerender } = renderWithContext(
570
+ <ThingEditableConfigList {...baseProps()} />,
571
+ systemConfig
572
+ );
573
+ expect(container.querySelector(".alert-danger")).toBeNull();
574
+
575
+ const fetchError = {
576
+ status: 404,
577
+ response: "test load error",
578
+ url: "test url",
579
+ };
580
+ rerender(<ThingEditableConfigList {...baseProps({ fetchError })} />);
581
+ expect(container.querySelector(".alert-danger")).not.toBeNull();
582
+ expect(screen.getByText("Error: test load error")).toBeInTheDocument();
583
+
584
+ // Hidden when the user goes to the form.
585
+ rerender(
586
+ <ThingEditableConfigList
587
+ {...baseProps({ fetchError, editOrCreate: "create" })}
588
+ />
589
+ );
590
+ expect(container.querySelector(".alert-danger")).toBeNull();
591
+ });
592
+
593
+ it("shows form submission error message only if the form is displayed", () => {
594
+ const formError = {
595
+ status: 400,
596
+ response: "test submission error",
597
+ url: "test url",
598
+ };
599
+ const { container, rerender } = renderWithContext(
600
+ <ThingEditableConfigList {...baseProps({ formError })} />,
601
+ systemConfig
602
+ );
603
+ // Not displayed while looking at the list.
604
+ expect(container.querySelector(".alert-danger")).toBeNull();
605
+
606
+ rerender(
607
+ <ThingEditableConfigList
608
+ {...baseProps({ formError, editOrCreate: "create" })}
609
+ />
610
+ );
611
+ expect(container.querySelector(".alert-danger")).not.toBeNull();
612
+ expect(
613
+ screen.getByText("Error: test submission error")
614
+ ).toBeInTheDocument();
615
+
616
+ // Hidden when the user goes back to the list.
617
+ rerender(
618
+ <ThingEditableConfigList
619
+ {...baseProps({ formError, editOrCreate: "" })}
620
+ />
621
+ );
622
+ expect(container.querySelector(".alert-danger")).toBeNull();
623
+ });
624
+
625
+ it("shows a success message with an edit link on create", () => {
626
+ const { container } = renderWithContext(
627
+ <ThingEditableConfigList
628
+ {...baseProps({ responseBody: "itemType", editOrCreate: "create" })}
629
+ />,
630
+ systemConfig
631
+ );
632
+ const success = container.querySelector(".alert-success");
633
+ expect(success).not.toBeNull();
634
+ expect(success.textContent).toBe("Successfully created a new thing");
635
+ const link = success.querySelector<HTMLAnchorElement>("a");
636
+ expect(link).not.toBeNull();
637
+ expect(link.getAttribute("href")).toBe("/admin/things/edit/itemType");
638
+ });
639
+
640
+ it("shows a success message without a link on edit", () => {
641
+ const { container } = renderWithContext(
642
+ <ThingEditableConfigList
643
+ {...baseProps({ responseBody: "itemType", editOrCreate: "edit" })}
644
+ />,
645
+ systemConfig
646
+ );
647
+ const success = container.querySelector(".alert-success");
648
+ expect(success).not.toBeNull();
649
+ expect(success.textContent).toBe("Successfully edited this thing");
650
+ expect(success.querySelector("a")).toBeNull();
651
+ });
652
+
653
+ it("keeps all-caps item type names uppercase in the success message", () => {
654
+ const { container } = renderWithContext(
655
+ <CapsThingList
656
+ {...baseProps({ responseBody: "itemType", editOrCreate: "create" })}
657
+ />,
658
+ systemConfig
659
+ );
660
+ const success = container.querySelector(".alert-success");
661
+ expect(success.textContent).toBe("Successfully created a new CDN");
662
+ });
663
+
664
+ it("shows a loading indicator only while fetching", () => {
665
+ const { rerender } = renderWithContext(
666
+ <ThingEditableConfigList {...baseProps()} />,
667
+ systemConfig
668
+ );
669
+ expect(screen.queryByRole("dialog")).toBeNull();
670
+ rerender(<ThingEditableConfigList {...baseProps({ isFetching: true })} />);
671
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
672
+ });
673
+
674
+ it("shows the thing header", () => {
675
+ renderWithContext(
676
+ <ThingEditableConfigList {...baseProps()} />,
677
+ systemConfig
678
+ );
679
+ expect(
680
+ screen.getByRole("heading", { level: 2, name: "Thing configuration" })
681
+ ).toBeInTheDocument();
682
+ });
683
+
684
+ it("shows the thing list with an edit link and a count", () => {
685
+ const { container } = renderWithContext(
686
+ <ThingEditableConfigList {...baseProps()} />,
687
+ systemConfig
688
+ );
689
+ const items = container.querySelectorAll("ul > li");
690
+ expect(items).toHaveLength(1);
691
+ expect(items[0].textContent).toContain("test label");
692
+ const editLink = items[0].querySelector<HTMLAnchorElement>(".edit-item");
693
+ expect(editLink.getAttribute("href")).toBe("/admin/things/edit/5");
694
+ expect(
695
+ container.querySelector(".list-container header div").textContent
696
+ ).toBe("1 configured");
697
+ });
698
+
699
+ it("updates the thing list when new data arrives", () => {
700
+ const { container, rerender } = renderWithContext(
701
+ <ThingEditableConfigList {...baseProps()} />,
702
+ systemConfig
703
+ );
704
+ const newThingsData = {
705
+ things: [thingData, { id: 6, label: "another thing" }],
706
+ };
707
+ rerender(
708
+ <ThingEditableConfigList {...baseProps({ data: newThingsData })} />
709
+ );
710
+ const items = container.querySelectorAll("ul > li");
711
+ expect(items).toHaveLength(2);
712
+ expect(items[1].textContent).toContain("test another thing");
713
+ expect(
714
+ items[1]
715
+ .querySelector<HTMLAnchorElement>(".edit-item")
716
+ .getAttribute("href")
717
+ ).toBe("/admin/things/edit/6");
718
+ });
719
+
720
+ it("shows the create link, which respects canCreate", () => {
721
+ const { container, rerender } = renderWithContext(
722
+ <ThingEditableConfigList {...baseProps()} />,
723
+ systemConfig
724
+ );
725
+ const createLink =
726
+ container.querySelector<HTMLAnchorElement>(".create-item");
727
+ expect(createLink.textContent).toBe("Create new thing");
728
+ expect(createLink.getAttribute("href")).toBe("/admin/things/create");
729
+
730
+ canCreate = false;
731
+ rerender(<ThingEditableConfigList {...baseProps()} />);
732
+ expect(container.querySelector(".create-item")).toBeNull();
733
+ });
734
+
735
+ it("uses the base canCreate (true) when a subclass does not override it", () => {
736
+ const { container } = renderWithContext(
737
+ <BaseThingList {...baseProps()} />,
738
+ systemConfig
739
+ );
740
+ expect(container.querySelector(".create-item")).not.toBeNull();
741
+ });
742
+
743
+ it("hides the create link if only one item is allowed and it already exists", () => {
744
+ const { container, rerender } = renderWithContext(
745
+ <OneThingEditableConfigList {...baseProps()} />,
746
+ systemConfig
747
+ );
748
+ expect(container.querySelector(".create-item")).toBeNull();
749
+
750
+ rerender(
751
+ <OneThingEditableConfigList {...baseProps({ data: { things: [] } })} />
752
+ );
753
+ const createLink =
754
+ container.querySelector<HTMLAnchorElement>(".create-item");
755
+ expect(createLink).not.toBeNull();
756
+ expect(createLink.getAttribute("href")).toBe("/admin/things/create");
757
+ });
758
+
759
+ it("shows a view button instead of an edit button when canEdit is false", () => {
760
+ canEdit = false;
761
+ const { container } = renderWithContext(
762
+ <ThingEditableConfigList
763
+ {...baseProps({
764
+ data: { things: [{ id: 6, label: "View Only", level: 3 }] },
765
+ })}
766
+ />,
767
+ configWithRoles([{ role: "manager", library: "nypl" }])
768
+ );
769
+ const editItemLink = container.querySelector(".edit-item");
770
+ expect(editItemLink.textContent).toContain("View");
771
+ expect(editItemLink.textContent).not.toContain("Edit");
772
+ });
773
+
774
+ it("shows a delete button when canDelete is true and hides it otherwise", () => {
775
+ // Base canDelete: system admin (level 3) → delete button shown.
776
+ const shown = renderWithContext(
777
+ <BaseThingList {...baseProps()} />,
778
+ systemConfig
779
+ );
780
+ expect(shown.container.querySelector(".delete-item")).not.toBeNull();
781
+ shown.unmount();
782
+
783
+ // Base canDelete for a librarian (level 1) → no delete button.
784
+ const hiddenByRole = renderWithContext(
785
+ <BaseThingList {...baseProps()} />,
786
+ configWithRoles([{ role: "librarian", library: "nypl" }])
787
+ );
788
+ expect(hiddenByRole.container.querySelector(".delete-item")).toBeNull();
789
+ hiddenByRole.unmount();
790
+
791
+ // Explicit override returning false → no delete button.
792
+ canDelete = false;
793
+ const hiddenByOverride = renderWithContext(
794
+ <ThingEditableConfigList {...baseProps()} />,
795
+ systemConfig
796
+ );
797
+ expect(hiddenByOverride.container.querySelector(".delete-item")).toBeNull();
798
+ });
799
+
800
+ it("deletes an item only after the user confirms", async () => {
801
+ const user = userEvent.setup();
802
+ const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(false);
803
+ const { container } = renderWithContext(
804
+ <BaseThingList {...baseProps()} />,
805
+ systemConfig
806
+ );
807
+ const deleteButton =
808
+ container.querySelector<HTMLButtonElement>(".delete-item");
809
+ expect(deleteButton).not.toBeNull();
810
+
811
+ await user.click(deleteButton);
812
+ expect(deleteItem).toHaveBeenCalledTimes(0);
813
+
814
+ confirmSpy.mockReturnValue(true);
815
+ await user.click(deleteButton);
816
+ await waitFor(() => expect(deleteItem).toHaveBeenCalledTimes(1));
817
+ expect(deleteItem).toHaveBeenCalledWith(5);
818
+ });
819
+
820
+ it("renders the create form and its header", () => {
821
+ renderWithContext(
822
+ <ThingEditableConfigList {...baseProps({ editOrCreate: "create" })} />,
823
+ systemConfig
824
+ );
825
+ expect(
826
+ screen.getByRole("heading", { level: 3, name: "Create a new thing" })
827
+ ).toBeInTheDocument();
828
+ expect(screen.getByTestId("ef-item")).toHaveTextContent("none");
829
+ expect(screen.getByTestId("ef-disabled")).toHaveTextContent("false");
830
+ expect(screen.getByTestId("ef-datakey")).toHaveTextContent("things");
831
+ expect(screen.getByTestId("ef-has-save")).toHaveTextContent("true");
832
+ });
833
+
834
+ it("renders the edit form with the item and an editable header", () => {
835
+ renderWithContext(
836
+ <ThingEditableConfigList
837
+ {...baseProps({ editOrCreate: "edit", identifier: "5" })}
838
+ />,
839
+ systemConfig
840
+ );
841
+ expect(
842
+ screen.getByRole("heading", { level: 3, name: "Edit test label" })
843
+ ).toBeInTheDocument();
844
+ expect(screen.getByTestId("ef-item")).toHaveTextContent("5");
845
+ expect(screen.getByTestId("ef-disabled")).toHaveTextContent("false");
846
+ expect(screen.getByTestId("ef-has-save")).toHaveTextContent("true");
847
+ });
848
+
849
+ it("shows a non-editable header, no save function, and a disabled form when canEdit is false", () => {
850
+ canEdit = false;
851
+ renderWithContext(
852
+ <ThingEditableConfigList
853
+ {...baseProps({ editOrCreate: "edit", identifier: "5" })}
854
+ />,
855
+ systemConfig
856
+ );
857
+ expect(
858
+ screen.getByRole("heading", { level: 3, name: "test label" })
859
+ ).toBeInTheDocument();
860
+ expect(screen.getByTestId("ef-has-save")).toHaveTextContent("false");
861
+ expect(screen.getByTestId("ef-disabled")).toHaveTextContent("true");
862
+ });
863
+
864
+ it("updates the edit-form header when the item's label changes", () => {
865
+ const { rerender } = renderWithContext(
866
+ <ThingEditableConfigList
867
+ {...baseProps({ editOrCreate: "edit", identifier: "5" })}
868
+ />,
869
+ systemConfig
870
+ );
871
+ expect(
872
+ screen.getByRole("heading", { level: 3, name: "Edit test label" })
873
+ ).toBeInTheDocument();
874
+
875
+ rerender(
876
+ <ThingEditableConfigList
877
+ {...baseProps({
878
+ editOrCreate: "edit",
879
+ identifier: "5",
880
+ data: { things: [{ id: 5, label: "new thing!" }] },
881
+ })}
882
+ />
883
+ );
884
+ expect(
885
+ screen.getByRole("heading", { level: 3, name: "Edit test new thing!" })
886
+ ).toBeInTheDocument();
887
+ });
888
+
889
+ it("fetches on mount, passes a working save function, and refetches on save", async () => {
890
+ const user = userEvent.setup();
891
+ renderWithContext(
892
+ <ThingEditableConfigList {...baseProps({ editOrCreate: "create" })} />,
893
+ systemConfig
894
+ );
895
+ expect(fetchData).toHaveBeenCalledTimes(1);
896
+ expect(editItem).toHaveBeenCalledTimes(0);
897
+
898
+ await user.click(screen.getByTestId("ef-save"));
899
+ expect(editItem).toHaveBeenCalledTimes(1);
900
+ await waitFor(() => expect(fetchData).toHaveBeenCalledTimes(2));
901
+ });
902
+
903
+ it("does not fetch on mount if a fetch is already in progress", () => {
904
+ renderWithContext(
905
+ <ThingEditableConfigList {...baseProps({ isFetching: true })} />,
906
+ systemConfig
907
+ );
908
+ expect(fetchData).toHaveBeenCalledTimes(0);
909
+ });
910
+
911
+ it("navigates to the edit page after successfully creating a limit-one item", async () => {
912
+ const user = userEvent.setup();
913
+ const navigateSpy = jest
914
+ .spyOn(navigate, "navigateTo")
915
+ .mockImplementation(() => undefined);
916
+ renderWithContext(
917
+ <OneThingEditableConfigList
918
+ {...baseProps({ editOrCreate: "create", responseBody: "99" })}
919
+ />,
920
+ systemConfig
921
+ );
922
+ await user.click(screen.getByTestId("ef-save"));
923
+ await waitFor(
924
+ () => expect(navigateSpy).toHaveBeenCalledWith("/admin/things/edit/99"),
925
+ { timeout: 3000 }
926
+ );
927
+ });
928
+
929
+ it("does not render AdditionalContent unless the subclass supplies it", () => {
930
+ const { container, unmount } = renderWithContext(
931
+ <ThingEditableConfigList {...baseProps()} />,
932
+ systemConfig
933
+ );
934
+ expect(container.querySelector(".additional-content")).toBeNull();
935
+ unmount();
936
+
937
+ const withContent = renderWithContext(
938
+ <ThingAdditionalContentEditableConfigList {...baseProps()} />,
939
+ systemConfig
940
+ );
941
+ const additional = withContent.container.querySelector<HTMLElement>(
942
+ ".additional-content"
943
+ );
944
+ expect(additional).not.toBeNull();
945
+ expect(additional.textContent).toBe("Test Additional Content");
946
+ expect(additional.getAttribute("data-item-id")).toBe("5");
947
+ expect(additional.getAttribute("data-csrf")).toBe("token");
948
+ });
949
+
950
+ it("shows the troubleshooting link and info alert only when there are self-tests", () => {
951
+ const { container, unmount } = renderWithContext(
952
+ <ThingEditableConfigList {...baseProps()} />,
953
+ systemConfig
954
+ );
955
+ // No self-tests configured → neither the info alert nor the footer link.
956
+ expect(container.querySelector(".alert-info")).toBeNull();
957
+ expect(screen.queryByText(/visit the troubleshooting page/)).toBeNull();
958
+ unmount();
959
+
960
+ const withSelfTests = renderWithContext(
961
+ <ThingWithSelfTests {...baseProps()} />,
962
+ systemConfig
963
+ );
964
+ const info = withSelfTests.container.querySelector(".alert-info");
965
+ expect(info.textContent).toBe(
966
+ "Self-tests for the things have been moved to the troubleshooting page."
967
+ );
968
+ expect(info.querySelector("a").getAttribute("href")).toBe(
969
+ "/admin/web/troubleshooting/self-tests/thingServices"
970
+ );
971
+
972
+ const footer = withSelfTests.container.querySelector("p");
973
+ expect(footer.textContent).toBe(
974
+ "Problems with your things? Please visit the troubleshooting page."
975
+ );
976
+ expect(footer.querySelector("a").getAttribute("href")).toBe(
977
+ "/admin/web/troubleshooting/self-tests/thingServices"
978
+ );
979
+ });
980
+
981
+ it("figures out what level of permissions the admin has", () => {
982
+ const adminLevelFor = (roles) => {
983
+ const { getByTestId, unmount } = renderWithContext(
984
+ <BaseThingList {...baseProps({ editOrCreate: "create" })} />,
985
+ configWithRoles(roles)
986
+ );
987
+ const level = getByTestId("ef-adminlevel").textContent;
988
+ unmount();
989
+ return level;
990
+ };
991
+
992
+ expect(adminLevelFor([{ role: "system", library: "nypl" }])).toBe("3");
993
+ expect(adminLevelFor([{ role: "manager", library: "nypl" }])).toBe("2");
994
+ expect(adminLevelFor([{ role: "librarian", library: "nypl" }])).toBe("1");
995
+ });
996
+ });