@thepalaceproject/circulation-admin 1.43.0-post.15 → 1.43.0-post.17
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/package.json +1 -1
- package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
- package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
- package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
- package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
- package/tests/jest/components/EditableInput.test.tsx +250 -1
- package/tests/jest/components/Header.test.tsx +421 -0
- package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
- package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
- package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
- package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
- package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
- package/tests/jest/components/ToolTip.test.tsx +46 -0
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { installFormDataShim } from "../testUtils/formDataShim";
|
|
3
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
3
5
|
import { DiscoveryServices } from "../../../src/components/DiscoveryServices";
|
|
6
|
+
import DiscoveryServicesConnected from "../../../src/components/DiscoveryServices";
|
|
4
7
|
import renderWithContext from "../testUtils/renderWithContext";
|
|
8
|
+
import { renderWithProviders } from "../testUtils/withProviders";
|
|
9
|
+
import buildStore from "../../../src/store";
|
|
5
10
|
import {
|
|
6
11
|
ConfigurationSettings,
|
|
7
12
|
DiscoveryServicesData,
|
|
8
13
|
} from "../../../src/interfaces";
|
|
9
14
|
import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
|
|
10
15
|
|
|
11
|
-
// NB: This adds tests to the already existing tests in:
|
|
12
|
-
// - `src/components/__tests__/DiscoveryServices-test.tsx`.
|
|
13
|
-
//
|
|
14
|
-
// Those tests should eventually be migrated here and
|
|
15
|
-
// adapted to the Jest/React Testing Library paradigm.
|
|
16
|
-
|
|
17
16
|
describe("DiscoveryServices - registered library disclosure", () => {
|
|
18
17
|
// ── Shared fixtures ───────────────────────────────────────────────────────
|
|
19
18
|
|
|
@@ -543,3 +542,151 @@ describe("DiscoveryServices - registered library disclosure", () => {
|
|
|
543
542
|
expect(container.querySelectorAll(".associated-items")).toHaveLength(0);
|
|
544
543
|
});
|
|
545
544
|
});
|
|
545
|
+
|
|
546
|
+
// These exercise behavior the disclosure tests above do not: the connect()
|
|
547
|
+
// wiring (mapStateToProps / mapDispatchToProps, which only run when the CONNECTED
|
|
548
|
+
// default export mounts) and the getChildContext().registerLibrary closure
|
|
549
|
+
// (which only runs when a library is registered from the edit form).
|
|
550
|
+
|
|
551
|
+
describe("DiscoveryServices - connected wiring and registration", () => {
|
|
552
|
+
const allLibraries = [
|
|
553
|
+
{ short_name: "nypl", name: "NYPL", uuid: "uuid-nypl" },
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
const sysAdminConfig: Partial<ConfigurationSettings> = {
|
|
557
|
+
csrfToken: "",
|
|
558
|
+
featureFlags: defaultFeatureFlags,
|
|
559
|
+
roles: [{ role: "system" }],
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// A loaded `libraries` slice persists through the on-mount discovery fetches
|
|
563
|
+
// (the libraries reducer ignores those actions), so mapStateToProps takes its
|
|
564
|
+
// `data.allLibraries` branch on every render.
|
|
565
|
+
const loadedLibrariesState = {
|
|
566
|
+
data: { libraries: allLibraries },
|
|
567
|
+
isFetching: false,
|
|
568
|
+
isEditing: false,
|
|
569
|
+
fetchError: null,
|
|
570
|
+
formError: null,
|
|
571
|
+
isLoaded: true,
|
|
572
|
+
responseBody: null,
|
|
573
|
+
successMessage: null,
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Stub `fetch` so the panel's on-mount fetches resolve with `body`. A fresh
|
|
578
|
+
* Response per call avoids "body already used" when several fetches run.
|
|
579
|
+
*/
|
|
580
|
+
const stubFetch = (body: unknown) =>
|
|
581
|
+
jest.spyOn(globalThis, "fetch").mockImplementation(
|
|
582
|
+
async () =>
|
|
583
|
+
new Response(JSON.stringify(body), {
|
|
584
|
+
status: 200,
|
|
585
|
+
headers: { "Content-Type": "application/json" },
|
|
586
|
+
})
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
afterEach(() => {
|
|
590
|
+
jest.restoreAllMocks();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe("connected default export", () => {
|
|
594
|
+
it("wires mapStateToProps/mapDispatchToProps: fetches on mount and renders the list", async () => {
|
|
595
|
+
const listData = {
|
|
596
|
+
discovery_services: [
|
|
597
|
+
{ id: 2, protocol: "test protocol", name: "Test Discovery" },
|
|
598
|
+
],
|
|
599
|
+
protocols: [{ name: "test protocol", label: "TP", settings: [] }],
|
|
600
|
+
};
|
|
601
|
+
stubFetch(listData);
|
|
602
|
+
// A fresh store, preloaded with a loaded libraries slice, isolates this
|
|
603
|
+
// test and exercises mapStateToProps' allLibraries branch.
|
|
604
|
+
const store = buildStore({
|
|
605
|
+
editor: { libraries: loadedLibrariesState },
|
|
606
|
+
} as any);
|
|
607
|
+
|
|
608
|
+
const { container } = renderWithProviders(
|
|
609
|
+
<DiscoveryServicesConnected csrfToken="token" />,
|
|
610
|
+
{ reduxProviderProps: { store } }
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
// The list appears once the connected component's on-mount fetch resolves
|
|
614
|
+
// and mapStateToProps feeds the fetched data back in as props.
|
|
615
|
+
expect(
|
|
616
|
+
await screen.findByRole("heading", { level: 3, name: "Test Discovery" })
|
|
617
|
+
).toBeInTheDocument();
|
|
618
|
+
|
|
619
|
+
const editLink = container.querySelector("a.edit-item");
|
|
620
|
+
expect(editLink).not.toBeNull();
|
|
621
|
+
expect(editLink.getAttribute("href")).toBe(
|
|
622
|
+
"/admin/web/config/discovery/edit/2"
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe("registerLibrary child context (edit mode)", () => {
|
|
628
|
+
// The reusable-components `Form` builds `new FormData(formElement)` on
|
|
629
|
+
// submit, which the unit jsdom env's undici FormData rejects; install the
|
|
630
|
+
// shared shim that reads the form's successful controls.
|
|
631
|
+
installFormDataShim();
|
|
632
|
+
const renderEditMode = (
|
|
633
|
+
registerLibrary: jest.Mock,
|
|
634
|
+
fetchLibraryRegistrations: jest.Mock
|
|
635
|
+
) =>
|
|
636
|
+
renderWithContext(
|
|
637
|
+
<DiscoveryServices
|
|
638
|
+
data={
|
|
639
|
+
{
|
|
640
|
+
discovery_services: [
|
|
641
|
+
{ id: "2", protocol: "test protocol", name: "Test Discovery" },
|
|
642
|
+
],
|
|
643
|
+
protocols: [
|
|
644
|
+
{
|
|
645
|
+
name: "test protocol",
|
|
646
|
+
supports_registration: true,
|
|
647
|
+
settings: [],
|
|
648
|
+
library_settings: [],
|
|
649
|
+
},
|
|
650
|
+
],
|
|
651
|
+
allLibraries,
|
|
652
|
+
} as any
|
|
653
|
+
}
|
|
654
|
+
editOrCreate="edit"
|
|
655
|
+
identifier="2"
|
|
656
|
+
fetchData={jest.fn()}
|
|
657
|
+
editItem={jest.fn().mockResolvedValue(undefined)}
|
|
658
|
+
deleteItem={jest.fn().mockResolvedValue(undefined)}
|
|
659
|
+
registerLibrary={registerLibrary}
|
|
660
|
+
fetchLibraryRegistrations={fetchLibraryRegistrations}
|
|
661
|
+
csrfToken="token"
|
|
662
|
+
isFetching={false}
|
|
663
|
+
/>,
|
|
664
|
+
sysAdminConfig
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
it("fetches library registrations on mount, and registers a library (building the right FormData) and refetches on click", async () => {
|
|
668
|
+
const user = userEvent.setup();
|
|
669
|
+
const registerLibrary = jest.fn().mockResolvedValue(undefined);
|
|
670
|
+
const fetchLibraryRegistrations = jest.fn().mockResolvedValue(undefined);
|
|
671
|
+
renderEditMode(registerLibrary, fetchLibraryRegistrations);
|
|
672
|
+
|
|
673
|
+
// getChildContext supplies registerLibrary to the edit form; the edit form
|
|
674
|
+
// surfaces a "Register" button for each library.
|
|
675
|
+
expect(fetchLibraryRegistrations).toHaveBeenCalledTimes(1);
|
|
676
|
+
expect(registerLibrary).not.toHaveBeenCalled();
|
|
677
|
+
|
|
678
|
+
await user.click(screen.getByRole("button", { name: "Register" }));
|
|
679
|
+
|
|
680
|
+
expect(registerLibrary).toHaveBeenCalledTimes(1);
|
|
681
|
+
const formData = registerLibrary.mock.calls[0][0];
|
|
682
|
+
expect(formData.get("library_short_name")).toBe("nypl");
|
|
683
|
+
expect(formData.get("integration_id")).toBe("2");
|
|
684
|
+
expect(formData.get("registration_stage")).toBe("testing");
|
|
685
|
+
|
|
686
|
+
// After a successful registration, the child context refetches.
|
|
687
|
+
await waitFor(() =>
|
|
688
|
+
expect(fetchLibraryRegistrations).toHaveBeenCalledTimes(2)
|
|
689
|
+
);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { render, screen, act, fireEvent } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
3
4
|
import EditableInput from "../../../src/components/EditableInput";
|
|
4
5
|
|
|
5
6
|
describe("EditableInput", () => {
|
|
@@ -62,3 +63,251 @@ describe("EditableInput", () => {
|
|
|
62
63
|
expect(textboxes[5]).toHaveAccessibleDescription("");
|
|
63
64
|
});
|
|
64
65
|
});
|
|
66
|
+
|
|
67
|
+
// Value/change handling, the prop-sync in componentWillReceiveProps, and the
|
|
68
|
+
// imperative ref API (getValue/getChecked/setValue/clear) that parents like
|
|
69
|
+
// ProtocolFormField use.
|
|
70
|
+
describe("EditableInput - value, change, and imperative API", () => {
|
|
71
|
+
it("updates as the user types and reports the value via getValue()", async () => {
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
const onChange = jest.fn();
|
|
74
|
+
const ref = React.createRef<EditableInput>();
|
|
75
|
+
render(
|
|
76
|
+
<EditableInput ref={ref} label="Field" value="" onChange={onChange} />
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const input = screen.getByRole("textbox", { name: "Field" });
|
|
80
|
+
await user.type(input, "hello");
|
|
81
|
+
|
|
82
|
+
expect(input).toHaveValue("hello");
|
|
83
|
+
expect(ref.current?.getValue()).toBe("hello");
|
|
84
|
+
expect(onChange).toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("toggles a checkbox and reports it via getChecked()", async () => {
|
|
88
|
+
const user = userEvent.setup();
|
|
89
|
+
const ref = React.createRef<EditableInput>();
|
|
90
|
+
render(
|
|
91
|
+
<EditableInput ref={ref} type="checkbox" label="Check" checked={false} />
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const box = screen.getByRole("checkbox", { name: "Check" });
|
|
95
|
+
await user.click(box);
|
|
96
|
+
|
|
97
|
+
expect(box).toBeChecked();
|
|
98
|
+
expect(ref.current?.getChecked()).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not fire onChange when readOnly", () => {
|
|
102
|
+
const onChange = jest.fn();
|
|
103
|
+
render(<EditableInput label="RO" value="x" readOnly onChange={onChange} />);
|
|
104
|
+
|
|
105
|
+
// `userEvent.type` honors the DOM's read-only semantics and never dispatches
|
|
106
|
+
// a change event, so it would never reach handleChange. Dispatch directly, so
|
|
107
|
+
// the component's own readOnly guard is what stops onChange.
|
|
108
|
+
fireEvent.change(screen.getByRole("textbox", { name: "RO" }), {
|
|
109
|
+
target: { value: "y" },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("syncs its value when the value prop changes", () => {
|
|
116
|
+
const { rerender } = render(<EditableInput label="F" value="a" />);
|
|
117
|
+
const input = screen.getByRole("textbox", { name: "F" });
|
|
118
|
+
expect(input).toHaveValue("a");
|
|
119
|
+
|
|
120
|
+
rerender(<EditableInput label="F" value="b" />);
|
|
121
|
+
expect(input).toHaveValue("b");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("syncs its checked state when the checked prop changes", () => {
|
|
125
|
+
const { rerender } = render(
|
|
126
|
+
<EditableInput type="checkbox" label="C" checked={false} />
|
|
127
|
+
);
|
|
128
|
+
const box = screen.getByRole("checkbox", { name: "C" });
|
|
129
|
+
expect(box).not.toBeChecked();
|
|
130
|
+
|
|
131
|
+
rerender(<EditableInput type="checkbox" label="C" checked={true} />);
|
|
132
|
+
expect(box).toBeChecked();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("setValue and clear update the value imperatively", () => {
|
|
136
|
+
const ref = React.createRef<EditableInput>();
|
|
137
|
+
render(<EditableInput ref={ref} label="F" value="a" />);
|
|
138
|
+
const input = screen.getByRole("textbox", { name: "F" });
|
|
139
|
+
|
|
140
|
+
act(() => ref.current?.setValue("z"));
|
|
141
|
+
expect(input).toHaveValue("z");
|
|
142
|
+
|
|
143
|
+
act(() => ref.current?.clear());
|
|
144
|
+
expect(input).toHaveValue("");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("clear also resets a checked box", () => {
|
|
148
|
+
const ref = React.createRef<EditableInput>();
|
|
149
|
+
render(
|
|
150
|
+
<EditableInput ref={ref} type="checkbox" label="C" checked={true} />
|
|
151
|
+
);
|
|
152
|
+
const box = screen.getByRole("checkbox", { name: "C" });
|
|
153
|
+
expect(box).toBeChecked();
|
|
154
|
+
|
|
155
|
+
act(() => ref.current?.clear());
|
|
156
|
+
expect(box).not.toBeChecked();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// A falsy-but-valid value must survive: this is what defaultValueIfMissing
|
|
160
|
+
// guards, and what a naive `props.value || ""` would silently break.
|
|
161
|
+
it("keeps a zero value rather than falling back to the empty default", () => {
|
|
162
|
+
const { rerender } = render(<EditableInput label="F" value={0} />);
|
|
163
|
+
const input = screen.getByRole("textbox", { name: "F" });
|
|
164
|
+
expect(input).toHaveValue("0");
|
|
165
|
+
|
|
166
|
+
rerender(<EditableInput label="F" value="a" />);
|
|
167
|
+
expect(input).toHaveValue("a");
|
|
168
|
+
|
|
169
|
+
rerender(<EditableInput label="F" value={0} />);
|
|
170
|
+
expect(input).toHaveValue("0");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("EditableInput - labels, children, and pass-through props", () => {
|
|
175
|
+
it("labels a radio input and toggles it", async () => {
|
|
176
|
+
const user = userEvent.setup();
|
|
177
|
+
const ref = React.createRef<EditableInput>();
|
|
178
|
+
render(
|
|
179
|
+
<EditableInput
|
|
180
|
+
ref={ref}
|
|
181
|
+
type="radio"
|
|
182
|
+
label="Radio label"
|
|
183
|
+
name="r"
|
|
184
|
+
checked={false}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const radio = screen.getByRole("radio", { name: "Radio label" });
|
|
189
|
+
expect(radio).not.toBeChecked();
|
|
190
|
+
|
|
191
|
+
await user.click(radio);
|
|
192
|
+
|
|
193
|
+
expect(radio).toBeChecked();
|
|
194
|
+
expect(ref.current?.getChecked()).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("renders children into the element", () => {
|
|
198
|
+
render(
|
|
199
|
+
<EditableInput elementType="select" label="S" name="s" value="b">
|
|
200
|
+
<option value="a">option a</option>
|
|
201
|
+
<option value="b">option b</option>
|
|
202
|
+
</EditableInput>
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const select = screen.getByRole("combobox", { name: "S" });
|
|
206
|
+
expect(select).toHaveValue("b");
|
|
207
|
+
expect(
|
|
208
|
+
Array.from(select.querySelectorAll("option")).map((o) => o.textContent)
|
|
209
|
+
).toEqual(["option a", "option b"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("forwards the disabled prop to the element", () => {
|
|
213
|
+
render(<EditableInput label="F" name="name" disabled={true} value="v" />);
|
|
214
|
+
expect(screen.getByRole("textbox", { name: "F" })).toBeDisabled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// renderDescription uses dangerouslySetInnerHTML, and callers pass markup.
|
|
218
|
+
it("renders the description as HTML", () => {
|
|
219
|
+
const { container } = render(
|
|
220
|
+
<EditableInput
|
|
221
|
+
label="F"
|
|
222
|
+
optionalText={false}
|
|
223
|
+
description="<p>description</p>"
|
|
224
|
+
/>
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const description = container.querySelector(".description");
|
|
228
|
+
expect(description.innerHTML).toContain("<p>description</p>");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("shows extra content in an add-on wrapper only when supplied", () => {
|
|
232
|
+
const { container, rerender } = render(
|
|
233
|
+
<EditableInput label="F" name="name" value="v" />
|
|
234
|
+
);
|
|
235
|
+
expect(container.querySelector(".with-add-on")).toBeNull();
|
|
236
|
+
|
|
237
|
+
rerender(
|
|
238
|
+
<EditableInput
|
|
239
|
+
label="F"
|
|
240
|
+
name="name"
|
|
241
|
+
value="v"
|
|
242
|
+
extraContent={<span>Extra content!</span>}
|
|
243
|
+
/>
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const extra = container.querySelector(".with-add-on");
|
|
247
|
+
expect(extra).not.toBeNull();
|
|
248
|
+
expect(extra).toHaveTextContent("Extra content!");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// The error styling is a compound predicate:
|
|
253
|
+
// clientError || (error && error.status >= 400 && !value && required)
|
|
254
|
+
describe("EditableInput - error styling", () => {
|
|
255
|
+
const formGroup = (container: HTMLElement) =>
|
|
256
|
+
container.querySelector(".form-group");
|
|
257
|
+
|
|
258
|
+
it("applies no error class by default", () => {
|
|
259
|
+
const { container } = render(
|
|
260
|
+
<EditableInput label="F" name="name" value="initial value" />
|
|
261
|
+
);
|
|
262
|
+
expect(formGroup(container)).not.toHaveClass("field-error");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("applies the error class on a client error", () => {
|
|
266
|
+
const { container } = render(
|
|
267
|
+
<EditableInput
|
|
268
|
+
label="F"
|
|
269
|
+
name="name"
|
|
270
|
+
value="initial value"
|
|
271
|
+
clientError={true}
|
|
272
|
+
/>
|
|
273
|
+
);
|
|
274
|
+
expect(formGroup(container)).toHaveClass("field-error");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("applies the error class for a blank required field after a server error", () => {
|
|
278
|
+
const { container } = render(
|
|
279
|
+
<EditableInput
|
|
280
|
+
label="F"
|
|
281
|
+
name="name"
|
|
282
|
+
error={{ status: 400, response: "", url: "" }}
|
|
283
|
+
required={true}
|
|
284
|
+
/>
|
|
285
|
+
);
|
|
286
|
+
expect(formGroup(container)).toHaveClass("field-error");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("does not apply the error class when the required field has a value", () => {
|
|
290
|
+
const { container } = render(
|
|
291
|
+
<EditableInput
|
|
292
|
+
label="F"
|
|
293
|
+
name="name"
|
|
294
|
+
value="a value"
|
|
295
|
+
error={{ status: 400, response: "", url: "" }}
|
|
296
|
+
required={true}
|
|
297
|
+
/>
|
|
298
|
+
);
|
|
299
|
+
expect(formGroup(container)).not.toHaveClass("field-error");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("does not apply the error class for a server error on an optional field", () => {
|
|
303
|
+
const { container } = render(
|
|
304
|
+
<EditableInput
|
|
305
|
+
label="F"
|
|
306
|
+
name="name"
|
|
307
|
+
error={{ status: 400, response: "", url: "" }}
|
|
308
|
+
required={false}
|
|
309
|
+
/>
|
|
310
|
+
);
|
|
311
|
+
expect(formGroup(container)).not.toHaveClass("field-error");
|
|
312
|
+
});
|
|
313
|
+
});
|