@thepalaceproject/circulation-admin 1.38.0 → 1.39.0-post.2

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/jest.config.js CHANGED
@@ -4,6 +4,7 @@ module.exports = {
4
4
  "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
5
5
  "<rootDir>/tests/__mocks__/fileMock.js",
6
6
  "\\.(css|less)$": "<rootDir>/tests/__mocks__/styleMock.js",
7
+ "\\.md$": "<rootDir>/tests/__mocks__/fileMock.js",
7
8
  },
8
9
  preset: "ts-jest",
9
10
  testEnvironment: "jest-fixed-jsdom",
package/package.json CHANGED
@@ -32,7 +32,8 @@
32
32
  "dev-server": "dotenv -c -- webpack serve --progress --hot --config webpack.dev-server.config",
33
33
  "dev-test-axe": "TEST_AXE=true npm run dev",
34
34
  "prod": "webpack --progress --config webpack.prod.config",
35
- "build-docs": "typedoc --tsconfig tsconfig.json src"
35
+ "build-docs": "typedoc --tsconfig tsconfig.json src",
36
+ "sync-patron-blocking-docs": "node scripts/syncPatronBlockingDocs.js"
36
37
  },
37
38
  "dependencies": {
38
39
  "@nypl/dgx-svg-icons": "0.3.4",
@@ -105,7 +106,7 @@
105
106
  "fetch-mock-jest": "^1.5.1",
106
107
  "fetch-ponyfill": "^7.1.0",
107
108
  "file-loader": "^6.2.0",
108
- "follow-redirects": "^1.15.6",
109
+ "follow-redirects": "^1.16.0",
109
110
  "husky": "^4.3.0",
110
111
  "jest": "^29.3.1",
111
112
  "jest-environment-jsdom": "^29.3.1",
@@ -113,6 +114,7 @@
113
114
  "jsdom": "^20.0.3",
114
115
  "json-loader": "^0.5.4",
115
116
  "lint-staged": "^10.4.0",
117
+ "marked": "^17.0.6",
116
118
  "mini-css-extract-plugin": "1.6.0",
117
119
  "mocha": "^10.2.0",
118
120
  "msw": "^2.7.3",
@@ -152,5 +154,5 @@
152
154
  "*.{js,jsx,ts,tsx,css,md}": "prettier --write",
153
155
  "*.{js,css,md}": "prettier --write"
154
156
  },
155
- "version": "1.38.0"
157
+ "version": "1.39.0-post.2"
156
158
  }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable @typescript-eslint/no-var-requires */
3
+ /**
4
+ * Reads ../circulation/docs/FUNCTIONS.md, converts it to HTML, and writes
5
+ * src/content/patronBlockingFunctionsHtml.ts — a TypeScript module that
6
+ * exports the HTML as a string constant.
7
+ *
8
+ * Run via: npm run sync-patron-blocking-docs
9
+ */
10
+
11
+ "use strict";
12
+
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const { marked } = require("marked");
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Main
19
+ // ---------------------------------------------------------------------------
20
+ const srcMd = path.resolve(__dirname, "../../circulation/docs/FUNCTIONS.md");
21
+ const destTs = path.resolve(
22
+ __dirname,
23
+ "../src/content/patronBlockingFunctionsHtml.ts"
24
+ );
25
+
26
+ if (!fs.existsSync(srcMd)) {
27
+ console.error(`Source not found: ${srcMd}`);
28
+ process.exit(1);
29
+ }
30
+
31
+ const markdown = fs.readFileSync(srcMd, "utf8");
32
+ const html = marked(markdown);
33
+
34
+ const tsContent = `// AUTO-GENERATED — do not edit by hand.
35
+ // Run \`npm run sync-patron-blocking-docs\` to regenerate from
36
+ // circulation/docs/FUNCTIONS.md.
37
+ const patronBlockingFunctionsHtml = ${JSON.stringify(html)};
38
+ export default patronBlockingFunctionsHtml;
39
+ `;
40
+
41
+ fs.mkdirSync(path.dirname(destTs), { recursive: true });
42
+ fs.writeFileSync(destTs, tsContent, "utf8");
43
+ console.log(`Written: ${destTs}`);
@@ -1,5 +1,8 @@
1
1
  import * as fetchMock from "fetch-mock-jest";
2
- import { validatePatronBlockingRuleExpression } from "../../../src/api/patronBlockingRules";
2
+ import {
3
+ validatePatronBlockingRuleExpression,
4
+ ValidationResult,
5
+ } from "../../../src/api/patronBlockingRules";
3
6
  import { PatronBlockingRule } from "../../../src/interfaces";
4
7
 
5
8
  const VALIDATE_URL = "/admin/patron_auth_service_validate_patron_blocking_rule";
@@ -21,7 +24,24 @@ describe("validatePatronBlockingRuleExpression", () => {
21
24
  sampleRule,
22
25
  "test-token"
23
26
  );
24
- expect(result).toBeNull();
27
+ expect(result.error).toBeNull();
28
+ });
29
+
30
+ it("returns available_fields from a 200 response with a body", async () => {
31
+ fetchMock.post(VALIDATE_URL, {
32
+ status: 200,
33
+ body: { available_fields: { fines: 0, patron_identifier: "X123" } },
34
+ });
35
+ const result: ValidationResult = await validatePatronBlockingRuleExpression(
36
+ 42,
37
+ sampleRule,
38
+ "test-token"
39
+ );
40
+ expect(result.error).toBeNull();
41
+ expect(result.availableFields).toEqual({
42
+ fines: 0,
43
+ patron_identifier: "X123",
44
+ });
25
45
  });
26
46
 
27
47
  it("returns the detail string from a 400 response", async () => {
@@ -34,7 +54,8 @@ describe("validatePatronBlockingRuleExpression", () => {
34
54
  sampleRule,
35
55
  "test-token"
36
56
  );
37
- expect(result).toBe("Unknown placeholder: {unknown_field}");
57
+ expect(result.error).toBe("Unknown placeholder: {unknown_field}");
58
+ expect(result.availableFields).toBeNull();
38
59
  });
39
60
 
40
61
  it("returns a fallback string when a 400 response body has no detail", async () => {
@@ -44,8 +65,9 @@ describe("validatePatronBlockingRuleExpression", () => {
44
65
  sampleRule,
45
66
  "test-token"
46
67
  );
47
- expect(result).not.toBeNull();
48
- expect(typeof result).toBe("string");
68
+ expect(result.error).not.toBeNull();
69
+ expect(typeof result.error).toBe("string");
70
+ expect(result.availableFields).toBeNull();
49
71
  });
50
72
 
51
73
  it("sends the correct URL, method, and CSRF header", async () => {
@@ -73,6 +95,6 @@ describe("validatePatronBlockingRuleExpression", () => {
73
95
  expect.objectContaining({ method: "POST" })
74
96
  );
75
97
  // Server would return an error detail in a real call; here it returns 200 (mocked)
76
- expect(result).toBeNull();
98
+ expect(result.error).toBeNull();
77
99
  });
78
100
  });
@@ -79,7 +79,56 @@ describe("CollectionImportButton", () => {
79
79
  screen.getByRole("button", { name: "Queue Import" })
80
80
  ).toBeInTheDocument();
81
81
  expect(screen.getByRole("checkbox")).toBeInTheDocument();
82
- expect(screen.getByText("Force full re-import")).toBeInTheDocument();
82
+ expect(screen.getByLabelText("Force full re-import")).toBeInTheDocument();
83
+ });
84
+
85
+ it("shows compact summary by default; detailed docs are hidden", async () => {
86
+ const user = userEvent.setup();
87
+ renderButton();
88
+ await expandPanel(user);
89
+ expect(
90
+ screen.getByText(/queue import picks up new and changed items/i)
91
+ ).toBeInTheDocument();
92
+ expect(
93
+ screen.getByText(/schedules a background import job/i)
94
+ ).not.toBeVisible();
95
+ expect(
96
+ screen.getByText(/the import job re-processes every item/i)
97
+ ).not.toBeVisible();
98
+ });
99
+
100
+ it("clicking 'More details' reveals the detailed docs", async () => {
101
+ const user = userEvent.setup();
102
+ renderButton();
103
+ await expandPanel(user);
104
+
105
+ const details = screen.getByText("More details").closest("details");
106
+ expect(details).not.toHaveAttribute("open");
107
+
108
+ await user.click(screen.getByText("More details"));
109
+
110
+ expect(details).toHaveAttribute("open");
111
+ expect(
112
+ screen.getByText(/schedules a background import job/i)
113
+ ).toBeVisible();
114
+ expect(
115
+ screen.getByText(/the import job re-processes every item/i)
116
+ ).toBeVisible();
117
+ });
118
+
119
+ it("clicking 'More details' again hides the detailed docs", async () => {
120
+ const user = userEvent.setup();
121
+ renderButton();
122
+ await expandPanel(user);
123
+
124
+ await user.click(screen.getByText("More details"));
125
+ expect(
126
+ screen.getByText(/schedules a background import job/i)
127
+ ).toBeVisible();
128
+
129
+ await user.click(screen.getByText("More details"));
130
+ const details = screen.getByText("More details").closest("details");
131
+ expect(details).not.toHaveAttribute("open");
83
132
  });
84
133
 
85
134
  it("checkbox toggles force state", async () => {
@@ -94,6 +143,41 @@ describe("CollectionImportButton", () => {
94
143
  expect(checkbox).not.toBeChecked();
95
144
  });
96
145
 
146
+ it("button text changes to 'Queue Full Re-import' when force is checked", async () => {
147
+ const user = userEvent.setup();
148
+ renderButton();
149
+ await expandPanel(user);
150
+
151
+ expect(
152
+ screen.getByRole("button", { name: "Queue Import" })
153
+ ).toBeInTheDocument();
154
+
155
+ await user.click(screen.getByRole("checkbox"));
156
+
157
+ expect(
158
+ screen.getByRole("button", { name: "Queue Full Re-import" })
159
+ ).toBeInTheDocument();
160
+ expect(
161
+ screen.queryByRole("button", { name: "Queue Import" })
162
+ ).not.toBeInTheDocument();
163
+ });
164
+
165
+ it("button uses force class when force is checked", async () => {
166
+ const user = userEvent.setup();
167
+ renderButton();
168
+ await expandPanel(user);
169
+
170
+ const button = screen.getByRole("button", { name: "Queue Import" });
171
+ expect(button).not.toHaveClass("force");
172
+
173
+ await user.click(screen.getByRole("checkbox"));
174
+
175
+ const forceButton = screen.getByRole("button", {
176
+ name: "Queue Full Re-import",
177
+ });
178
+ expect(forceButton).toHaveClass("force");
179
+ });
180
+
97
181
  it("button triggers import with correct args (force=false)", async () => {
98
182
  const user = userEvent.setup();
99
183
  const { importCollection } = renderButton();
@@ -109,18 +193,39 @@ describe("CollectionImportButton", () => {
109
193
  await expandPanel(user);
110
194
  const checkbox = screen.getByRole("checkbox");
111
195
  await user.click(checkbox);
112
- const button = screen.getByRole("button", { name: "Queue Import" });
196
+ const button = screen.getByRole("button", {
197
+ name: "Queue Full Re-import",
198
+ });
113
199
  await user.click(button);
114
200
  expect(importCollection).toHaveBeenCalledWith(42, true);
115
201
  });
116
202
 
117
- it("shows success feedback with alert-success styling after import", async () => {
203
+ it("shows success feedback for regular import", async () => {
118
204
  const user = userEvent.setup();
119
205
  renderButton();
120
206
  await expandPanel(user);
121
207
  await user.click(screen.getByRole("button", { name: "Queue Import" }));
122
208
  await waitFor(() => {
123
- const feedback = screen.getByText("Import task queued.");
209
+ const feedback = screen.getByText(
210
+ /import task queued\. new and updated items will appear/i
211
+ );
212
+ expect(feedback).toBeInTheDocument();
213
+ expect(feedback).toHaveClass("alert", "alert-success");
214
+ });
215
+ });
216
+
217
+ it("shows success feedback for force re-import", async () => {
218
+ const user = userEvent.setup();
219
+ renderButton();
220
+ await expandPanel(user);
221
+ await user.click(screen.getByRole("checkbox"));
222
+ await user.click(
223
+ screen.getByRole("button", { name: "Queue Full Re-import" })
224
+ );
225
+ await waitFor(() => {
226
+ const feedback = screen.getByText(
227
+ /full re-import task queued\. all items will be re-processed/i
228
+ );
124
229
  expect(feedback).toBeInTheDocument();
125
230
  expect(feedback).toHaveClass("alert", "alert-success");
126
231
  });
@@ -150,9 +255,13 @@ describe("CollectionImportButton", () => {
150
255
  await user.click(checkbox);
151
256
  expect(checkbox).toBeChecked();
152
257
 
153
- await user.click(screen.getByRole("button", { name: "Queue Import" }));
258
+ await user.click(
259
+ screen.getByRole("button", { name: "Queue Full Re-import" })
260
+ );
154
261
  await waitFor(() => {
155
- expect(screen.getByText("Import task queued.")).toBeInTheDocument();
262
+ expect(
263
+ screen.getByText(/full re-import task queued/i)
264
+ ).toBeInTheDocument();
156
265
  });
157
266
 
158
267
  const nextCollection: CollectionData = {
@@ -171,7 +280,9 @@ describe("CollectionImportButton", () => {
171
280
 
172
281
  await waitFor(() => {
173
282
  expect(screen.getByRole("checkbox")).not.toBeChecked();
174
- expect(screen.queryByText("Import task queued.")).not.toBeInTheDocument();
283
+ expect(
284
+ screen.queryByText(/full re-import task queued/i)
285
+ ).not.toBeInTheDocument();
175
286
  });
176
287
  });
177
288
 
@@ -204,4 +315,31 @@ describe("CollectionImportButton", () => {
204
315
  ).toBeEnabled();
205
316
  });
206
317
  });
318
+
319
+ it("shows 'Queuing Full Re-import...' while importing with force", async () => {
320
+ const user = userEvent.setup();
321
+ let resolveImport: () => void;
322
+ const pendingImport = new Promise<void>((resolve) => {
323
+ resolveImport = resolve;
324
+ });
325
+ const mockImport = jest.fn().mockReturnValue(pendingImport);
326
+ renderButton({ importCollection: mockImport });
327
+ await expandPanel(user);
328
+
329
+ await user.click(screen.getByRole("checkbox"));
330
+ await user.click(
331
+ screen.getByRole("button", { name: "Queue Full Re-import" })
332
+ );
333
+
334
+ expect(
335
+ screen.getByRole("button", { name: "Queuing Full Re-import..." })
336
+ ).toBeDisabled();
337
+
338
+ resolveImport();
339
+ await waitFor(() => {
340
+ expect(
341
+ screen.getByRole("button", { name: "Queue Full Re-import" })
342
+ ).toBeEnabled();
343
+ });
344
+ });
207
345
  });
@@ -0,0 +1,220 @@
1
+ import * as React from "react";
2
+ import { fireEvent } from "@testing-library/react";
3
+ import { Collections } from "../../../src/components/Collections";
4
+ import renderWithContext from "../testUtils/renderWithContext";
5
+ import {
6
+ CollectionsData,
7
+ ConfigurationSettings,
8
+ } 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__/Collections-test.tsx`.
13
+ //
14
+ // Those tests should eventually be migrated here and
15
+ // adapted to the Jest/React Testing Library paradigm.
16
+
17
+ describe("Collections - associated library disclosure", () => {
18
+ // ── Shared fixtures ───────────────────────────────────────────────────────
19
+
20
+ const allLibraries = [
21
+ { short_name: "gamma", name: "Gamma Library", uuid: "uuid-gamma" },
22
+ { short_name: "alpha", name: "Alpha Library", uuid: "uuid-alpha" },
23
+ { short_name: "beta", name: "Beta Library", uuid: "uuid-beta" },
24
+ { short_name: "delta", name: "Delta Library" }, // no uuid
25
+ ];
26
+
27
+ const sysAdminConfig: Partial<ConfigurationSettings> = {
28
+ csrfToken: "",
29
+ featureFlags: defaultFeatureFlags,
30
+ roles: [{ role: "system" }],
31
+ };
32
+
33
+ const renderCollections = (data: Partial<CollectionsData>) =>
34
+ renderWithContext(
35
+ <Collections
36
+ data={
37
+ {
38
+ collections: [],
39
+ protocols: [],
40
+ allLibraries,
41
+ ...data,
42
+ } as CollectionsData
43
+ }
44
+ fetchData={jest.fn()}
45
+ editItem={jest.fn().mockResolvedValue(undefined)}
46
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
47
+ registerLibrary={jest.fn().mockResolvedValue(undefined)}
48
+ importCollection={jest.fn().mockResolvedValue(undefined)}
49
+ csrfToken="token"
50
+ isFetching={false}
51
+ />,
52
+ sysAdminConfig
53
+ );
54
+
55
+ // ── Toggle visibility ─────────────────────────────────────────────────────
56
+
57
+ it("shows no toggle for a collection without a libraries field", () => {
58
+ const { container } = renderCollections({
59
+ collections: [{ id: 1, protocol: "p", name: "My Collection" } as any],
60
+ });
61
+ expect(container.querySelector(".association-toggle")).toBeNull();
62
+ expect(container.querySelector(".library-count")).toBeNull();
63
+ });
64
+
65
+ it("shows a disabled toggle and 'no libraries' for a collection with an empty libraries array", () => {
66
+ const { container } = renderCollections({
67
+ collections: [
68
+ { id: 1, protocol: "p", name: "My Collection", libraries: [] } as any,
69
+ ],
70
+ });
71
+ const toggle = container.querySelector<HTMLButtonElement>(
72
+ ".association-toggle"
73
+ );
74
+ expect(toggle).not.toBeNull();
75
+ expect(toggle.disabled).toBe(true);
76
+ expect(container.querySelector(".library-count").textContent).toBe(
77
+ " (no libraries)"
78
+ );
79
+ });
80
+
81
+ it("shows '1 library' for a collection with one associated library", () => {
82
+ const { container } = renderCollections({
83
+ collections: [
84
+ {
85
+ id: 1,
86
+ protocol: "p",
87
+ name: "My Collection",
88
+ libraries: [{ short_name: "alpha" }],
89
+ } as any,
90
+ ],
91
+ });
92
+ const toggle = container.querySelector<HTMLButtonElement>(
93
+ ".association-toggle"
94
+ );
95
+ expect(toggle.disabled).toBe(false);
96
+ expect(container.querySelector(".library-count").textContent).toBe(
97
+ " (1 library)"
98
+ );
99
+ });
100
+
101
+ it("shows 'N libraries' for a collection with multiple associated libraries", () => {
102
+ const { container } = renderCollections({
103
+ collections: [
104
+ {
105
+ id: 1,
106
+ protocol: "p",
107
+ name: "My Collection",
108
+ libraries: [
109
+ { short_name: "alpha" },
110
+ { short_name: "beta" },
111
+ { short_name: "gamma" },
112
+ ],
113
+ } as any,
114
+ ],
115
+ });
116
+ expect(container.querySelector(".library-count").textContent).toBe(
117
+ " (3 libraries)"
118
+ );
119
+ });
120
+
121
+ // ── Expand / collapse ─────────────────────────────────────────────────────
122
+
123
+ it("expands the library list on toggle click and collapses on a second click", () => {
124
+ const { container } = renderCollections({
125
+ collections: [
126
+ {
127
+ id: 1,
128
+ protocol: "p",
129
+ name: "My Collection",
130
+ libraries: [{ short_name: "alpha" }, { short_name: "beta" }],
131
+ } as any,
132
+ ],
133
+ });
134
+ const toggle = container.querySelector(".association-toggle");
135
+ expect(container.querySelector(".associated-items")).toBeNull();
136
+ fireEvent.click(toggle);
137
+ expect(container.querySelector(".associated-items")).not.toBeNull();
138
+ fireEvent.click(toggle);
139
+ expect(container.querySelector(".associated-items")).toBeNull();
140
+ });
141
+
142
+ // ── allLibraries injection from mapStateToProps ───────────────────────────
143
+
144
+ it("resolves library display names from allLibraries", () => {
145
+ const { container } = renderCollections({
146
+ collections: [
147
+ {
148
+ id: 1,
149
+ protocol: "p",
150
+ name: "My Collection",
151
+ libraries: [{ short_name: "alpha" }, { short_name: "beta" }],
152
+ } as any,
153
+ ],
154
+ });
155
+ fireEvent.click(container.querySelector(".association-toggle"));
156
+
157
+ const items = container.querySelectorAll(".associated-items li");
158
+ // Sorted alphabetically: Alpha, Beta
159
+ expect(items[0].textContent).toBe("Alpha Library");
160
+ expect(items[1].textContent).toBe("Beta Library");
161
+ });
162
+
163
+ it("links a library name to its config page when a uuid is available", () => {
164
+ const { container } = renderCollections({
165
+ collections: [
166
+ {
167
+ id: 1,
168
+ protocol: "p",
169
+ name: "My Collection",
170
+ libraries: [{ short_name: "alpha" }],
171
+ } as any,
172
+ ],
173
+ });
174
+ fireEvent.click(container.querySelector(".association-toggle"));
175
+
176
+ const link = container.querySelector<HTMLAnchorElement>(
177
+ ".associated-items a"
178
+ );
179
+ expect(link).not.toBeNull();
180
+ expect(link.textContent).toBe("Alpha Library");
181
+ expect(link.href).toContain("/admin/web/config/libraries/edit/uuid-alpha");
182
+ });
183
+
184
+ it("renders a library without a uuid as plain text", () => {
185
+ const { container } = renderCollections({
186
+ collections: [
187
+ {
188
+ id: 1,
189
+ protocol: "p",
190
+ name: "My Collection",
191
+ libraries: [{ short_name: "delta" }],
192
+ } as any,
193
+ ],
194
+ });
195
+ fireEvent.click(container.querySelector(".association-toggle"));
196
+
197
+ expect(container.querySelector(".associated-items a")).toBeNull();
198
+ expect(container.querySelector(".associated-items li").textContent).toBe(
199
+ "Delta Library"
200
+ );
201
+ });
202
+
203
+ it("falls back to short_name when the library is not in allLibraries", () => {
204
+ const { container } = renderCollections({
205
+ collections: [
206
+ {
207
+ id: 1,
208
+ protocol: "p",
209
+ name: "My Collection",
210
+ libraries: [{ short_name: "unknown" }],
211
+ } as any,
212
+ ],
213
+ });
214
+ fireEvent.click(container.querySelector(".association-toggle"));
215
+
216
+ expect(container.querySelector(".associated-items li").textContent).toBe(
217
+ "unknown"
218
+ );
219
+ });
220
+ });