@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/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 +5 -3
- package/scripts/syncPatronBlockingDocs.js +43 -0
- package/tests/jest/api/patronBlockingRules.test.ts +28 -6
- package/tests/jest/components/CollectionImportButton.test.tsx +145 -7
- package/tests/jest/components/Collections.test.tsx +220 -0
- package/tests/jest/components/DiscoveryServices.test.tsx +545 -0
- package/tests/jest/components/EditableConfigList.test.tsx +399 -0
- package/tests/jest/components/IndividualAdmins.test.tsx +390 -0
- 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
|
@@ -2,14 +2,37 @@ import * as React from "react";
|
|
|
2
2
|
import { render, screen, waitFor, act } 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 PatronBlockingRulesEditor, {
|
|
6
7
|
PatronBlockingRulesEditorHandle,
|
|
7
8
|
} from "../../../src/components/PatronBlockingRulesEditor";
|
|
8
9
|
import { PatronBlockingRule } from "../../../src/interfaces";
|
|
9
10
|
import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces";
|
|
10
11
|
|
|
12
|
+
/** Renders with a fresh QueryClient so useQuery hooks work in tests. */
|
|
13
|
+
function renderEditor(element: React.ReactElement) {
|
|
14
|
+
const queryClient = new QueryClient({
|
|
15
|
+
defaultOptions: { queries: { retry: false } },
|
|
16
|
+
});
|
|
17
|
+
return render(
|
|
18
|
+
<QueryClientProvider client={queryClient}>{element}</QueryClientProvider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
const VALIDATE_URL = "/admin/patron_auth_service_validate_patron_blocking_rule";
|
|
12
23
|
|
|
24
|
+
/** Sample available_fields dict returned by the server on successful validation. */
|
|
25
|
+
const SAMPLE_FIELDS = {
|
|
26
|
+
fines: "2.50",
|
|
27
|
+
patron_identifier: "12345",
|
|
28
|
+
patron_name: "John Doe",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const SUCCESS_RESPONSE = {
|
|
32
|
+
status: 200,
|
|
33
|
+
body: { available_fields: SAMPLE_FIELDS },
|
|
34
|
+
};
|
|
35
|
+
|
|
13
36
|
const existingRules: PatronBlockingRule[] = [
|
|
14
37
|
{ name: "Rule A", rule: "expr_a", message: "msg a" },
|
|
15
38
|
{ name: "Rule B", rule: "expr_b" },
|
|
@@ -30,7 +53,7 @@ const existingRules: PatronBlockingRule[] = [
|
|
|
30
53
|
|
|
31
54
|
describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)", () => {
|
|
32
55
|
beforeEach(() => {
|
|
33
|
-
fetchMock.post(VALIDATE_URL,
|
|
56
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
34
57
|
});
|
|
35
58
|
|
|
36
59
|
afterEach(() => {
|
|
@@ -41,7 +64,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
41
64
|
const user = userEvent.setup();
|
|
42
65
|
const onChange = jest.fn();
|
|
43
66
|
|
|
44
|
-
|
|
67
|
+
renderEditor(
|
|
45
68
|
<PatronBlockingRulesEditor
|
|
46
69
|
value={[]}
|
|
47
70
|
serviceId={42}
|
|
@@ -59,7 +82,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
59
82
|
const user = userEvent.setup();
|
|
60
83
|
const onChange = jest.fn();
|
|
61
84
|
|
|
62
|
-
|
|
85
|
+
renderEditor(
|
|
63
86
|
<PatronBlockingRulesEditor
|
|
64
87
|
value={[]}
|
|
65
88
|
serviceId={42}
|
|
@@ -86,7 +109,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
86
109
|
body: { detail: "Bad expression" },
|
|
87
110
|
});
|
|
88
111
|
|
|
89
|
-
|
|
112
|
+
renderEditor(
|
|
90
113
|
<PatronBlockingRulesEditor
|
|
91
114
|
value={[]}
|
|
92
115
|
serviceId={42}
|
|
@@ -113,7 +136,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
113
136
|
{ name: "Rule B", rule: "expr_b" },
|
|
114
137
|
];
|
|
115
138
|
|
|
116
|
-
|
|
139
|
+
renderEditor(
|
|
117
140
|
<PatronBlockingRulesEditor
|
|
118
141
|
value={rules}
|
|
119
142
|
serviceId={42}
|
|
@@ -143,7 +166,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
143
166
|
{ name: "Rule A", rule: "expr_b" }, // duplicate
|
|
144
167
|
];
|
|
145
168
|
|
|
146
|
-
|
|
169
|
+
renderEditor(
|
|
147
170
|
<PatronBlockingRulesEditor
|
|
148
171
|
value={rules}
|
|
149
172
|
serviceId={42}
|
|
@@ -169,7 +192,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
169
192
|
const user = userEvent.setup();
|
|
170
193
|
const onChange = jest.fn();
|
|
171
194
|
|
|
172
|
-
|
|
195
|
+
renderEditor(
|
|
173
196
|
<PatronBlockingRulesEditor
|
|
174
197
|
value={[]}
|
|
175
198
|
csrfToken="tok"
|
|
@@ -188,7 +211,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
188
211
|
const user = userEvent.setup();
|
|
189
212
|
const onChange = jest.fn();
|
|
190
213
|
|
|
191
|
-
|
|
214
|
+
renderEditor(
|
|
192
215
|
<PatronBlockingRulesEditor
|
|
193
216
|
value={[]}
|
|
194
217
|
serviceId={42}
|
|
@@ -209,7 +232,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
209
232
|
const user = userEvent.setup();
|
|
210
233
|
const onChange = jest.fn();
|
|
211
234
|
|
|
212
|
-
|
|
235
|
+
renderEditor(
|
|
213
236
|
<PatronBlockingRulesEditor
|
|
214
237
|
value={[]}
|
|
215
238
|
csrfToken="tok"
|
|
@@ -230,7 +253,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
230
253
|
const user = userEvent.setup();
|
|
231
254
|
const onChange = jest.fn();
|
|
232
255
|
|
|
233
|
-
|
|
256
|
+
renderEditor(
|
|
234
257
|
<PatronBlockingRulesEditor
|
|
235
258
|
value={[]}
|
|
236
259
|
serviceId={42}
|
|
@@ -266,7 +289,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
266
289
|
const onChange = jest.fn();
|
|
267
290
|
const rules: PatronBlockingRule[] = [{ name: "Rule A", rule: "expr_a" }];
|
|
268
291
|
|
|
269
|
-
|
|
292
|
+
renderEditor(
|
|
270
293
|
<PatronBlockingRulesEditor
|
|
271
294
|
value={rules}
|
|
272
295
|
serviceId={42}
|
|
@@ -290,7 +313,7 @@ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)"
|
|
|
290
313
|
|
|
291
314
|
describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
292
315
|
beforeEach(() => {
|
|
293
|
-
fetchMock.post(VALIDATE_URL,
|
|
316
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
294
317
|
});
|
|
295
318
|
|
|
296
319
|
afterEach(() => {
|
|
@@ -300,7 +323,7 @@ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
|
300
323
|
it("calls the validation API when the user leaves the Rule Expression field", async () => {
|
|
301
324
|
const user = userEvent.setup();
|
|
302
325
|
|
|
303
|
-
|
|
326
|
+
renderEditor(
|
|
304
327
|
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
305
328
|
);
|
|
306
329
|
|
|
@@ -328,7 +351,7 @@ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
|
328
351
|
body: { detail: "Unknown placeholder: {x}" },
|
|
329
352
|
});
|
|
330
353
|
|
|
331
|
-
|
|
354
|
+
renderEditor(
|
|
332
355
|
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
333
356
|
);
|
|
334
357
|
|
|
@@ -348,7 +371,7 @@ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
|
348
371
|
body: { detail: "Bad expression" },
|
|
349
372
|
});
|
|
350
373
|
|
|
351
|
-
|
|
374
|
+
renderEditor(
|
|
352
375
|
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
353
376
|
);
|
|
354
377
|
|
|
@@ -370,7 +393,7 @@ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
|
370
393
|
const user = userEvent.setup();
|
|
371
394
|
fetchMock.mockReset();
|
|
372
395
|
|
|
373
|
-
|
|
396
|
+
renderEditor(
|
|
374
397
|
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
375
398
|
);
|
|
376
399
|
|
|
@@ -395,7 +418,7 @@ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
|
395
418
|
});
|
|
396
419
|
|
|
397
420
|
// No serviceId — simulates a new service that has not yet been saved
|
|
398
|
-
|
|
421
|
+
renderEditor(<PatronBlockingRulesEditor value={[]} csrfToken="tok" />);
|
|
399
422
|
|
|
400
423
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
401
424
|
await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
|
|
@@ -421,7 +444,7 @@ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
|
421
444
|
body: { detail: "Bad expression syntax" },
|
|
422
445
|
});
|
|
423
446
|
|
|
424
|
-
|
|
447
|
+
renderEditor(
|
|
425
448
|
<PatronBlockingRulesEditor
|
|
426
449
|
value={[{ name: "Rule A", rule: "expr_a" }]}
|
|
427
450
|
serviceId={42}
|
|
@@ -467,7 +490,7 @@ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
|
467
490
|
body: { detail: "Syntax error in rule" },
|
|
468
491
|
});
|
|
469
492
|
|
|
470
|
-
|
|
493
|
+
renderEditor(
|
|
471
494
|
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
472
495
|
);
|
|
473
496
|
|
|
@@ -480,7 +503,7 @@ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
|
|
|
480
503
|
|
|
481
504
|
// Switch mock to success, correct the rule, and blur again
|
|
482
505
|
fetchMock.mockReset();
|
|
483
|
-
fetchMock.post(VALIDATE_URL,
|
|
506
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
484
507
|
|
|
485
508
|
await user.clear(screen.getByLabelText(/Rule Expression/i));
|
|
486
509
|
await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0");
|
|
@@ -497,7 +520,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
497
520
|
// incidentally trigger blur on the Rule Expression field (e.g. by typing in
|
|
498
521
|
// the Message textarea) don't produce "only absolute URLs" fetch errors.
|
|
499
522
|
beforeEach(() => {
|
|
500
|
-
fetchMock.post(VALIDATE_URL,
|
|
523
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
501
524
|
});
|
|
502
525
|
|
|
503
526
|
afterEach(() => {
|
|
@@ -505,13 +528,13 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
505
528
|
});
|
|
506
529
|
|
|
507
530
|
it("renders with no rules when no value provided", () => {
|
|
508
|
-
|
|
531
|
+
renderEditor(<PatronBlockingRulesEditor />);
|
|
509
532
|
expect(screen.getByText(/No patron blocking rules defined/i)).toBeTruthy();
|
|
510
533
|
expect(screen.getByRole("button", { name: /Add Rule/i })).toBeTruthy();
|
|
511
534
|
});
|
|
512
535
|
|
|
513
536
|
it("renders existing rules from value prop", () => {
|
|
514
|
-
|
|
537
|
+
renderEditor(<PatronBlockingRulesEditor value={existingRules} />);
|
|
515
538
|
expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(2);
|
|
516
539
|
expect(screen.getAllByLabelText(/Rule Expression/i)).toHaveLength(2);
|
|
517
540
|
|
|
@@ -530,7 +553,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
530
553
|
|
|
531
554
|
it("adds a new blank rule row when Add Rule is clicked", async () => {
|
|
532
555
|
const user = userEvent.setup();
|
|
533
|
-
|
|
556
|
+
renderEditor(<PatronBlockingRulesEditor value={[]} />);
|
|
534
557
|
|
|
535
558
|
expect(screen.queryByLabelText(/Rule Name/i)).toBeNull();
|
|
536
559
|
|
|
@@ -543,7 +566,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
543
566
|
|
|
544
567
|
it("removes a rule row when Delete is clicked", async () => {
|
|
545
568
|
const user = userEvent.setup();
|
|
546
|
-
|
|
569
|
+
renderEditor(<PatronBlockingRulesEditor value={existingRules} />);
|
|
547
570
|
|
|
548
571
|
expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(2);
|
|
549
572
|
|
|
@@ -560,7 +583,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
560
583
|
it("getValue returns current rules including edits", async () => {
|
|
561
584
|
const user = userEvent.setup();
|
|
562
585
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
563
|
-
|
|
586
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={[]} />);
|
|
564
587
|
|
|
565
588
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
566
589
|
|
|
@@ -584,28 +607,33 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
584
607
|
|
|
585
608
|
it("getValue returns an empty array when no rules exist", () => {
|
|
586
609
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
587
|
-
|
|
610
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={[]} />);
|
|
588
611
|
expect(ref.current.getValue()).toEqual([]);
|
|
589
612
|
});
|
|
590
613
|
|
|
591
|
-
it("disables all inputs and buttons when disabled prop is true", () => {
|
|
592
|
-
|
|
614
|
+
it("disables all editing inputs and buttons when disabled prop is true", () => {
|
|
615
|
+
renderEditor(<PatronBlockingRulesEditor value={existingRules} disabled={true} />);
|
|
593
616
|
|
|
594
|
-
|
|
595
|
-
|
|
617
|
+
// The Help button stays enabled even in disabled mode (read-only affordance).
|
|
618
|
+
const editingButtons = screen
|
|
619
|
+
.getAllByRole("button")
|
|
620
|
+
.filter(
|
|
621
|
+
(btn) => !btn.classList.contains("patron-blocking-rules-help-btn")
|
|
622
|
+
);
|
|
623
|
+
editingButtons.forEach((btn) => expect(btn).toBeDisabled());
|
|
596
624
|
|
|
597
625
|
const inputs = screen.getAllByRole("textbox");
|
|
598
626
|
inputs.forEach((input) => expect(input).toBeDisabled());
|
|
599
627
|
});
|
|
600
628
|
|
|
601
629
|
it("does not show 'no rules' message when rules exist", () => {
|
|
602
|
-
|
|
630
|
+
renderEditor(<PatronBlockingRulesEditor value={existingRules} />);
|
|
603
631
|
expect(screen.queryByText(/No patron blocking rules defined/i)).toBeNull();
|
|
604
632
|
});
|
|
605
633
|
|
|
606
634
|
it("disables Add Rule button when an existing rule is missing required fields", async () => {
|
|
607
635
|
const user = userEvent.setup();
|
|
608
|
-
|
|
636
|
+
renderEditor(<PatronBlockingRulesEditor value={[]} />);
|
|
609
637
|
|
|
610
638
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
611
639
|
|
|
@@ -614,7 +642,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
614
642
|
|
|
615
643
|
it("re-enables Add Rule button once all required fields are filled", async () => {
|
|
616
644
|
const user = userEvent.setup();
|
|
617
|
-
|
|
645
|
+
renderEditor(<PatronBlockingRulesEditor value={[]} />);
|
|
618
646
|
|
|
619
647
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
620
648
|
expect(screen.getByRole("button", { name: /Add Rule/i })).toBeDisabled();
|
|
@@ -637,7 +665,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
637
665
|
{ name: "Rule B", rule: "expr_b" },
|
|
638
666
|
];
|
|
639
667
|
|
|
640
|
-
|
|
668
|
+
renderEditor(<PatronBlockingRulesEditor value={rules} serviceId={42} />);
|
|
641
669
|
|
|
642
670
|
// Rename rule B to match rule A
|
|
643
671
|
const nameInputs = screen.getAllByLabelText(
|
|
@@ -658,7 +686,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
658
686
|
{ name: "Rule A", rule: "expr_b" }, // duplicate
|
|
659
687
|
];
|
|
660
688
|
|
|
661
|
-
|
|
689
|
+
renderEditor(<PatronBlockingRulesEditor value={rules} serviceId={42} />);
|
|
662
690
|
|
|
663
691
|
// Both rows should start with the duplicate error
|
|
664
692
|
expect(screen.getAllByText(/Rule Name must be unique/i)).toHaveLength(2);
|
|
@@ -681,13 +709,13 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
681
709
|
response: JSON.stringify({ detail: "Internal server error" }),
|
|
682
710
|
url: "",
|
|
683
711
|
};
|
|
684
|
-
|
|
712
|
+
renderEditor(<PatronBlockingRulesEditor value={[]} error={error} />);
|
|
685
713
|
expect(screen.getByText(/Internal server error/i)).toBeTruthy();
|
|
686
714
|
});
|
|
687
715
|
|
|
688
716
|
it("getValue does not include internal _id field in returned rules", () => {
|
|
689
717
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
690
|
-
|
|
718
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={existingRules} />);
|
|
691
719
|
const value = ref.current.getValue();
|
|
692
720
|
value.forEach((rule) => {
|
|
693
721
|
expect(rule).not.toHaveProperty("_id");
|
|
@@ -696,7 +724,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
696
724
|
|
|
697
725
|
it("hides the 'no rules' message once a rule is added", async () => {
|
|
698
726
|
const user = userEvent.setup();
|
|
699
|
-
|
|
727
|
+
renderEditor(<PatronBlockingRulesEditor value={[]} />);
|
|
700
728
|
expect(screen.getByText(/No patron blocking rules defined/i)).toBeTruthy();
|
|
701
729
|
|
|
702
730
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
@@ -707,7 +735,7 @@ describe("PatronBlockingRulesEditor", () => {
|
|
|
707
735
|
|
|
708
736
|
describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
|
|
709
737
|
beforeEach(() => {
|
|
710
|
-
fetchMock.post(VALIDATE_URL,
|
|
738
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
711
739
|
});
|
|
712
740
|
|
|
713
741
|
afterEach(() => {
|
|
@@ -716,7 +744,7 @@ describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
|
|
|
716
744
|
|
|
717
745
|
it("returns all rules (stripped of _id) when every rule has name and expression", () => {
|
|
718
746
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
719
|
-
|
|
747
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={existingRules} />);
|
|
720
748
|
|
|
721
749
|
let result: PatronBlockingRule[] | null;
|
|
722
750
|
act(() => {
|
|
@@ -736,7 +764,7 @@ describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
|
|
|
736
764
|
it("returns null and shows a name error when a rule is missing its name", async () => {
|
|
737
765
|
const user = userEvent.setup();
|
|
738
766
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
739
|
-
|
|
767
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={[]} />);
|
|
740
768
|
|
|
741
769
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
742
770
|
// Leave name empty, fill only the expression
|
|
@@ -755,7 +783,7 @@ describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
|
|
|
755
783
|
it("returns null and shows an expression error when a rule is missing its expression", async () => {
|
|
756
784
|
const user = userEvent.setup();
|
|
757
785
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
758
|
-
|
|
786
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={[]} />);
|
|
759
787
|
|
|
760
788
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
761
789
|
// Fill only the name, leave expression empty
|
|
@@ -774,7 +802,7 @@ describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
|
|
|
774
802
|
it("returns null and shows both errors when a rule has neither name nor expression", async () => {
|
|
775
803
|
const user = userEvent.setup();
|
|
776
804
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
777
|
-
|
|
805
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={[]} />);
|
|
778
806
|
|
|
779
807
|
// Add rule but leave both fields empty
|
|
780
808
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
@@ -792,7 +820,7 @@ describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
|
|
|
792
820
|
it("clears prior client errors on a subsequent call that succeeds", async () => {
|
|
793
821
|
const user = userEvent.setup();
|
|
794
822
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
795
|
-
|
|
823
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={[]} />);
|
|
796
824
|
|
|
797
825
|
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
798
826
|
|
|
@@ -821,7 +849,7 @@ describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
|
|
|
821
849
|
|
|
822
850
|
it("returns an empty array (not null) when there are no rules at all", () => {
|
|
823
851
|
const ref = React.createRef<PatronBlockingRulesEditorHandle>();
|
|
824
|
-
|
|
852
|
+
renderEditor(<PatronBlockingRulesEditor ref={ref} value={[]} />);
|
|
825
853
|
|
|
826
854
|
let result: PatronBlockingRule[] | null;
|
|
827
855
|
act(() => {
|
|
@@ -831,3 +859,163 @@ describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
|
|
|
831
859
|
expect(result).toEqual([]);
|
|
832
860
|
});
|
|
833
861
|
});
|
|
862
|
+
|
|
863
|
+
describe("PatronBlockingRulesEditor — help modal and available fields prefetch", () => {
|
|
864
|
+
afterEach(() => {
|
|
865
|
+
fetchMock.mockReset();
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("renders a Help button in the header", () => {
|
|
869
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
870
|
+
renderEditor(
|
|
871
|
+
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
872
|
+
);
|
|
873
|
+
expect(
|
|
874
|
+
screen.getByRole("button", { name: /patron blocking rules help/i })
|
|
875
|
+
).toBeTruthy();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("prefetches available fields on mount when serviceId is provided", async () => {
|
|
879
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
880
|
+
renderEditor(
|
|
881
|
+
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
882
|
+
);
|
|
883
|
+
// The prefetch call is fired on mount.
|
|
884
|
+
await waitFor(() =>
|
|
885
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
886
|
+
VALIDATE_URL,
|
|
887
|
+
expect.objectContaining({ method: "POST" })
|
|
888
|
+
)
|
|
889
|
+
);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("opens the help modal when the Help button is clicked", async () => {
|
|
893
|
+
const user = userEvent.setup();
|
|
894
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
895
|
+
renderEditor(
|
|
896
|
+
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
await user.click(
|
|
900
|
+
screen.getByRole("button", { name: /patron blocking rules help/i })
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
expect(screen.getByText(/Patron Blocking Rules — Help/i)).toBeTruthy();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("shows available fields in the help modal after a successful prefetch", async () => {
|
|
907
|
+
const user = userEvent.setup();
|
|
908
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
909
|
+
renderEditor(
|
|
910
|
+
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
// Wait for the prefetch to settle
|
|
914
|
+
await waitFor(() =>
|
|
915
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
916
|
+
VALIDATE_URL,
|
|
917
|
+
expect.objectContaining({ method: "POST" })
|
|
918
|
+
)
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
await user.click(
|
|
922
|
+
screen.getByRole("button", { name: /patron blocking rules help/i })
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
// Field names and sample values should appear in the modal table
|
|
926
|
+
await waitFor(() => {
|
|
927
|
+
expect(screen.getByText("fines")).toBeTruthy();
|
|
928
|
+
expect(screen.getByText("2.50")).toBeTruthy();
|
|
929
|
+
expect(screen.getByText("patron_identifier")).toBeTruthy();
|
|
930
|
+
expect(screen.getByText("12345")).toBeTruthy();
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("shows an unavailability message when serviceId is not provided", async () => {
|
|
935
|
+
const user = userEvent.setup();
|
|
936
|
+
// No serviceId → prefetch skipped; no fetch call expected.
|
|
937
|
+
renderEditor(<PatronBlockingRulesEditor value={[]} csrfToken="tok" />);
|
|
938
|
+
|
|
939
|
+
await user.click(
|
|
940
|
+
screen.getByRole("button", { name: /patron blocking rules help/i })
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
expect(
|
|
944
|
+
screen.getByText(
|
|
945
|
+
/Save the service before template variables can be fetched/i
|
|
946
|
+
)
|
|
947
|
+
).toBeTruthy();
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("shows an error message when the prefetch fails with a 400", async () => {
|
|
951
|
+
const user = userEvent.setup();
|
|
952
|
+
fetchMock.post(VALIDATE_URL, {
|
|
953
|
+
status: 400,
|
|
954
|
+
body: { detail: "Patron auth service not found." },
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
renderEditor(
|
|
958
|
+
<PatronBlockingRulesEditor value={[]} serviceId={99} csrfToken="tok" />
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
await user.click(
|
|
962
|
+
screen.getByRole("button", { name: /patron blocking rules help/i })
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
await waitFor(() =>
|
|
966
|
+
expect(screen.getByText(/Patron auth service not found/i)).toBeTruthy()
|
|
967
|
+
);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("updates available fields after a successful blur validation", async () => {
|
|
971
|
+
const user = userEvent.setup();
|
|
972
|
+
const updatedFields = { fines: "5.00", new_field: "hello" };
|
|
973
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
974
|
+
|
|
975
|
+
renderEditor(
|
|
976
|
+
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
// Add a rule and blur the expression field with the updated-fields mock
|
|
980
|
+
await user.click(screen.getByRole("button", { name: /Add Rule/i }));
|
|
981
|
+
await user.type(screen.getByLabelText(/Rule Name/i), "Test Rule");
|
|
982
|
+
|
|
983
|
+
fetchMock.mockReset();
|
|
984
|
+
fetchMock.post(VALIDATE_URL, {
|
|
985
|
+
status: 200,
|
|
986
|
+
body: { available_fields: updatedFields },
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0");
|
|
990
|
+
await user.tab();
|
|
991
|
+
|
|
992
|
+
// Open help modal and verify updated fields are shown
|
|
993
|
+
await user.click(
|
|
994
|
+
screen.getByRole("button", { name: /patron blocking rules help/i })
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
await waitFor(() => {
|
|
998
|
+
expect(screen.getByText("new_field")).toBeTruthy();
|
|
999
|
+
expect(screen.getByText("hello")).toBeTruthy();
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it("closes the help modal when the close button is clicked", async () => {
|
|
1004
|
+
const user = userEvent.setup();
|
|
1005
|
+
fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE);
|
|
1006
|
+
renderEditor(
|
|
1007
|
+
<PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
await user.click(
|
|
1011
|
+
screen.getByRole("button", { name: /patron blocking rules help/i })
|
|
1012
|
+
);
|
|
1013
|
+
expect(screen.getByText(/Patron Blocking Rules — Help/i)).toBeTruthy();
|
|
1014
|
+
|
|
1015
|
+
// Close via the modal's × button (first close-named button; footer Close is last).
|
|
1016
|
+
await user.click(screen.getAllByRole("button", { name: /close/i })[0]);
|
|
1017
|
+
await waitFor(() =>
|
|
1018
|
+
expect(screen.queryByText(/Patron Blocking Rules — Help/i)).toBeNull()
|
|
1019
|
+
);
|
|
1020
|
+
});
|
|
1021
|
+
});
|