@thepalaceproject/circulation-admin 1.46.0-post.2 → 1.46.0-post.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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-post.2"
142
+ "version": "1.46.0-post.3"
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,
@@ -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",
@@ -484,6 +484,91 @@ describe("ServiceEditForm", () => {
484
484
  expect(container.querySelector(".with-edit-button a")).toBeNull();
485
485
  });
486
486
 
487
+ it("shows a loading indicator in the Libraries panel until allLibraries arrives", () => {
488
+ // Undefined allLibraries means the library list is still loading.
489
+ const dataStillLoading = Object.assign({}, servicesData, {
490
+ allLibraries: undefined,
491
+ });
492
+ const { container, rerender } = renderForm({
493
+ data: dataStillLoading,
494
+ item: serviceData,
495
+ });
496
+ expect(container.querySelector(".update-libraries")).toBeNull();
497
+ expect(container.querySelector('[role="status"]')).toHaveTextContent(
498
+ "Loading libraries..."
499
+ );
500
+
501
+ rerenderForm(rerender, { item: serviceData });
502
+ // The status line stays mounted and announces completion; the
503
+ // completion text is visually hidden.
504
+ const status = container.querySelector('[role="status"]');
505
+ expect(status).toHaveTextContent("Libraries loaded.");
506
+ expect(status).toHaveClass("visuallyHidden");
507
+ const editable = container.querySelectorAll(".with-edit-button");
508
+ expect(editable).toHaveLength(1);
509
+ expect(editable[0]).toHaveTextContent("New York Public Library - nypl");
510
+ });
511
+
512
+ it("explains a failed library list load in the Libraries panel", () => {
513
+ // On failure allLibraries settles to [] and allLibrariesError is set.
514
+ const dataWithError = Object.assign({}, servicesData, {
515
+ allLibraries: [],
516
+ allLibrariesError: {
517
+ status: 500,
518
+ response: "nope",
519
+ url: "/admin/libraries",
520
+ },
521
+ });
522
+ const { container } = renderForm({
523
+ data: dataWithError,
524
+ item: serviceData,
525
+ });
526
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
527
+ "The library list failed to load"
528
+ );
529
+ // The role="alert" danger Alert announces the failure itself, so the
530
+ // status region empties rather than duplicating the announcement.
531
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
532
+ // The associated library still renders, by short name.
533
+ const editable = container.querySelectorAll(".with-edit-button");
534
+ expect(editable).toHaveLength(1);
535
+ expect(editable[0]).toHaveTextContent("nypl");
536
+ // A removal could not be undone in-session, so it is disabled too.
537
+ expect(container.querySelector("button.remove-btn")).toBeDisabled();
538
+ });
539
+
540
+ it("warns when the library list could not be refreshed", () => {
541
+ const staleData = Object.assign({}, servicesData, {
542
+ allLibrariesRefreshError: {
543
+ status: 500,
544
+ response: "nope",
545
+ url: "/admin/libraries",
546
+ },
547
+ });
548
+ const { container } = renderForm({ data: staleData, item: serviceData });
549
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
550
+ "The library list could not be refreshed"
551
+ );
552
+ // The role="alert" warning announces the refresh failure itself, so
553
+ // the status region empties rather than duplicating the announcement.
554
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
555
+ // The stale list still renders in full, links included, and the
556
+ // panel stays editable; only the blocking error disables removal.
557
+ const editable = container.querySelectorAll(".with-edit-button");
558
+ expect(editable).toHaveLength(1);
559
+ expect(editable[0]).toHaveTextContent("New York Public Library - nypl");
560
+ expect(container.querySelector(".alert-danger")).toBeNull();
561
+ expect(container.querySelector("button.remove-btn")).toBeEnabled();
562
+ });
563
+
564
+ it("says when no libraries are configured", () => {
565
+ const emptyData = Object.assign({}, servicesData, { allLibraries: [] });
566
+ const { container } = renderForm({ data: emptyData });
567
+ expect(container.querySelector(".update-libraries")).toHaveTextContent(
568
+ "No libraries are configured."
569
+ );
570
+ });
571
+
487
572
  it("renders removable and editable libraries", () => {
488
573
  const { container, unmount } = renderForm();
489
574
  expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
@@ -6,13 +6,16 @@ import SetupPage from "../../../src/components/SetupPage";
6
6
 
7
7
  describe("SetupPage", () => {
8
8
  beforeEach(() => {
9
- // SetupPage renders the connected IndividualAdmins list, which fetches the
10
- // admin list on mount. Stub fetch so mounting does not hit the network.
11
- jest.spyOn(globalThis, "fetch").mockResolvedValue(
12
- new Response(JSON.stringify({ individualAdmins: [] }), {
13
- status: 200,
14
- headers: { "Content-Type": "application/json" },
15
- })
9
+ // SetupPage renders the connected IndividualAdmins list, which fetches on
10
+ // mount. Stub fetch so mounting does not hit the network; a Response body
11
+ // can only be read once, so build one per call.
12
+ jest.spyOn(globalThis, "fetch").mockImplementation(() =>
13
+ Promise.resolve(
14
+ new Response(JSON.stringify({ individualAdmins: [] }), {
15
+ status: 200,
16
+ headers: { "Content-Type": "application/json" },
17
+ })
18
+ )
16
19
  );
17
20
  });
18
21
 
@@ -37,5 +40,19 @@ describe("SetupPage", () => {
37
40
  name: "Set up your system admin account",
38
41
  })
39
42
  ).toBeInTheDocument();
43
+
44
+ // settingUp skips the libraries request, which cannot succeed before an
45
+ // admin exists. The positive check keeps the negative one honest.
46
+ const urls = (globalThis.fetch as jest.Mock).mock.calls.map((call) =>
47
+ String(call[0])
48
+ );
49
+ expect(urls).toEqual(
50
+ expect.arrayContaining([
51
+ expect.stringContaining("/admin/individual_admins"),
52
+ ])
53
+ );
54
+ expect(urls).not.toEqual(
55
+ expect.arrayContaining([expect.stringContaining("/admin/libraries")])
56
+ );
40
57
  });
41
58
  });