@thepalaceproject/circulation-admin 1.39.0 → 1.40.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.
@@ -0,0 +1,399 @@
1
+ import * as React from "react";
2
+ import { fireEvent } from "@testing-library/react";
3
+ import {
4
+ EditableConfigList,
5
+ EditFormProps,
6
+ } from "../../../src/components/EditableConfigList";
7
+ import renderWithContext from "../testUtils/renderWithContext";
8
+ import { ConfigurationSettings } from "../../../src/interfaces";
9
+ 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.
16
+
17
+ describe("EditableConfigList - library association disclosure", () => {
18
+ // ── Test doubles ──────────────────────────────────────────────────────────
19
+
20
+ interface ServiceItem {
21
+ id: number;
22
+ name: string;
23
+ libraries?: Array<{ short_name: string }>;
24
+ }
25
+
26
+ interface ServicesData {
27
+ services: ServiceItem[];
28
+ allLibraries?: Array<{ short_name: string; name?: string; uuid?: string }>;
29
+ }
30
+
31
+ class TestEditForm extends React.Component<
32
+ EditFormProps<ServicesData, ServiceItem>
33
+ > {
34
+ render() {
35
+ return <div />;
36
+ }
37
+ }
38
+
39
+ class TestServiceList extends EditableConfigList<ServicesData, ServiceItem> {
40
+ EditForm = TestEditForm;
41
+ listDataKey = "services";
42
+ itemTypeName = "service";
43
+ urlBase = "/admin/services/";
44
+ identifierKey = "id";
45
+ labelKey = "name";
46
+ canCreate() {
47
+ return false;
48
+ }
49
+ canDelete() {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ // ── Shared fixtures ───────────────────────────────────────────────────────
55
+
56
+ const allLibraries = [
57
+ { short_name: "gamma", name: "Gamma Library", uuid: "uuid-gamma" },
58
+ { short_name: "alpha", name: "Alpha Library", uuid: "uuid-alpha" },
59
+ { short_name: "beta", name: "Beta Library", uuid: "uuid-beta" },
60
+ { short_name: "delta", name: "Delta Library" }, // no uuid
61
+ ];
62
+
63
+ const config: Partial<ConfigurationSettings> = {
64
+ csrfToken: "",
65
+ featureFlags: defaultFeatureFlags,
66
+ roles: [{ role: "system" }],
67
+ };
68
+
69
+ const renderList = (items: ServiceItem[]) =>
70
+ renderWithContext(
71
+ <TestServiceList
72
+ data={{ services: items, allLibraries }}
73
+ fetchData={jest.fn()}
74
+ editItem={jest.fn().mockResolvedValue(undefined)}
75
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
76
+ csrfToken="token"
77
+ isFetching={false}
78
+ />,
79
+ config
80
+ );
81
+
82
+ // ── Toggle visibility ─────────────────────────────────────────────────────
83
+
84
+ it("shows no toggle for an item without a libraries field", () => {
85
+ const { container } = renderList([{ id: 1, name: "Service A" }]);
86
+ expect(container.querySelector(".association-toggle")).toBeNull();
87
+ expect(container.querySelector(".library-count")).toBeNull();
88
+ });
89
+
90
+ it("shows a disabled toggle and 'no libraries' for an item with an empty libraries array", () => {
91
+ const { container } = renderList([
92
+ { id: 1, name: "Service A", libraries: [] },
93
+ ]);
94
+ const toggle = container.querySelector<HTMLButtonElement>(
95
+ ".association-toggle"
96
+ );
97
+ expect(toggle).not.toBeNull();
98
+ expect(toggle.disabled).toBe(true);
99
+ // aria-expanded is omitted on a permanently-disabled toggle (it can never change state).
100
+ expect(toggle.getAttribute("aria-expanded")).toBeNull();
101
+ expect(container.querySelector(".library-count").textContent).toBe(
102
+ " (no libraries)"
103
+ );
104
+ });
105
+
106
+ it("shows an enabled toggle and '1 library' for an item with one library", () => {
107
+ const { container } = renderList([
108
+ { id: 1, name: "Service A", libraries: [{ short_name: "alpha" }] },
109
+ ]);
110
+ const toggle = container.querySelector<HTMLButtonElement>(
111
+ ".association-toggle"
112
+ );
113
+ expect(toggle.disabled).toBe(false);
114
+ expect(container.querySelector(".library-count").textContent).toBe(
115
+ " (1 library)"
116
+ );
117
+ });
118
+
119
+ it("shows 'N libraries' for an item with multiple libraries", () => {
120
+ const { container } = renderList([
121
+ {
122
+ id: 1,
123
+ name: "Service A",
124
+ libraries: [
125
+ { short_name: "alpha" },
126
+ { short_name: "beta" },
127
+ { short_name: "gamma" },
128
+ ],
129
+ },
130
+ ]);
131
+ expect(container.querySelector(".library-count").textContent).toBe(
132
+ " (3 libraries)"
133
+ );
134
+ });
135
+
136
+ // ── Expand / collapse ─────────────────────────────────────────────────────
137
+
138
+ it("expands the library list on toggle click and collapses on a second click", () => {
139
+ const { container } = renderList([
140
+ {
141
+ id: 1,
142
+ name: "Service A",
143
+ libraries: [{ short_name: "alpha" }, { short_name: "beta" }],
144
+ },
145
+ ]);
146
+ const toggle = container.querySelector(".association-toggle");
147
+
148
+ expect(container.querySelector(".associated-items")).toBeNull();
149
+ fireEvent.click(toggle);
150
+ expect(container.querySelector(".associated-items")).not.toBeNull();
151
+ fireEvent.click(toggle);
152
+ expect(container.querySelector(".associated-items")).toBeNull();
153
+ });
154
+
155
+ it("sets aria-expanded correctly as the list is toggled", () => {
156
+ const { container } = renderList([
157
+ { id: 1, name: "Service A", libraries: [{ short_name: "alpha" }] },
158
+ ]);
159
+ const toggle = container.querySelector(".association-toggle");
160
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
161
+ fireEvent.click(toggle);
162
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
163
+ });
164
+
165
+ // ── Expand all / Collapse all buttons ────────────────────────────────────
166
+
167
+ it("shows no expand/collapse controls when no items have libraries", () => {
168
+ const { container } = renderList([
169
+ { id: 1, name: "A", libraries: [] },
170
+ { id: 2, name: "B" },
171
+ ]);
172
+ expect(container.querySelector(".expand-collapse-controls")).toBeNull();
173
+ });
174
+
175
+ it("shows expand/collapse controls when at least one item has libraries", () => {
176
+ const { container } = renderList([
177
+ { id: 1, name: "A", libraries: [{ short_name: "alpha" }] },
178
+ ]);
179
+ const controlSets = container.querySelectorAll(".expand-collapse-controls");
180
+ // One set above the list and one below (visual duplicate).
181
+ expect(controlSets).toHaveLength(2);
182
+ // Top set: functional, in tab order, not hidden from accessibility tree.
183
+ const topSet = controlSets[0];
184
+ expect(topSet.closest("[aria-hidden]")).toBeNull();
185
+ expect(
186
+ topSet.querySelector<HTMLButtonElement>(".expand-all").tabIndex
187
+ ).not.toBe(-1);
188
+ expect(
189
+ topSet.querySelector<HTMLButtonElement>(".expand-all").disabled
190
+ ).toBe(false);
191
+ expect(
192
+ topSet.querySelector<HTMLButtonElement>(".collapse-all").disabled
193
+ ).toBe(true);
194
+ // Bottom set: hidden from accessibility tree and removed from tab order.
195
+ const bottomSet = controlSets[1];
196
+ expect(bottomSet.closest("[aria-hidden='true']")).not.toBeNull();
197
+ expect(
198
+ bottomSet.querySelector<HTMLButtonElement>(".expand-all").tabIndex
199
+ ).toBe(-1);
200
+ expect(
201
+ bottomSet.querySelector<HTMLButtonElement>(".collapse-all").tabIndex
202
+ ).toBe(-1);
203
+ });
204
+
205
+ it("Expand all expands all items that have libraries", () => {
206
+ const { container } = renderList([
207
+ { id: 1, name: "A", libraries: [{ short_name: "alpha" }] },
208
+ { id: 2, name: "B", libraries: [] }, // no libraries → toggle disabled
209
+ { id: 3, name: "C", libraries: [{ short_name: "beta" }] },
210
+ { id: 4, name: "D" }, // no libraries field → no toggle
211
+ ]);
212
+
213
+ fireEvent.click(container.querySelector(".expand-all"));
214
+
215
+ const lists = container.querySelectorAll(".associated-items");
216
+ // Items 1 and 3 have libraries; item 2 is empty so no list shown even when expanded.
217
+ expect(lists).toHaveLength(2);
218
+ expect(
219
+ container.querySelector<HTMLButtonElement>(".expand-all").disabled
220
+ ).toBe(true);
221
+ expect(
222
+ container.querySelector<HTMLButtonElement>(".collapse-all").disabled
223
+ ).toBe(false);
224
+ });
225
+
226
+ it("Collapse all collapses all expanded items", () => {
227
+ const { container } = renderList([
228
+ { id: 1, name: "A", libraries: [{ short_name: "alpha" }] },
229
+ { id: 2, name: "B", libraries: [{ short_name: "beta" }] },
230
+ ]);
231
+
232
+ fireEvent.click(container.querySelector(".expand-all"));
233
+ expect(container.querySelectorAll(".associated-items")).toHaveLength(2);
234
+
235
+ fireEvent.click(container.querySelector(".collapse-all"));
236
+ expect(container.querySelectorAll(".associated-items")).toHaveLength(0);
237
+ expect(
238
+ container.querySelector<HTMLButtonElement>(".collapse-all").disabled
239
+ ).toBe(true);
240
+ });
241
+
242
+ // ── Alt+click toggle-all ──────────────────────────────────────────────────
243
+
244
+ it("alt+click on any toggle expands all items that have libraries", () => {
245
+ const { container } = renderList([
246
+ { id: 1, name: "A", libraries: [{ short_name: "alpha" }] },
247
+ { id: 2, name: "B", libraries: [] }, // no libraries → toggle disabled
248
+ { id: 3, name: "C", libraries: [{ short_name: "beta" }] },
249
+ { id: 4, name: "D" }, // no libraries field → no toggle
250
+ ]);
251
+ const toggles = container.querySelectorAll(".association-toggle");
252
+ // Items 1, 2 and 3 have a libraries array → 3 toggles; item 4 has none.
253
+ expect(toggles).toHaveLength(3);
254
+
255
+ fireEvent.click(toggles[0], { altKey: true });
256
+
257
+ const lists = container.querySelectorAll(".associated-items");
258
+ // Items 1 and 3 have libraries; item 2 is empty so no list shown even when "expanded".
259
+ expect(lists).toHaveLength(2);
260
+ });
261
+
262
+ it("alt+click collapses all items when all expandable items are already expanded", () => {
263
+ const { container } = renderList([
264
+ { id: 1, name: "A", libraries: [{ short_name: "alpha" }] },
265
+ { id: 2, name: "B", libraries: [{ short_name: "beta" }] },
266
+ ]);
267
+ const toggles = container.querySelectorAll(".association-toggle");
268
+
269
+ // Expand all first.
270
+ fireEvent.click(toggles[0], { altKey: true });
271
+ expect(container.querySelectorAll(".associated-items")).toHaveLength(2);
272
+
273
+ // Alt+click again should collapse all.
274
+ fireEvent.click(toggles[0], { altKey: true });
275
+ expect(container.querySelectorAll(".associated-items")).toHaveLength(0);
276
+ });
277
+
278
+ // ── Sorted library list ───────────────────────────────────────────────────
279
+
280
+ it("renders associated libraries sorted alphabetically by display name", () => {
281
+ const { container } = renderList([
282
+ {
283
+ id: 1,
284
+ name: "Service A",
285
+ libraries: [
286
+ { short_name: "gamma" },
287
+ { short_name: "alpha" },
288
+ { short_name: "beta" },
289
+ ],
290
+ },
291
+ ]);
292
+ fireEvent.click(container.querySelector(".association-toggle"));
293
+
294
+ const items = container.querySelectorAll(".associated-items li");
295
+ expect(items).toHaveLength(3);
296
+ expect(items[0].textContent).toBe("Alpha Library");
297
+ expect(items[1].textContent).toBe("Beta Library");
298
+ expect(items[2].textContent).toBe("Gamma Library");
299
+ });
300
+
301
+ it("renders a library with a uuid as a link to its config page", () => {
302
+ const { container } = renderList([
303
+ { id: 1, name: "Service A", libraries: [{ short_name: "alpha" }] },
304
+ ]);
305
+ fireEvent.click(container.querySelector(".association-toggle"));
306
+
307
+ const link = container.querySelector<HTMLAnchorElement>(
308
+ ".associated-items a"
309
+ );
310
+ expect(link).not.toBeNull();
311
+ expect(link.textContent).toBe("Alpha Library");
312
+ expect(link.href).toContain("/admin/web/config/libraries/edit/uuid-alpha");
313
+ });
314
+
315
+ it("renders a library without a uuid as plain text (no link)", () => {
316
+ const { container } = renderList([
317
+ { id: 1, name: "Service A", libraries: [{ short_name: "delta" }] },
318
+ ]);
319
+ fireEvent.click(container.querySelector(".association-toggle"));
320
+
321
+ expect(container.querySelector(".associated-items a")).toBeNull();
322
+ expect(container.querySelector(".associated-items li").textContent).toBe(
323
+ "Delta Library"
324
+ );
325
+ });
326
+
327
+ it("falls back to short_name when no display name is available in allLibraries", () => {
328
+ const { container } = renderList([
329
+ { id: 1, name: "Service A", libraries: [{ short_name: "unknown" }] },
330
+ ]);
331
+ fireEvent.click(container.querySelector(".association-toggle"));
332
+
333
+ expect(container.querySelector(".associated-items li").textContent).toBe(
334
+ "unknown"
335
+ );
336
+ });
337
+
338
+ it("renders multiple libraries that are not in allLibraries, each falling back to its short_name", () => {
339
+ // Neither library is in allLibraries, so both fall back to their short_name
340
+ // as the display label. Distinct short_names → distinct labels → no key collision.
341
+ const { container } = renderList([
342
+ {
343
+ id: 1,
344
+ name: "Service A",
345
+ libraries: [{ short_name: "dup-a" }, { short_name: "dup-b" }],
346
+ },
347
+ ]);
348
+ fireEvent.click(container.querySelector(".association-toggle"));
349
+
350
+ const items = container.querySelectorAll(".associated-items li");
351
+ expect(items).toHaveLength(2);
352
+ expect(items[0].textContent).toBe("dup-a");
353
+ expect(items[1].textContent).toBe("dup-b");
354
+ });
355
+
356
+ describe("renderAssociatedLibraries", () => {
357
+ it("renders no associated-items panel when the item has no libraries property", () => {
358
+ const { container } = renderList([{ id: 1, name: "A" }]);
359
+ expect(container.querySelector(".associated-items")).toBeNull();
360
+ });
361
+
362
+ it("renders no associated-items panel when the item has an empty libraries array", () => {
363
+ const { container } = renderList([{ id: 1, name: "A", libraries: [] }]);
364
+ expect(container.querySelector(".associated-items")).toBeNull();
365
+ });
366
+
367
+ it("renders library names resolved from allLibraries on expand", () => {
368
+ const { container } = renderList([
369
+ {
370
+ id: 1,
371
+ name: "Service A",
372
+ libraries: [{ short_name: "alpha" }, { short_name: "beta" }],
373
+ },
374
+ ]);
375
+ fireEvent.click(container.querySelector(".association-toggle"));
376
+ const items = container.querySelectorAll(".associated-items li");
377
+ expect(items).toHaveLength(2);
378
+ // Sorted alphabetically by resolved display name.
379
+ expect(items[0].textContent).toBe("Alpha Library");
380
+ expect(items[1].textContent).toBe("Beta Library");
381
+ });
382
+
383
+ it("falls back to short_name when allLibraries is absent from the data", () => {
384
+ const { container } = renderWithContext(
385
+ <TestServiceList
386
+ data={{ services: [{ id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] }] }}
387
+ fetchData={jest.fn()}
388
+ editItem={jest.fn().mockResolvedValue(undefined)}
389
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
390
+ csrfToken="token"
391
+ isFetching={false}
392
+ />,
393
+ config
394
+ );
395
+ fireEvent.click(container.querySelector(".association-toggle"));
396
+ expect(container.querySelector(".associated-items li").textContent).toBe("nypl");
397
+ });
398
+ });
399
+ });