@thepalaceproject/circulation-admin 1.46.0 → 1.47.0-post.15

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.
@@ -142,4 +142,4 @@ object-assign
142
142
 
143
143
  //! momentjs.com
144
144
 
145
- //! version : 2.30.1
145
+ //! version : 2.31.0
package/package.json CHANGED
@@ -139,5 +139,5 @@
139
139
  "*.{js,jsx,ts,tsx,css,md}": "prettier --write",
140
140
  "*.{js,css,md}": "prettier --write"
141
141
  },
142
- "version": "1.46.0"
142
+ "version": "1.47.0-post.15"
143
143
  }
@@ -52,6 +52,42 @@ describe("DiscoveryServices - registered library disclosure", () => {
52
52
 
53
53
  // ── Toggle visibility ─────────────────────────────────────────────────────
54
54
 
55
+ it("names registration data in the failed-library-list alert", () => {
56
+ // This tab's entries fall back to registration records, so the generic
57
+ // "shown by short name only" message would be wrong here.
58
+ const { container } = renderServices({
59
+ discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any],
60
+ allLibraries: [],
61
+ allLibrariesError: {
62
+ status: 500,
63
+ response: "nope",
64
+ url: "/admin/libraries",
65
+ },
66
+ libraryRegistrations: [
67
+ { id: 1, libraries: [{ short_name: "alpha", status: "success" }] },
68
+ ] as any,
69
+ });
70
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
71
+ "Registered libraries are shown using registration data"
72
+ );
73
+ });
74
+
75
+ it("shows no toggle while allLibraries has not settled", () => {
76
+ // Labels must render once, in final linked form, not flash bare short
77
+ // names that get rewritten when the sitewide list arrives.
78
+ const { container } = renderServices({
79
+ discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any],
80
+ allLibraries: undefined,
81
+ libraryRegistrations: [
82
+ {
83
+ id: 1,
84
+ libraries: [{ short_name: "alpha", status: "success" }],
85
+ },
86
+ ] as any,
87
+ });
88
+ expect(container.querySelector(".association-toggle")).toBeNull();
89
+ });
90
+
55
91
  it("shows no toggle when libraryRegistrations data has not yet loaded", () => {
56
92
  const { container } = renderServices({
57
93
  discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any],
@@ -23,6 +23,12 @@ describe("EditableConfigList - library association disclosure", () => {
23
23
  interface ServicesData {
24
24
  services: ServiceItem[];
25
25
  allLibraries?: Array<{ short_name: string; name?: string; uuid?: string }>;
26
+ allLibrariesError?: { status: number; response: string; url: string };
27
+ allLibrariesRefreshError?: {
28
+ status: number;
29
+ response: string;
30
+ url: string;
31
+ };
26
32
  }
27
33
 
28
34
  class TestEditForm extends React.Component<
@@ -36,6 +42,7 @@ describe("EditableConfigList - library association disclosure", () => {
36
42
  class TestServiceList extends EditableConfigList<ServicesData, ServiceItem> {
37
43
  EditForm = TestEditForm;
38
44
  listDataKey = "services";
45
+ usesLibraryList = true;
39
46
  itemTypeName = "service";
40
47
  urlBase = "/admin/services/";
41
48
  identifierKey = "id";
@@ -377,13 +384,19 @@ describe("EditableConfigList - library association disclosure", () => {
377
384
  expect(items[1].textContent).toBe("Beta Library - beta");
378
385
  });
379
386
 
380
- it("falls back to short_name when allLibraries is absent from the data", () => {
387
+ it("shows an alert and bare short names when the library list failed to load", () => {
381
388
  const { container } = renderWithContext(
382
389
  <TestServiceList
383
390
  data={{
384
391
  services: [
385
392
  { id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] },
386
393
  ],
394
+ allLibraries: [],
395
+ allLibrariesError: {
396
+ status: 500,
397
+ response: "nope",
398
+ url: "/admin/libraries",
399
+ },
387
400
  }}
388
401
  fetchData={jest.fn()}
389
402
  editItem={jest.fn().mockResolvedValue(undefined)}
@@ -393,11 +406,72 @@ describe("EditableConfigList - library association disclosure", () => {
393
406
  />,
394
407
  config
395
408
  );
409
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
410
+ "The library list failed to load"
411
+ );
412
+ // The associations still render, by short name, below the alert.
396
413
  fireEvent.click(container.querySelector(".association-toggle"));
397
414
  expect(container.querySelector(".associated-items li").textContent).toBe(
398
415
  "nypl"
399
416
  );
400
417
  });
418
+
419
+ it("shows a warning when the library list is stale", () => {
420
+ const { container } = renderWithContext(
421
+ <TestServiceList
422
+ data={{
423
+ services: [
424
+ { id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] },
425
+ ],
426
+ allLibraries: [{ short_name: "nypl", name: "NYPL" }],
427
+ allLibrariesRefreshError: {
428
+ status: 500,
429
+ response: "nope",
430
+ url: "/admin/libraries",
431
+ },
432
+ }}
433
+ fetchData={jest.fn()}
434
+ editItem={jest.fn().mockResolvedValue(undefined)}
435
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
436
+ csrfToken="token"
437
+ isFetching={false}
438
+ />,
439
+ config
440
+ );
441
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
442
+ "The library list could not be refreshed"
443
+ );
444
+ // The associations still render, from the retained list.
445
+ fireEvent.click(container.querySelector(".association-toggle"));
446
+ expect(container.querySelector(".associated-items li").textContent).toBe(
447
+ "NYPL - nypl"
448
+ );
449
+ });
450
+
451
+ it("holds the association panel until allLibraries settles", () => {
452
+ const { container } = renderWithContext(
453
+ <TestServiceList
454
+ data={{
455
+ services: [
456
+ { id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] },
457
+ ],
458
+ }}
459
+ fetchData={jest.fn()}
460
+ editItem={jest.fn().mockResolvedValue(undefined)}
461
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
462
+ csrfToken="token"
463
+ isFetching={false}
464
+ />,
465
+ config
466
+ );
467
+ // With the sitewide list unsettled, no toggle renders at all, so the
468
+ // panel cannot flash bare short names that get rewritten when the
469
+ // list arrives. The status line explains and announces the gap.
470
+ expect(container.querySelector(".association-toggle")).toBeNull();
471
+ expect(container.querySelector('[role="status"]')).toHaveTextContent(
472
+ "Loading libraries..."
473
+ );
474
+ });
401
475
  });
402
476
  });
403
477
 
@@ -202,6 +202,104 @@ describe("IndividualAdminEditForm - rendered inputs and role changes", () => {
202
202
  const roleCheckbox = (role: string) =>
203
203
  screen.getByRole("checkbox", { name: roleNames[role] });
204
204
 
205
+ const failureData = {
206
+ individualAdmins: [adminData],
207
+ allLibraries: [],
208
+ allLibrariesError: {
209
+ status: 500,
210
+ response: "nope",
211
+ url: "/admin/libraries",
212
+ },
213
+ };
214
+
215
+ it("waits for the library list before showing per-library roles", () => {
216
+ const { container } = renderForm({
217
+ item: adminData,
218
+ data: { individualAdmins: [adminData] },
219
+ });
220
+ expect(container.querySelector("table.library-admin-roles")).toBeNull();
221
+ expect(container.querySelector('[role="status"]')).toHaveTextContent(
222
+ "Loading libraries..."
223
+ );
224
+ // Sitewide toggles rewrite an existing admin's hidden per-library roles
225
+ // wholesale, so role edits are disabled until the list is available.
226
+ expect(roleCheckbox("system")).toBeDisabled();
227
+ });
228
+
229
+ it("explains a failed library list load when editing an existing admin", () => {
230
+ // On failure allLibraries settles to [] and allLibrariesError is set.
231
+ const { container } = renderForm({ item: adminData, data: failureData });
232
+ expect(
233
+ screen.getByText(
234
+ /library roles cannot be shown, and no roles can be changed/
235
+ )
236
+ ).toBeInTheDocument();
237
+ // The role="alert" danger Alert announces the failure itself, so the
238
+ // status region empties rather than duplicating the announcement.
239
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
240
+ expect(roleCheckbox("system")).toBeDisabled();
241
+ // All role edits are disabled, so the empty table shell is dropped.
242
+ expect(container.querySelector("table.library-admin-roles")).toBeNull();
243
+ });
244
+
245
+ it("still allows sitewide roles for a new admin when the library list failed", () => {
246
+ // A new admin has no hidden per-library roles to clobber.
247
+ const { container } = renderForm({ data: failureData });
248
+ expect(
249
+ screen.getByText(/Sitewide roles can still be assigned/)
250
+ ).toBeInTheDocument();
251
+ expect(roleCheckbox("system")).toBeEnabled();
252
+ expect(roleCheckbox("manager-all")).toBeEnabled();
253
+ // No misleading "no libraries" row under a failure.
254
+ expect(container.querySelector("tbody")).toBeEmptyDOMElement();
255
+ });
256
+
257
+ const staleData = {
258
+ individualAdmins: [adminData],
259
+ allLibraries,
260
+ allLibrariesRefreshError: {
261
+ status: 500,
262
+ response: "nope",
263
+ url: "/admin/libraries",
264
+ },
265
+ };
266
+
267
+ it("locks only per-library roles for an existing admin while the list is stale", () => {
268
+ const { container } = renderForm({ item: adminData, data: staleData });
269
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
270
+ "Per-library roles cannot be changed until the list can be refreshed."
271
+ );
272
+ // The role="alert" warning announces the refresh failure itself, so
273
+ // the status region empties rather than duplicating the announcement.
274
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
275
+ // Only the per-library un-check branches expand sitewide roles from
276
+ // the (stale) list; sitewide toggles are list-independent and stay
277
+ // assignable, so submitting cannot be forced into a roleless admin.
278
+ expect(roleCheckbox("system")).toBeEnabled();
279
+ expect(roleCheckbox("manager-all")).toBeEnabled();
280
+ expect(roleCheckbox("manager-nypl")).toBeDisabled();
281
+ expect(roleCheckbox("librarian-nypl")).toBeDisabled();
282
+ });
283
+
284
+ it("locks only per-library roles for a new admin while the list is stale", () => {
285
+ const { container } = renderForm({ data: staleData });
286
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
287
+ "Per-library roles cannot be changed"
288
+ );
289
+ expect(roleCheckbox("system")).toBeEnabled();
290
+ expect(roleCheckbox("manager-all")).toBeEnabled();
291
+ expect(roleCheckbox("manager-nypl")).toBeDisabled();
292
+ });
293
+
294
+ it("says when no libraries are configured", () => {
295
+ renderForm({
296
+ data: { individualAdmins: [adminData], allLibraries: [] },
297
+ });
298
+ expect(
299
+ screen.getByText("No libraries are configured.")
300
+ ).toBeInTheDocument();
301
+ });
302
+
205
303
  const expectRoles = (expected: string[]) => {
206
304
  for (const role of allRoles) {
207
305
  if (expected.includes(role)) {
@@ -46,6 +46,84 @@ describe("IndividualAdmins - role association disclosure", () => {
46
46
 
47
47
  // ── Toggle visibility ─────────────────────────────────────────────────────
48
48
 
49
+ it("shows no toggle while allLibraries has not settled", () => {
50
+ // Labels must render once, in final linked form, not flash bare short
51
+ // names that get rewritten when the sitewide list arrives.
52
+ const { container } = renderWithContext(
53
+ <IndividualAdmins
54
+ data={{
55
+ individualAdmins: [
56
+ {
57
+ email: "admin@example.org",
58
+ roles: [{ role: "manager", library: "alpha" }],
59
+ },
60
+ ],
61
+ }}
62
+ fetchData={jest.fn()}
63
+ editItem={jest.fn().mockResolvedValue(undefined)}
64
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
65
+ csrfToken="token"
66
+ isFetching={false}
67
+ />,
68
+ sysAdminConfig
69
+ );
70
+ expect(container.querySelector(".association-toggle")).toBeNull();
71
+ });
72
+
73
+ it("shows the sysadmin entry before allLibraries settles", () => {
74
+ // The synthetic sysadmin entry never consults the library list and can
75
+ // never be rewritten by it, so it is not held back.
76
+ const { container } = renderWithContext(
77
+ <IndividualAdmins
78
+ data={{
79
+ individualAdmins: [
80
+ { email: "root@example.org", roles: [{ role: "system" }] },
81
+ ],
82
+ }}
83
+ fetchData={jest.fn()}
84
+ editItem={jest.fn().mockResolvedValue(undefined)}
85
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
86
+ csrfToken="token"
87
+ isFetching={false}
88
+ />,
89
+ sysAdminConfig
90
+ );
91
+ fireEvent.click(container.querySelector(".association-toggle"));
92
+ expect(container.querySelector(".associated-items li").textContent).toBe(
93
+ "sysadmin"
94
+ );
95
+ });
96
+
97
+ it("names roles, not libraries, when the library list failed to load", () => {
98
+ const { container } = renderWithContext(
99
+ <IndividualAdmins
100
+ data={{
101
+ individualAdmins: [
102
+ {
103
+ email: "admin@example.org",
104
+ roles: [{ role: "manager", library: "alpha" }],
105
+ },
106
+ ],
107
+ allLibraries: [],
108
+ allLibrariesError: {
109
+ status: 500,
110
+ response: "nope",
111
+ url: "/admin/libraries",
112
+ },
113
+ }}
114
+ fetchData={jest.fn()}
115
+ editItem={jest.fn().mockResolvedValue(undefined)}
116
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
117
+ csrfToken="token"
118
+ isFetching={false}
119
+ />,
120
+ sysAdminConfig
121
+ );
122
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
123
+ "Roles are shown by library short name only."
124
+ );
125
+ });
126
+
49
127
  it("shows no toggle for an admin with no roles field", () => {
50
128
  const { container } = renderAdmins([{ email: "noroles@example.com" }]);
51
129
  expect(container.querySelector(".association-toggle")).toBeNull();
@@ -498,10 +576,13 @@ describe("IndividualAdmins - connect wiring", () => {
498
576
  afterEach(() => jest.restoreAllMocks());
499
577
 
500
578
  it("renders the connected default export, fetching on mount", async () => {
501
- jest.spyOn(globalThis, "fetch").mockResolvedValue(
502
- new Response(JSON.stringify({ individualAdmins: [] }), {
503
- headers: { "Content-Type": "application/json" },
504
- })
579
+ // A Response body can only be read once, so build one per fetch call.
580
+ const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation(() =>
581
+ Promise.resolve(
582
+ new Response(JSON.stringify({ individualAdmins: [] }), {
583
+ headers: { "Content-Type": "application/json" },
584
+ })
585
+ )
505
586
  );
506
587
 
507
588
  renderWithProviders(
@@ -517,5 +598,12 @@ describe("IndividualAdmins - connect wiring", () => {
517
598
  expect(
518
599
  await screen.findByText("Create new individual admin")
519
600
  ).toBeInTheDocument();
601
+
602
+ // Outside setup mode, fetchData also requests the libraries list (the
603
+ // settingUp half of that guard is pinned in SetupPage.test.tsx).
604
+ const urls = fetchSpy.mock.calls.map((call) => String(call[0]));
605
+ expect(urls).toEqual(
606
+ expect.arrayContaining([expect.stringContaining("/admin/libraries")])
607
+ );
520
608
  });
521
609
  });
@@ -87,6 +87,24 @@ describe("LibraryRegistration", () => {
87
87
  expect(libraries(container)).toHaveLength(0);
88
88
  });
89
89
 
90
+ it("explains instead of vanishing when the library list failed to load", () => {
91
+ const { container } = renderReg({
92
+ item: serviceData,
93
+ data: makeData({
94
+ allLibraries: [],
95
+ allLibrariesError: {
96
+ status: 500,
97
+ response: "nope",
98
+ url: "/admin/libraries",
99
+ },
100
+ }),
101
+ });
102
+ expect(libraries(container)).toHaveLength(0);
103
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
104
+ "Libraries cannot be registered: the library list is unavailable."
105
+ );
106
+ });
107
+
90
108
  it("doesn't render libraries in edit form if protocol doesn't support registration", () => {
91
109
  const { container } = renderReg({
92
110
  item: serviceData,
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { render, screen, waitFor } from "@testing-library/react";
2
+ import { render, screen, waitFor, within } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import * as fetchMock from "fetch-mock-jest";
5
5
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -217,8 +217,11 @@ describe("PatronAuthServiceEditForm – serialization", () => {
217
217
 
218
218
  await user.click(saveButton);
219
219
 
220
- // The library should now be collapsed (indicating editLibrary was called)
220
+ // Saving collapses the library. Reopening it shows the rule was stored in
221
+ // library state, not just read from the editor.
221
222
  expect(screen.queryByRole("button", { name: /Add Rule/i })).toBeNull();
223
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
224
+ expect(screen.getByLabelText(/Rule Name/i)).toHaveValue("Test Rule");
222
225
  });
223
226
 
224
227
  it("includes patron_blocking_rules in payload when adding a new library", async () => {
@@ -253,8 +256,99 @@ describe("PatronAuthServiceEditForm – serialization", () => {
253
256
 
254
257
  await user.click(addButton);
255
258
 
256
- // After adding, the library should appear in the list, editor no longer in "new library" form
259
+ // The new-library editor is gone. Opening the added library shows the rule
260
+ // was stored in library state.
257
261
  expect(screen.queryByLabelText(/Rule Name/i)).toBeNull();
262
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
263
+ expect(screen.getByLabelText(/Rule Name/i)).toHaveValue("New Rule");
264
+ });
265
+ });
266
+
267
+ describe("PatronAuthServiceEditForm – per-library settings", () => {
268
+ const LIB_SETTING_KEY = "lib_setting";
269
+ const protocolWithLibrarySettings = {
270
+ ...SIP2_PROTOCOL_DATA,
271
+ library_settings: [{ key: LIB_SETTING_KEY, label: "Library setting" }],
272
+ };
273
+ const dataWithLibrarySettings: PatronAuthServicesData = {
274
+ ...servicesData,
275
+ protocols: [protocolWithLibrarySettings, OTHER_PROTOCOL_DATA],
276
+ };
277
+ const settingInput = (root: HTMLElement) =>
278
+ within(root).getByRole("textbox", {
279
+ name: /Library setting/i,
280
+ }) as HTMLInputElement;
281
+
282
+ it("keeps a library setting entered when adding a new library", async () => {
283
+ const user = userEvent.setup();
284
+ const item = {
285
+ id: 3,
286
+ protocol: SIP2_PROTOCOL,
287
+ settings: {},
288
+ libraries: [],
289
+ };
290
+ const { container } = renderForm(
291
+ <PatronAuthServiceEditForm
292
+ {...baseProps}
293
+ data={dataWithLibrarySettings}
294
+ item={item}
295
+ />
296
+ );
297
+
298
+ await expandLibrariesPanel(user);
299
+ await user.selectOptions(
300
+ screen.getByRole("combobox", { name: /Add Library/i }),
301
+ LIBRARY_SHORT_NAME
302
+ );
303
+ await user.type(settingInput(container), "added value");
304
+ await user.click(screen.getByRole("button", { name: /Add Library/i }));
305
+
306
+ // Reopen the new library's settings; the value entered on add is there.
307
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
308
+ const editSection = container.querySelector(
309
+ ".edit-library-settings"
310
+ ) as HTMLElement;
311
+ expect(settingInput(editSection).value).toBe("added value");
312
+ });
313
+
314
+ it("keeps a library setting changed when editing an existing library", async () => {
315
+ const user = userEvent.setup();
316
+ const item = {
317
+ ...buildSIP2Item(),
318
+ libraries: [
319
+ { short_name: LIBRARY_SHORT_NAME, [LIB_SETTING_KEY]: "old value" },
320
+ ],
321
+ };
322
+ const { container } = renderForm(
323
+ <PatronAuthServiceEditForm
324
+ {...baseProps}
325
+ data={dataWithLibrarySettings}
326
+ item={item}
327
+ />
328
+ );
329
+
330
+ await expandLibrariesPanel(user);
331
+ const clickEdit = () =>
332
+ user.click(screen.getByRole("button", { name: /Edit/i }));
333
+ await clickEdit();
334
+ let editSection = container.querySelector(
335
+ ".edit-library-settings"
336
+ ) as HTMLElement;
337
+ expect(settingInput(editSection).value).toBe("old value");
338
+
339
+ await user.clear(settingInput(editSection));
340
+ await user.type(settingInput(editSection), "new value");
341
+ await user.click(
342
+ editSection.querySelector("button.edit-library") as HTMLElement
343
+ );
344
+
345
+ // Saving collapses the library; reopening shows the saved value.
346
+ expect(container.querySelector(".edit-library-settings")).toBeNull();
347
+ await clickEdit();
348
+ editSection = container.querySelector(
349
+ ".edit-library-settings"
350
+ ) as HTMLElement;
351
+ expect(settingInput(editSection).value).toBe("new value");
258
352
  });
259
353
  });
260
354
 
@@ -77,6 +77,21 @@ describe("PatronAuthServices", () => {
77
77
  ).not.toBeInTheDocument();
78
78
  });
79
79
 
80
+ it("fetches the libraries list along with the services on mount", async () => {
81
+ const fetchSpy = stubFetch(listData);
82
+ renderConnected(<PatronAuthServices csrfToken="token" />);
83
+
84
+ await waitFor(() => {
85
+ const urls = fetchSpy.mock.calls.map((call) => String(call[0]));
86
+ expect(urls).toEqual(
87
+ expect.arrayContaining([
88
+ expect.stringContaining("/admin/patron_auth_services"),
89
+ expect.stringContaining("/admin/libraries"),
90
+ ])
91
+ );
92
+ });
93
+ });
94
+
80
95
  it("shows the neighborhood analytics panel when creating a service whose protocol has a neighborhood_mode setting", async () => {
81
96
  const neighborhoodSetting = {
82
97
  key: "neighborhood_mode",