@thepalaceproject/circulation-admin 1.38.0-post.4 → 1.38.0-post.5
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/README.md +42 -0
- package/dist/circulation-admin.css +1 -1
- package/dist/circulation-admin.js +1 -1
- package/jest.config.js +1 -0
- package/package.json +4 -2
- package/scripts/syncPatronBlockingDocs.js +43 -0
- package/tests/jest/api/patronBlockingRules.test.ts +28 -6
- package/tests/jest/components/PatronAuthServiceEditForm.test.tsx +39 -16
- package/tests/jest/components/PatronBlockingRulesEditor.test.tsx +234 -46
- package/tests/jest/components/PatronBlockingRulesHelpModal.test.tsx +148 -0
- package/webpack.common.js +4 -0
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-post.
|
|
157
|
+
"version": "1.38.0-post.5"
|
|
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 {
|
|
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
|
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }));
|