@thepalaceproject/circulation-admin 1.38.0 → 1.39.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.
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",
@@ -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"
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
  });
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import { render, screen, waitFor } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import * as fetchMock from "fetch-mock-jest";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
6
  import PatronAuthServiceEditForm from "../../../src/components/PatronAuthServiceEditForm";
6
7
  import {
7
8
  PatronAuthServicesData,
@@ -66,6 +67,16 @@ function buildSIP2Item(rules: PatronBlockingRule[] = []) {
66
67
  };
67
68
  }
68
69
 
70
+ /** Renders with a fresh QueryClient so useAvailableFields (useQuery) works. */
71
+ function renderForm(element: React.ReactElement) {
72
+ const queryClient = new QueryClient({
73
+ defaultOptions: { queries: { retry: false } },
74
+ });
75
+ return render(
76
+ <QueryClientProvider client={queryClient}>{element}</QueryClientProvider>
77
+ );
78
+ }
79
+
69
80
  // Guard: any blur on the Rule Expression textarea calls validatePatronBlockingRuleExpression.
70
81
  // All describe blocks get a default 200 mock so tests that incidentally trigger blur
71
82
  // don't fail with "only absolute URLs are supported" from the fetch polyfill.
@@ -80,7 +91,9 @@ afterEach(() => {
80
91
  describe("PatronAuthServiceEditForm – capability gating", () => {
81
92
  it("shows PatronBlockingRulesEditor in expanded library settings for SIP2", async () => {
82
93
  const user = userEvent.setup();
83
- render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
94
+ renderForm(
95
+ <PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />
96
+ );
84
97
 
85
98
  await expandLibrariesPanel(user);
86
99
  const editButton = screen.getByRole("button", { name: /Edit/i });
@@ -99,7 +112,7 @@ describe("PatronAuthServiceEditForm – capability gating", () => {
99
112
  settings: { idp_url: "https://idp.example.com" },
100
113
  libraries: [{ short_name: LIBRARY_SHORT_NAME }],
101
114
  };
102
- render(<PatronAuthServiceEditForm {...baseProps} item={item} />);
115
+ renderForm(<PatronAuthServiceEditForm {...baseProps} item={item} />);
103
116
  await expandLibrariesPanel(user);
104
117
 
105
118
  // For non-SIP2 with no library_settings, the Edit button should not render
@@ -119,7 +132,7 @@ describe("PatronAuthServiceEditForm – load / initial value", () => {
119
132
  message: "Card expired",
120
133
  },
121
134
  ];
122
- render(
135
+ renderForm(
123
136
  <PatronAuthServiceEditForm
124
137
  {...baseProps}
125
138
  item={buildSIP2Item(existingRules)}
@@ -144,7 +157,7 @@ describe("PatronAuthServiceEditForm – load / initial value", () => {
144
157
 
145
158
  it("shows empty editor when library has no existing patron_blocking_rules", async () => {
146
159
  const user = userEvent.setup();
147
- render(
160
+ renderForm(
148
161
  <PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item([])} />
149
162
  );
150
163
 
@@ -160,7 +173,7 @@ describe("PatronAuthServiceEditForm – error display", () => {
160
173
  it("passes error prop down to PatronBlockingRulesEditor", async () => {
161
174
  const user = userEvent.setup();
162
175
  const error = { status: 400, response: "Validation failed", url: "" };
163
- render(
176
+ renderForm(
164
177
  <PatronAuthServiceEditForm
165
178
  {...baseProps}
166
179
  item={buildSIP2Item([{ name: "", rule: "expr", message: null }])}
@@ -180,7 +193,9 @@ describe("PatronAuthServiceEditForm – error display", () => {
180
193
  describe("PatronAuthServiceEditForm – serialization", () => {
181
194
  it("includes patron_blocking_rules in the library state when editLibrary is called", async () => {
182
195
  const user = userEvent.setup();
183
- render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
196
+ renderForm(
197
+ <PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />
198
+ );
184
199
 
185
200
  await expandLibrariesPanel(user);
186
201
  const editButton = screen.getByRole("button", { name: /Edit/i });
@@ -215,7 +230,7 @@ describe("PatronAuthServiceEditForm – serialization", () => {
215
230
  settings: {},
216
231
  libraries: [],
217
232
  };
218
- render(<PatronAuthServiceEditForm {...baseProps} item={item} />);
233
+ renderForm(<PatronAuthServiceEditForm {...baseProps} item={item} />);
219
234
 
220
235
  await expandLibrariesPanel(user);
221
236
  // Select the library from the dropdown
@@ -246,7 +261,9 @@ describe("PatronAuthServiceEditForm – serialization", () => {
246
261
  describe("PatronAuthServiceEditForm – save button gating", () => {
247
262
  it("disables the per-library Save button immediately when a new rule is added", async () => {
248
263
  const user = userEvent.setup();
249
- render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
264
+ renderForm(
265
+ <PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />
266
+ );
250
267
 
251
268
  await expandLibrariesPanel(user);
252
269
  await user.click(screen.getByRole("button", { name: /Edit/i }));
@@ -258,7 +275,9 @@ describe("PatronAuthServiceEditForm – save button gating", () => {
258
275
 
259
276
  it("re-enables the per-library Save button once validation succeeds", async () => {
260
277
  const user = userEvent.setup();
261
- render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
278
+ renderForm(
279
+ <PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />
280
+ );
262
281
 
263
282
  await expandLibrariesPanel(user);
264
283
  await user.click(screen.getByRole("button", { name: /Edit/i }));
@@ -280,7 +299,9 @@ describe("PatronAuthServiceEditForm – save button gating", () => {
280
299
  body: { detail: "Unknown placeholder" },
281
300
  });
282
301
 
283
- render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
302
+ renderForm(
303
+ <PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />
304
+ );
284
305
 
285
306
  await expandLibrariesPanel(user);
286
307
  await user.click(screen.getByRole("button", { name: /Edit/i }));
@@ -302,7 +323,7 @@ describe("PatronAuthServiceEditForm – save button gating", () => {
302
323
  settings: {},
303
324
  libraries: [],
304
325
  };
305
- render(<PatronAuthServiceEditForm {...baseProps} item={item} />);
326
+ renderForm(<PatronAuthServiceEditForm {...baseProps} item={item} />);
306
327
 
307
328
  await expandLibrariesPanel(user);
308
329
  const select = screen.getByRole("combobox", { name: /Add Library/i });
@@ -324,7 +345,7 @@ describe("PatronAuthServiceEditForm – save button gating", () => {
324
345
  { name: "Rule A", rule: "expr_a" },
325
346
  { name: "Rule B", rule: "expr_b" },
326
347
  ];
327
- render(
348
+ renderForm(
328
349
  <PatronAuthServiceEditForm
329
350
  {...baseProps}
330
351
  item={buildSIP2Item(existingRules)}
@@ -354,7 +375,7 @@ describe("PatronAuthServiceEditForm – save button gating", () => {
354
375
  { name: "Rule A", rule: "expr_a" },
355
376
  { name: "Rule A", rule: "expr_b" }, // starts as duplicate
356
377
  ];
357
- render(
378
+ renderForm(
358
379
  <PatronAuthServiceEditForm
359
380
  {...baseProps}
360
381
  item={buildSIP2Item(existingRules)}
@@ -380,7 +401,7 @@ describe("PatronAuthServiceEditForm – save button gating", () => {
380
401
  it("re-blocks and then re-enables the Save button when an existing rule's expression is edited and re-validated", async () => {
381
402
  const user = userEvent.setup();
382
403
  const existingRules = [{ name: "Rule A", rule: "expr_a" }];
383
- render(
404
+ renderForm(
384
405
  <PatronAuthServiceEditForm
385
406
  {...baseProps}
386
407
  item={buildSIP2Item(existingRules)}
@@ -411,7 +432,7 @@ describe("PatronAuthServiceEditForm – save button gating", () => {
411
432
  describe("PatronAuthServiceEditForm – csrfToken / serviceId wiring", () => {
412
433
  it("passes the csrfToken from additionalData to the validation API request", async () => {
413
434
  const user = userEvent.setup();
414
- render(
435
+ renderForm(
415
436
  <PatronAuthServiceEditForm
416
437
  {...baseProps}
417
438
  item={buildSIP2Item()}
@@ -441,7 +462,9 @@ describe("PatronAuthServiceEditForm – csrfToken / serviceId wiring", () => {
441
462
  it("passes the item id as service_id in the validation API request body", async () => {
442
463
  const user = userEvent.setup();
443
464
  // item.id = 1 (from buildSIP2Item)
444
- render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
465
+ renderForm(
466
+ <PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />
467
+ );
445
468
 
446
469
  await expandLibrariesPanel(user);
447
470
  await user.click(screen.getByRole("button", { name: /Edit/i }));