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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CLAUDE.md +28 -7
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/eslint.config.mjs +9 -1
  5. package/jest.config.js +22 -8
  6. package/package.json +6 -20
  7. package/tests/jest/actions.test.ts +962 -0
  8. package/tests/jest/components/AccountPage.test.tsx +41 -0
  9. package/tests/jest/components/AdvancedSearchBooleanFilter.test.tsx +231 -0
  10. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +93 -20
  11. package/tests/jest/components/AdvancedSearchFilter.test.tsx +142 -0
  12. package/tests/jest/components/AdvancedSearchFilterInput.test.tsx +212 -0
  13. package/tests/jest/components/AdvancedSearchFilterViewer.test.tsx +52 -0
  14. package/tests/jest/components/AdvancedSearchValueFilter.test.tsx +123 -0
  15. package/tests/jest/components/Announcement.test.tsx +76 -0
  16. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  17. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  18. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  19. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  20. package/tests/jest/components/BookDetails.test.tsx +133 -0
  21. package/tests/jest/components/BookDetailsContainer.test.tsx +116 -0
  22. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  23. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  24. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  25. package/tests/jest/components/BookEditor.test.tsx +20 -21
  26. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  27. package/tests/jest/components/CatalogServices.test.tsx +100 -0
  28. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  29. package/tests/jest/components/CirculationEventsDownloadForm.test.tsx +41 -0
  30. package/tests/jest/components/Classifications.test.tsx +166 -0
  31. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  32. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  33. package/tests/jest/components/Collections.test.tsx +335 -7
  34. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  35. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  36. package/tests/jest/components/Complaints.test.tsx +259 -0
  37. package/tests/jest/components/ConfigPage.test.tsx +58 -0
  38. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  39. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  40. package/tests/jest/components/Contributors.test.tsx +157 -0
  41. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  42. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  43. package/tests/jest/components/CustomListPage.test.tsx +57 -0
  44. package/tests/jest/components/CustomListSearch.test.tsx +275 -0
  45. package/tests/jest/components/CustomLists.test.tsx +621 -4
  46. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  47. package/tests/jest/components/CustomListsSidebar.test.tsx +251 -0
  48. package/tests/jest/components/DashboardPage.test.tsx +76 -0
  49. package/tests/jest/components/DebugAuthentication.test.tsx +1 -5
  50. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  51. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  52. package/tests/jest/components/DiagnosticsTabContainer.test.tsx +147 -0
  53. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  54. package/tests/jest/components/EditableConfigList.test.tsx +606 -9
  55. package/tests/jest/components/EditableInput.test.tsx +250 -1
  56. package/tests/jest/components/EditorField.test.tsx +95 -0
  57. package/tests/jest/components/EntryPointsContainer.test.tsx +97 -0
  58. package/tests/jest/components/EntryPointsTabs.test.tsx +88 -0
  59. package/tests/jest/components/ErrorMessage.test.tsx +97 -0
  60. package/tests/jest/components/Footer.test.tsx +141 -0
  61. package/tests/jest/components/GenreForm.test.tsx +152 -0
  62. package/tests/jest/components/Header.test.tsx +421 -0
  63. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  64. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  65. package/tests/jest/components/InputList.test.tsx +493 -0
  66. package/tests/jest/components/InventoryReportRequestModal.test.tsx +16 -42
  67. package/tests/jest/components/JsonField.test.tsx +20 -0
  68. package/tests/jest/components/Lane.test.tsx +132 -10
  69. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  70. package/tests/jest/components/LaneEditor.test.tsx +377 -13
  71. package/tests/jest/components/LanePage.test.tsx +68 -0
  72. package/tests/jest/components/Lanes.test.tsx +446 -0
  73. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  74. package/tests/jest/components/LanguageField.test.tsx +40 -0
  75. package/tests/jest/components/Libraries.test.tsx +48 -0
  76. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  77. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  78. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  79. package/tests/jest/components/LoadButton.test.tsx +49 -0
  80. package/tests/jest/components/ManagePatrons.test.tsx +73 -0
  81. package/tests/jest/components/ManagePatronsForm.test.tsx +180 -0
  82. package/tests/jest/components/ManagePatronsTabContainer.test.tsx +143 -0
  83. package/tests/jest/components/MetadataServices.test.tsx +95 -0
  84. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  85. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  86. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  87. package/tests/jest/components/PatronBlockingRulesEditor.test.tsx +3 -1
  88. package/tests/jest/components/PatronInfo.test.tsx +54 -0
  89. package/tests/jest/components/ProtocolFormField.test.tsx +479 -7
  90. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  91. package/tests/jest/components/SaveButton.test.tsx +47 -0
  92. package/tests/jest/components/SelfTestResult.test.tsx +164 -0
  93. package/tests/jest/components/SelfTests.test.tsx +601 -0
  94. package/tests/jest/components/SelfTestsCategory.test.tsx +200 -0
  95. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  96. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  97. package/tests/jest/components/ServiceWithRegistrationsEditForm.test.tsx +110 -0
  98. package/tests/jest/components/SetupPage.test.tsx +41 -0
  99. package/tests/jest/components/SingleStatListItem.test.tsx +62 -0
  100. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  101. package/tests/jest/components/Stats.test.tsx +4 -15
  102. package/tests/jest/components/TabContainer.test.tsx +68 -0
  103. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  104. package/tests/jest/components/Timestamp.test.tsx +74 -0
  105. package/tests/jest/components/ToolTip.test.tsx +46 -0
  106. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  107. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  108. package/tests/jest/components/TroubleshootingTabContainer.test.tsx +68 -0
  109. package/tests/jest/components/UpdatingLoader.test.tsx +36 -0
  110. package/tests/jest/components/WelcomePage.test.tsx +43 -0
  111. package/tests/jest/components/WithEditButton.test.tsx +55 -0
  112. package/tests/jest/components/WithRemoveButton.test.tsx +95 -0
  113. package/tests/jest/editorAdapter.test.ts +245 -0
  114. package/tests/jest/features/book.test.ts +75 -80
  115. package/tests/jest/fixtures/classificationsData.ts +45 -0
  116. package/tests/jest/fixtures/genreData.ts +811 -0
  117. package/tests/jest/index.test.tsx +50 -0
  118. package/tests/jest/models/Admin.test.ts +206 -0
  119. package/tests/jest/reducers/bookCoverPreview.test.ts +81 -0
  120. package/tests/jest/reducers/classifications.test.ts +161 -0
  121. package/tests/jest/reducers/collections.test.ts +96 -0
  122. package/tests/jest/reducers/complaints.test.ts +166 -0
  123. package/tests/jest/reducers/createFetchEditReducer.test.ts +220 -0
  124. package/tests/jest/reducers/createRegisterLibraryReducer.test.ts +51 -0
  125. package/tests/jest/reducers/customListDetails.test.ts +120 -0
  126. package/tests/jest/reducers/customListEditor.test.ts +2823 -0
  127. package/tests/jest/reducers/managePatrons.test.ts +71 -0
  128. package/tests/jest/testUtils/formDataShim.test.ts +135 -0
  129. package/tests/jest/testUtils/formDataShim.ts +112 -0
  130. package/tests/jest/utils/sharedFunctions.test.ts +121 -0
  131. package/tsconfig.json +3 -4
  132. package/testReporter.js +0 -29
  133. package/tests/jest/README.md +0 -6
@@ -0,0 +1,180 @@
1
+ import * as React from "react";
2
+ import { screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import { renderWithProviders } from "../testUtils/withProviders";
6
+ import { installFormDataShim } from "../testUtils/formDataShim";
7
+ import buildStore from "../../../src/store";
8
+ import { ManagePatronsForm } from "../../../src/components/ManagePatronsForm";
9
+ import { PatronData } from "../../../src/interfaces";
10
+
11
+ // The reusable-components `Form` builds `new FormData(formElement)` on submit,
12
+ // which the unit jsdom environment's undici FormData rejects; install the shared
13
+ // shim that reads the form's successful controls.
14
+ installFormDataShim();
15
+
16
+ const patron: PatronData = {
17
+ authorization_expires: "",
18
+ username: "User Name",
19
+ personal_name: "Personal Name",
20
+ email_address: "user@email.com",
21
+ authorization_identifier: "1234",
22
+ authorization_identifiers: [""],
23
+ block_reason: "",
24
+ external_type: "",
25
+ fines: "",
26
+ permanent_id: "",
27
+ };
28
+
29
+ // `clearPatronData` is called on unmount (RTL cleanup), so it must always be a
30
+ // function; `patronLookup` too, so the connected dispatch props are stubbed.
31
+ const renderForm = (props: Record<string, unknown> = {}) =>
32
+ renderWithProviders(
33
+ <ManagePatronsForm
34
+ store={buildStore()}
35
+ csrfToken="token"
36
+ library="nypl"
37
+ patronLookup={jest.fn()}
38
+ clearPatronData={jest.fn()}
39
+ {...props}
40
+ />
41
+ );
42
+
43
+ describe("ManagePatronsForm", () => {
44
+ describe("rendering", () => {
45
+ it("has .manage-patrons-form class", () => {
46
+ const { container } = renderForm();
47
+ expect(
48
+ container.querySelector(".manage-patrons-form")
49
+ ).toBeInTheDocument();
50
+ });
51
+
52
+ it("shows an editable identifier input", () => {
53
+ renderForm();
54
+ expect(
55
+ screen.getByPlaceholderText("Enter the patron's identifier")
56
+ ).toBeInTheDocument();
57
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
58
+ });
59
+
60
+ it("has a form with an input field and a button", () => {
61
+ const { container } = renderForm();
62
+ expect(container.querySelectorAll("form")).toHaveLength(1);
63
+ expect(container.querySelectorAll("form input")).toHaveLength(1);
64
+ expect(container.querySelectorAll("form button")).toHaveLength(1);
65
+ });
66
+
67
+ it("doesn't initially show an alert or error message", () => {
68
+ const { container } = renderForm();
69
+ expect(container.querySelector(".alert")).not.toBeInTheDocument();
70
+ expect(screen.queryByText(/Patron found/)).not.toBeInTheDocument();
71
+ });
72
+ });
73
+
74
+ describe("behavior", () => {
75
+ it("calls patronLookup on submit", async () => {
76
+ const user = userEvent.setup();
77
+ const patronLookup = jest.fn();
78
+ renderForm({ patronLookup });
79
+
80
+ expect(patronLookup).not.toHaveBeenCalled();
81
+
82
+ await user.type(
83
+ screen.getByPlaceholderText("Enter the patron's identifier"),
84
+ "1234"
85
+ );
86
+ await user.click(screen.getByRole("button"));
87
+
88
+ expect(patronLookup).toHaveBeenCalledTimes(1);
89
+ // The second argument is the library short name.
90
+ expect(patronLookup.mock.calls[0][1]).toBe("nypl");
91
+ });
92
+
93
+ it("displays an error message if there is an error", () => {
94
+ const fetchError = { status: 400, response: "", url: "" };
95
+ const { container, rerender } = renderForm();
96
+ expect(container.querySelector(".alert-danger")).not.toBeInTheDocument();
97
+
98
+ rerender(
99
+ <ManagePatronsForm
100
+ store={buildStore()}
101
+ csrfToken="token"
102
+ library="nypl"
103
+ patronLookup={jest.fn()}
104
+ clearPatronData={jest.fn()}
105
+ fetchError={fetchError}
106
+ />
107
+ );
108
+
109
+ expect(container.querySelector(".alert-danger")).toBeInTheDocument();
110
+ expect(screen.getByText(/Error:/)).toBeInTheDocument();
111
+ });
112
+
113
+ it("should display a no-identifier message if the input field is blank when searching", () => {
114
+ const fetchError = {
115
+ status: 400,
116
+ response: "No patron identifier provided",
117
+ url: "",
118
+ };
119
+ const { rerender } = renderForm();
120
+ expect(
121
+ screen.queryByText("Error: No patron identifier provided")
122
+ ).not.toBeInTheDocument();
123
+
124
+ rerender(
125
+ <ManagePatronsForm
126
+ store={buildStore()}
127
+ csrfToken="token"
128
+ library="nypl"
129
+ patronLookup={jest.fn()}
130
+ clearPatronData={jest.fn()}
131
+ fetchError={fetchError}
132
+ />
133
+ );
134
+
135
+ expect(
136
+ screen.getByText("Error: No patron identifier provided")
137
+ ).toBeInTheDocument();
138
+ });
139
+
140
+ it("should display a success alert message when a patron is found", () => {
141
+ const { container, rerender } = renderForm();
142
+ expect(container.querySelector(".alert-success")).not.toBeInTheDocument();
143
+
144
+ rerender(
145
+ <ManagePatronsForm
146
+ store={buildStore()}
147
+ csrfToken="token"
148
+ library="nypl"
149
+ patronLookup={jest.fn()}
150
+ clearPatronData={jest.fn()}
151
+ patron={patron}
152
+ />
153
+ );
154
+
155
+ const alert = container.querySelector(".alert-success");
156
+ expect(alert).toBeInTheDocument();
157
+ expect(alert).toHaveTextContent(
158
+ `Patron found: ${patron.authorization_identifier}`
159
+ );
160
+ });
161
+
162
+ it("should show the PatronInfo list when a patron is found", () => {
163
+ const { container, rerender } = renderForm();
164
+ expect(container.querySelector(".patron-info")).not.toBeInTheDocument();
165
+
166
+ rerender(
167
+ <ManagePatronsForm
168
+ store={buildStore()}
169
+ csrfToken="token"
170
+ library="nypl"
171
+ patronLookup={jest.fn()}
172
+ clearPatronData={jest.fn()}
173
+ patron={patron}
174
+ />
175
+ );
176
+
177
+ expect(container.querySelector(".patron-info")).toBeInTheDocument();
178
+ });
179
+ });
180
+ });
@@ -0,0 +1,143 @@
1
+ import * as React from "react";
2
+ import * as PropTypes from "prop-types";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+
6
+ import buildStore from "../../../src/store";
7
+ import Admin from "../../../src/models/Admin";
8
+
9
+ // Both tab panels fetch on mount (ResetAdobeId is a connected component; Debug
10
+ // Authentication uses react-query). They're incidental to a tab-navigation test,
11
+ // so mock them to markers. ResetAdobeId echoes the csrfToken asserted below.
12
+ jest.mock("../../../src/components/ResetAdobeId", () => ({
13
+ __esModule: true,
14
+ default: (props: any) => (
15
+ <div data-testid="reset-adobe-id" data-csrf-token={props.csrfToken} />
16
+ ),
17
+ }));
18
+ jest.mock("../../../src/components/DebugAuthentication", () => ({
19
+ __esModule: true,
20
+ default: () => <div data-testid="debug-authentication" />,
21
+ }));
22
+
23
+ import ManagePatronsTabContainer from "../../../src/components/ManagePatronsTabContainer";
24
+
25
+ // ManagePatronsTabContainer reads `admin`, `router`, and `pathFor` from the legacy
26
+ // React context the app supplies in production. Wrap it in a small provider that
27
+ // hands it those.
28
+ class LegacyContextProvider extends React.Component<{ context: any }> {
29
+ static childContextTypes = {
30
+ router: PropTypes.object,
31
+ pathFor: PropTypes.func,
32
+ admin: PropTypes.object,
33
+ };
34
+ getChildContext() {
35
+ return this.props.context;
36
+ }
37
+ render() {
38
+ return this.props.children;
39
+ }
40
+ }
41
+
42
+ describe("ManagePatronsTabContainer", () => {
43
+ const systemAdmin = new Admin([{ role: "system" }]);
44
+ const libraryManagerNYPL = new Admin([{ role: "manager", library: "NYPL" }]);
45
+ const libraryManagerA = new Admin([{ role: "manager", library: "a" }]);
46
+ const librarian = new Admin([{ role: "librarian", library: "NYPL" }]);
47
+
48
+ const renderWithAdmin = (admin: Admin, push: jest.Mock = jest.fn()) => {
49
+ const context = { router: { push }, pathFor: jest.fn(), admin };
50
+ const result = render(
51
+ <LegacyContextProvider context={context}>
52
+ <ManagePatronsTabContainer
53
+ tab={null}
54
+ csrfToken="token"
55
+ store={buildStore()}
56
+ library="NYPL"
57
+ />
58
+ </LegacyContextProvider>
59
+ );
60
+ return { ...result, push };
61
+ };
62
+
63
+ describe("for system admin", () => {
64
+ it("shows tabs", () => {
65
+ renderWithAdmin(systemAdmin);
66
+ expect(
67
+ screen.getByRole("link", { name: "Reset Adobe ID" })
68
+ ).toBeInTheDocument();
69
+ expect(
70
+ screen.getByRole("link", { name: "Debug Authentication" })
71
+ ).toBeInTheDocument();
72
+ });
73
+
74
+ it("shows components", () => {
75
+ renderWithAdmin(systemAdmin);
76
+ expect(screen.getByTestId("reset-adobe-id")).toHaveAttribute(
77
+ "data-csrf-token",
78
+ "token"
79
+ );
80
+ });
81
+
82
+ it("uses router to navigate when tab is clicked", async () => {
83
+ const user = userEvent.setup();
84
+ const { push } = renderWithAdmin(systemAdmin);
85
+ await user.click(screen.getByRole("link", { name: "Reset Adobe ID" }));
86
+ expect(push).toHaveBeenCalledTimes(1);
87
+ expect(push).toHaveBeenCalledWith("/admin/web/patrons/NYPL/resetAdobeId");
88
+ });
89
+ });
90
+
91
+ describe("for right library manager", () => {
92
+ it("shows tabs", () => {
93
+ renderWithAdmin(libraryManagerNYPL);
94
+ expect(
95
+ screen.getByRole("link", { name: "Reset Adobe ID" })
96
+ ).toBeInTheDocument();
97
+ expect(
98
+ screen.getByRole("link", { name: "Debug Authentication" })
99
+ ).toBeInTheDocument();
100
+ });
101
+
102
+ it("marks the current tab as active", () => {
103
+ const { container } = renderWithAdmin(libraryManagerNYPL);
104
+ const tabItems = container.querySelectorAll("ul.nav-tabs li");
105
+ expect(tabItems).toHaveLength(2);
106
+ expect(tabItems[0]).toHaveClass("active");
107
+ });
108
+
109
+ it("shows components", () => {
110
+ renderWithAdmin(libraryManagerNYPL);
111
+ expect(screen.getByTestId("reset-adobe-id")).toHaveAttribute(
112
+ "data-csrf-token",
113
+ "token"
114
+ );
115
+ });
116
+
117
+ it("uses router to navigate when tab is clicked", async () => {
118
+ const user = userEvent.setup();
119
+ const { push } = renderWithAdmin(libraryManagerNYPL);
120
+ await user.click(screen.getByRole("link", { name: "Reset Adobe ID" }));
121
+ expect(push).toHaveBeenCalledTimes(1);
122
+ expect(push).toHaveBeenCalledWith("/admin/web/patrons/NYPL/resetAdobeId");
123
+ });
124
+ });
125
+
126
+ describe("for wrong library manager", () => {
127
+ it("doesn't show tabs", () => {
128
+ renderWithAdmin(libraryManagerA);
129
+ expect(
130
+ screen.queryByRole("link", { name: "Reset Adobe ID" })
131
+ ).not.toBeInTheDocument();
132
+ });
133
+ });
134
+
135
+ describe("for librarian", () => {
136
+ it("doesn't show tabs", () => {
137
+ renderWithAdmin(librarian);
138
+ expect(
139
+ screen.queryByRole("link", { name: "Reset Adobe ID" })
140
+ ).not.toBeInTheDocument();
141
+ });
142
+ });
143
+ });
@@ -0,0 +1,95 @@
1
+ import * as React from "react";
2
+ import { screen, within } from "@testing-library/react";
3
+ import MetadataServicesConnected, {
4
+ MetadataServices,
5
+ } from "../../../src/components/MetadataServices";
6
+ import renderWithContext from "../testUtils/renderWithContext";
7
+ import { renderWithProviders } from "../testUtils/withProviders";
8
+ import buildStore from "../../../src/store";
9
+ import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
10
+
11
+ describe("MetadataServices", () => {
12
+ const data = {
13
+ metadata_services: [
14
+ {
15
+ id: 2,
16
+ protocol: "test protocol",
17
+ name: "sample name",
18
+ settings: {
19
+ test_setting: "test setting",
20
+ },
21
+ libraries: [{ short_name: "nypl" }],
22
+ },
23
+ ],
24
+ protocols: [
25
+ {
26
+ name: "test protocol",
27
+ label: "test protocol label",
28
+ sitewide: false,
29
+ settings: [],
30
+ },
31
+ ],
32
+ allLibraries: [
33
+ {
34
+ short_name: "nypl",
35
+ },
36
+ ],
37
+ };
38
+
39
+ const config = { csrfToken: "token", featureFlags: defaultFeatureFlags };
40
+
41
+ it("shows metadata service list", () => {
42
+ renderWithContext(
43
+ <MetadataServices
44
+ data={data}
45
+ fetchData={jest.fn()}
46
+ editItem={jest.fn().mockResolvedValue(undefined)}
47
+ csrfToken="token"
48
+ isFetching={false}
49
+ />,
50
+ config
51
+ );
52
+
53
+ const items = screen.getAllByRole("listitem");
54
+ expect(items).toHaveLength(1);
55
+ expect(items[0]).toHaveTextContent("sample name: test protocol");
56
+
57
+ const editLink = within(items[0]).getByRole("link");
58
+ expect(editLink).toHaveAttribute(
59
+ "href",
60
+ "/admin/web/config/metadata/edit/2"
61
+ );
62
+ });
63
+ });
64
+
65
+ describe("MetadataServices - connected wiring", () => {
66
+ afterEach(() => jest.restoreAllMocks());
67
+
68
+ it("wires mapStateToProps/mapDispatchToProps and renders the fetched list", async () => {
69
+ jest.spyOn(globalThis, "fetch").mockImplementation(
70
+ async () =>
71
+ new Response(
72
+ JSON.stringify({
73
+ metadata_services: [
74
+ { id: 2, protocol: "test protocol", name: "Test Metadata" },
75
+ ],
76
+ protocols: [{ name: "test protocol", label: "TP", settings: [] }],
77
+ }),
78
+ { status: 200, headers: { "Content-Type": "application/json" } }
79
+ )
80
+ );
81
+
82
+ renderWithProviders(<MetadataServicesConnected csrfToken="token" />, {
83
+ reduxProviderProps: { store: buildStore() },
84
+ appConfigSettings: { roles: [{ role: "system" }] },
85
+ });
86
+
87
+ // The edit link only appears once the on-mount fetch resolves and
88
+ // mapStateToProps feeds the fetched service back in as props.
89
+ const editLink = await screen.findByRole("link", { name: /edit/i });
90
+ expect(editLink).toHaveAttribute(
91
+ "href",
92
+ "/admin/web/config/metadata/edit/2"
93
+ );
94
+ });
95
+ });
@@ -0,0 +1,167 @@
1
+ import * as React from "react";
2
+ import { render } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import NeighborhoodAnalyticsForm from "../../../src/components/NeighborhoodAnalyticsForm";
6
+ import { SettingData } from "../../../src/interfaces";
7
+
8
+ const patronAuthSetting: SettingData = {
9
+ key: "neighborhood_mode",
10
+ label: "Patron Auth",
11
+ options: [
12
+ { key: "disabled", label: "Off" },
13
+ { key: "choice1", label: "Choice 1" },
14
+ { key: "choice2", label: "Choice 2" },
15
+ ],
16
+ };
17
+
18
+ const analyticsSetting: SettingData = {
19
+ key: "location_source",
20
+ label: "Analytics",
21
+ options: [
22
+ { key: "disabled", label: "Off" },
23
+ { key: "choice1", label: "Choice 1" },
24
+ { key: "choice2", label: "Choice 2" },
25
+ ],
26
+ };
27
+
28
+ // The Panel renders its body collapsed and `aria-hidden` by default, so the
29
+ // form is present in the DOM but hidden from the accessibility tree; query it
30
+ // through the container.
31
+ const getSelect = (container: HTMLElement) =>
32
+ container.querySelector("select") as HTMLSelectElement;
33
+ const panelTitle = (container: HTMLElement) =>
34
+ container.querySelector(".panel-title") as HTMLElement;
35
+ const warning = (container: HTMLElement) =>
36
+ container.querySelector(".bg-warning") as HTMLElement | null;
37
+
38
+ describe("NeighborhoodAnalyticsForm", () => {
39
+ it("renders a panel with the feature name", () => {
40
+ const { container } = render(
41
+ <NeighborhoodAnalyticsForm setting={patronAuthSetting} />
42
+ );
43
+ expect(container.querySelector(".panel")).toBeInTheDocument();
44
+ expect(panelTitle(container)).toHaveTextContent(
45
+ "Patron Neighborhood Analytics: Disabled"
46
+ );
47
+ });
48
+
49
+ it("renders a select whose options reflect the setting", () => {
50
+ const { container } = render(
51
+ <NeighborhoodAnalyticsForm setting={patronAuthSetting} />
52
+ );
53
+ const options = Array.from(getSelect(container).querySelectorAll("option"));
54
+ expect(options).toHaveLength(patronAuthSetting.options.length);
55
+ options.forEach((option, idx) => {
56
+ expect(option).toHaveValue(patronAuthSetting.options[idx].key);
57
+ expect(option).toHaveTextContent(patronAuthSetting.options[idx].label);
58
+ });
59
+ });
60
+
61
+ it("renders a warning message pointing at the paired service once enabled", async () => {
62
+ const user = userEvent.setup();
63
+ const { container, rerender } = render(
64
+ <NeighborhoodAnalyticsForm setting={patronAuthSetting} />
65
+ );
66
+
67
+ // No warning while the feature is off.
68
+ expect(warning(container)).not.toBeInTheDocument();
69
+ await user.selectOptions(getSelect(container), "disabled");
70
+ expect(warning(container)).not.toBeInTheDocument();
71
+
72
+ // Enabling it surfaces a warning that links to the analytics config.
73
+ await user.selectOptions(getSelect(container), "choice1");
74
+ expect(warning(container)).toBeInTheDocument();
75
+ expect(warning(container)).toHaveTextContent(
76
+ "This feature will work only if it is also enabled in your local analytics service configuration settings."
77
+ );
78
+ expect(warning(container).querySelector("a")).toHaveAttribute(
79
+ "href",
80
+ "/admin/web/config/analytics"
81
+ );
82
+
83
+ // Switching the setting flips the paired service to patron auth.
84
+ rerender(<NeighborhoodAnalyticsForm setting={analyticsSetting} />);
85
+ expect(warning(container)).toHaveTextContent(
86
+ "This feature will work only if it is also enabled in your patron authentication service configuration settings."
87
+ );
88
+ expect(warning(container).querySelector("a")).toHaveAttribute(
89
+ "href",
90
+ "/admin/web/config/patronAuth"
91
+ );
92
+ });
93
+
94
+ it("updates the header when an option is chosen", async () => {
95
+ const user = userEvent.setup();
96
+ const { container } = render(
97
+ <NeighborhoodAnalyticsForm setting={patronAuthSetting} />
98
+ );
99
+ expect(panelTitle(container)).toHaveTextContent(
100
+ "Patron Neighborhood Analytics: Disabled"
101
+ );
102
+
103
+ await user.selectOptions(getSelect(container), "choice1");
104
+ expect(panelTitle(container)).toHaveTextContent(
105
+ "Patron Neighborhood Analytics: Enabled"
106
+ );
107
+ });
108
+
109
+ it("reflects the currentValue prop as the initial selection", () => {
110
+ const { container } = render(
111
+ <NeighborhoodAnalyticsForm
112
+ setting={patronAuthSetting}
113
+ currentValue="choice1"
114
+ />
115
+ );
116
+ // A non-"disabled" currentValue means the feature starts enabled.
117
+ expect(panelTitle(container)).toHaveTextContent(
118
+ "Patron Neighborhood Analytics: Enabled"
119
+ );
120
+ expect(getSelect(container)).toHaveValue("choice1");
121
+ });
122
+
123
+ it("tells the user whether the feature is currently enabled", async () => {
124
+ const user = userEvent.setup();
125
+ const { container } = render(
126
+ <NeighborhoodAnalyticsForm setting={patronAuthSetting} />
127
+ );
128
+
129
+ expect(panelTitle(container)).toHaveTextContent("Disabled");
130
+ await user.selectOptions(getSelect(container), "choice1");
131
+ expect(panelTitle(container)).toHaveTextContent("Enabled");
132
+ await user.selectOptions(getSelect(container), "choice2");
133
+ expect(panelTitle(container)).toHaveTextContent("Enabled");
134
+ await user.selectOptions(getSelect(container), "disabled");
135
+ expect(panelTitle(container)).toHaveTextContent("Disabled");
136
+ });
137
+
138
+ it("shows the warning only for enabled selections", async () => {
139
+ const user = userEvent.setup();
140
+ const { container } = render(
141
+ <NeighborhoodAnalyticsForm setting={patronAuthSetting} />
142
+ );
143
+
144
+ expect(warning(container)).not.toBeInTheDocument();
145
+ await user.selectOptions(getSelect(container), "choice1");
146
+ expect(warning(container)).toBeInTheDocument();
147
+ await user.selectOptions(getSelect(container), "choice2");
148
+ expect(warning(container)).toBeInTheDocument();
149
+ await user.selectOptions(getSelect(container), "disabled");
150
+ expect(warning(container)).not.toBeInTheDocument();
151
+ });
152
+
153
+ it("exposes the selected value through its getValue ref API", async () => {
154
+ // ServiceEditForm reads the chosen value via this imperative ref API.
155
+ const user = userEvent.setup();
156
+ const ref = React.createRef<NeighborhoodAnalyticsForm>();
157
+ const { container } = render(
158
+ <NeighborhoodAnalyticsForm ref={ref} setting={patronAuthSetting} />
159
+ );
160
+
161
+ expect(ref.current?.getValue()).toBe("");
162
+ await user.selectOptions(getSelect(container), "choice1");
163
+ expect(ref.current?.getValue()).toBe("choice1");
164
+ await user.selectOptions(getSelect(container), "choice2");
165
+ expect(ref.current?.getValue()).toBe("choice2");
166
+ });
167
+ });