@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.
@@ -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, { status: 200 });
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
- render(
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
- render(
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
- render(
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
- render(
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
- render(
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
- render(
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
- render(
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
- render(
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
- render(
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
- render(
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, { status: 200 });
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
- render(
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
- render(
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
- render(
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
- render(
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
- render(<PatronBlockingRulesEditor value={[]} csrfToken="tok" />);
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
- render(
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
- render(
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, { status: 200 });
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, { status: 200 });
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
- render(<PatronBlockingRulesEditor />);
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
- render(<PatronBlockingRulesEditor value={existingRules} />);
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
- render(<PatronBlockingRulesEditor value={[]} />);
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
- render(<PatronBlockingRulesEditor value={existingRules} />);
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
- render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
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
- render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
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
- render(<PatronBlockingRulesEditor value={existingRules} disabled={true} />);
614
+ it("disables all editing inputs and buttons when disabled prop is true", () => {
615
+ renderEditor(<PatronBlockingRulesEditor value={existingRules} disabled={true} />);
593
616
 
594
- const buttons = screen.getAllByRole("button");
595
- buttons.forEach((btn) => expect(btn).toBeDisabled());
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
- render(<PatronBlockingRulesEditor value={existingRules} />);
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
- render(<PatronBlockingRulesEditor value={[]} />);
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
- render(<PatronBlockingRulesEditor value={[]} />);
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
- render(<PatronBlockingRulesEditor value={rules} serviceId={42} />);
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
- render(<PatronBlockingRulesEditor value={rules} serviceId={42} />);
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
- render(<PatronBlockingRulesEditor value={[]} error={error} />);
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
- render(<PatronBlockingRulesEditor ref={ref} value={existingRules} />);
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
- render(<PatronBlockingRulesEditor value={[]} />);
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, { status: 200 });
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
- render(<PatronBlockingRulesEditor ref={ref} value={existingRules} />);
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
- render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
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
- render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
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
- render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
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
- render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
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
- render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
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
+ });