@thepalaceproject/circulation-admin 1.43.0-post.14 → 1.43.0-post.16
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/Collections.test.tsx +335 -7
- package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
- package/tests/jest/components/EditableConfigList.test.tsx +598 -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/ProtocolFormField.test.tsx +443 -7
- package/tests/jest/components/SelfTests.test.tsx +601 -0
- package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
- package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
- package/tests/jest/components/ToolTip.test.tsx +46 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as PropTypes from "prop-types";
|
|
3
|
+
import { render, screen, within, waitFor } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import { Provider } from "react-redux";
|
|
6
|
+
|
|
7
|
+
import { Header } from "../../../src/components/Header";
|
|
8
|
+
import HeaderWithStore from "../../../src/components/Header";
|
|
9
|
+
import Admin from "../../../src/models/Admin";
|
|
10
|
+
import title from "../../../src/utils/title";
|
|
11
|
+
import buildStore from "../../../src/store";
|
|
12
|
+
|
|
13
|
+
// Render react-router's Link as a marker `<div>` so we can read its target
|
|
14
|
+
// (`data-to`) and text without a real Router in context. (A bare `<a>` without an
|
|
15
|
+
// href would fail jsx-a11y, so use a div.)
|
|
16
|
+
jest.mock("react-router", () => ({
|
|
17
|
+
...jest.requireActual("react-router"),
|
|
18
|
+
Link: (props) => (
|
|
19
|
+
<div data-testid="Link" data-to={props.to} className={props.className}>
|
|
20
|
+
{props.children}
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Header reads `library`, `router`, and `admin` from the legacy React context that
|
|
26
|
+
// the OPDS web client supplies in production. Supply them via a small legacy
|
|
27
|
+
// context provider (also carrying the `editorStore`/`csrfToken` the connected
|
|
28
|
+
// default export needs).
|
|
29
|
+
class LegacyContextProvider extends React.Component<{ context: any }> {
|
|
30
|
+
static childContextTypes = {
|
|
31
|
+
library: PropTypes.func,
|
|
32
|
+
router: PropTypes.object,
|
|
33
|
+
admin: PropTypes.object,
|
|
34
|
+
editorStore: PropTypes.object,
|
|
35
|
+
csrfToken: PropTypes.string,
|
|
36
|
+
};
|
|
37
|
+
getChildContext() {
|
|
38
|
+
return this.props.context;
|
|
39
|
+
}
|
|
40
|
+
render() {
|
|
41
|
+
return this.props.children;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const makeRouter = () => ({
|
|
46
|
+
createHref: jest.fn(),
|
|
47
|
+
push: jest.fn(),
|
|
48
|
+
isActive: jest.fn(),
|
|
49
|
+
replace: jest.fn(),
|
|
50
|
+
go: jest.fn(),
|
|
51
|
+
goBack: jest.fn(),
|
|
52
|
+
goForward: jest.fn(),
|
|
53
|
+
setRouteLeaveHook: jest.fn(),
|
|
54
|
+
getCurrentLocation: jest.fn(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const libraryManager = new Admin([{ role: "manager", library: "nypl" }]);
|
|
58
|
+
const librarian = new Admin([{ role: "librarian", library: "nypl" }]);
|
|
59
|
+
const systemAdmin = new Admin([{ role: "system", library: "nypl" }]);
|
|
60
|
+
|
|
61
|
+
type RenderHeaderOptions = {
|
|
62
|
+
// Pass `null` to model an absent `library` in context (a destructuring default
|
|
63
|
+
// cannot distinguish an omitted key from an explicit `undefined`, so `null` is
|
|
64
|
+
// the sentinel for "no library selected"). Header treats null/undefined alike.
|
|
65
|
+
library?: (() => string) | null;
|
|
66
|
+
admin?: Admin;
|
|
67
|
+
router?: ReturnType<typeof makeRouter>;
|
|
68
|
+
props?: Record<string, unknown>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const renderHeader = ({
|
|
72
|
+
library = () => "nypl",
|
|
73
|
+
admin = libraryManager,
|
|
74
|
+
router = makeRouter(),
|
|
75
|
+
props = {},
|
|
76
|
+
}: RenderHeaderOptions = {}) =>
|
|
77
|
+
render(
|
|
78
|
+
<LegacyContextProvider context={{ library, admin, router }}>
|
|
79
|
+
<Header {...props} />
|
|
80
|
+
</LegacyContextProvider>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
describe("Header", () => {
|
|
84
|
+
describe("rendering", () => {
|
|
85
|
+
it("renders the site title as an h1", () => {
|
|
86
|
+
renderHeader();
|
|
87
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
88
|
+
title()
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("shows the brand image", () => {
|
|
93
|
+
renderHeader();
|
|
94
|
+
expect(screen.getByAltText(title())).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("shows no library dropdown when there are no libraries", () => {
|
|
98
|
+
renderHeader();
|
|
99
|
+
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("shows a library dropdown listing the available libraries", () => {
|
|
103
|
+
const libraries = [
|
|
104
|
+
{ short_name: "nypl", name: "NYPL" },
|
|
105
|
+
{ short_name: "bpl" },
|
|
106
|
+
];
|
|
107
|
+
renderHeader({ props: { libraries } });
|
|
108
|
+
|
|
109
|
+
const select = screen.getByRole("combobox");
|
|
110
|
+
expect(select).toHaveValue("nypl");
|
|
111
|
+
|
|
112
|
+
const options = within(select).getAllByRole("option");
|
|
113
|
+
expect(options).toHaveLength(2);
|
|
114
|
+
expect(options[0]).toHaveTextContent("NYPL");
|
|
115
|
+
expect(options[0]).toHaveValue("nypl");
|
|
116
|
+
expect(options[1]).toHaveTextContent("bpl");
|
|
117
|
+
expect(options[1]).toHaveValue("bpl");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("reflects the selected library as the dropdown value", () => {
|
|
121
|
+
const libraries = [
|
|
122
|
+
{ short_name: "nypl", name: "NYPL" },
|
|
123
|
+
{ short_name: "bpl" },
|
|
124
|
+
];
|
|
125
|
+
renderHeader({ library: () => "bpl", props: { libraries } });
|
|
126
|
+
expect(screen.getByRole("combobox")).toHaveValue("bpl");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("prepends a 'Select a library' option when no library is selected", () => {
|
|
130
|
+
const libraries = [
|
|
131
|
+
{ short_name: "nypl", name: "NYPL" },
|
|
132
|
+
{ short_name: "bpl" },
|
|
133
|
+
];
|
|
134
|
+
renderHeader({ library: null, props: { libraries } });
|
|
135
|
+
|
|
136
|
+
const options = within(screen.getByRole("combobox")).getAllByRole(
|
|
137
|
+
"option"
|
|
138
|
+
);
|
|
139
|
+
expect(options).toHaveLength(3);
|
|
140
|
+
expect(options[0]).toHaveTextContent("Select a library");
|
|
141
|
+
expect(options[1]).toHaveTextContent("NYPL");
|
|
142
|
+
expect(options[1]).toHaveValue("nypl");
|
|
143
|
+
expect(options[2]).toHaveTextContent("bpl");
|
|
144
|
+
expect(options[2]).toHaveValue("bpl");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("shows the catalog nav links when a library is selected", () => {
|
|
148
|
+
const { container } = renderHeader();
|
|
149
|
+
|
|
150
|
+
const catalogLinks = container.querySelectorAll(
|
|
151
|
+
'a[href^="/admin/web/collection/"]'
|
|
152
|
+
);
|
|
153
|
+
expect(catalogLinks).toHaveLength(2);
|
|
154
|
+
|
|
155
|
+
const catalog = container.querySelector('a[href*="nypl%2Fgroups"]');
|
|
156
|
+
expect(catalog).toHaveTextContent("Catalog");
|
|
157
|
+
const hidden = container.querySelector(
|
|
158
|
+
'a[href*="nypl%2Fadmin%2Fsuppressed"]'
|
|
159
|
+
);
|
|
160
|
+
expect(hidden).toHaveTextContent("Hidden Books");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("hides the catalog nav links when no library is selected", () => {
|
|
164
|
+
const { container } = renderHeader({ library: null });
|
|
165
|
+
expect(
|
|
166
|
+
container.querySelectorAll('a[href^="/admin/web/collection/"]')
|
|
167
|
+
).toHaveLength(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("shows dashboard, lists, lanes, and system configuration links for a library manager", () => {
|
|
171
|
+
renderHeader({ admin: libraryManager });
|
|
172
|
+
|
|
173
|
+
const links = screen.getAllByTestId("Link");
|
|
174
|
+
expect(links).toHaveLength(4);
|
|
175
|
+
expect(links[0]).toHaveTextContent("Dashboard");
|
|
176
|
+
expect(links[0]).toHaveAttribute("data-to", "/admin/web/dashboard/nypl");
|
|
177
|
+
expect(links[1]).toHaveTextContent("Lists");
|
|
178
|
+
expect(links[1]).toHaveAttribute("data-to", "/admin/web/lists/nypl");
|
|
179
|
+
expect(links[2]).toHaveTextContent("Lanes");
|
|
180
|
+
expect(links[2]).toHaveAttribute("data-to", "/admin/web/lanes/nypl");
|
|
181
|
+
expect(links[3]).toHaveTextContent("System Configuration");
|
|
182
|
+
expect(links[3]).toHaveAttribute("data-to", "/admin/web/config/");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("shows only site-wide dashboard and system configuration links when no library is selected", () => {
|
|
186
|
+
renderHeader({ library: null, admin: libraryManager });
|
|
187
|
+
|
|
188
|
+
const links = screen.getAllByTestId("Link");
|
|
189
|
+
expect(links).toHaveLength(2);
|
|
190
|
+
expect(links[0]).toHaveTextContent("Dashboard");
|
|
191
|
+
expect(links[0]).toHaveAttribute("data-to", "/admin/web/dashboard/");
|
|
192
|
+
expect(links[1]).toHaveTextContent("System Configuration");
|
|
193
|
+
expect(links[1]).toHaveAttribute("data-to", "/admin/web/config/");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("shows dashboard, lists, and system configuration links for a librarian", () => {
|
|
197
|
+
renderHeader({ admin: librarian });
|
|
198
|
+
|
|
199
|
+
const links = screen.getAllByTestId("Link");
|
|
200
|
+
expect(links).toHaveLength(3);
|
|
201
|
+
expect(links[0]).toHaveTextContent("Dashboard");
|
|
202
|
+
expect(links[0]).toHaveAttribute("data-to", "/admin/web/dashboard/nypl");
|
|
203
|
+
expect(links[1]).toHaveTextContent("Lists");
|
|
204
|
+
expect(links[1]).toHaveAttribute("data-to", "/admin/web/lists/nypl");
|
|
205
|
+
expect(links[2]).toHaveTextContent("System Configuration");
|
|
206
|
+
expect(links[2]).toHaveAttribute("data-to", "/admin/web/config/");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("shows the account dropdown toggle when the admin has an email", () => {
|
|
210
|
+
const admin = new Admin(
|
|
211
|
+
[{ role: "librarian", library: "nypl" }],
|
|
212
|
+
"admin@nypl.org"
|
|
213
|
+
);
|
|
214
|
+
const { container } = renderHeader({ admin });
|
|
215
|
+
|
|
216
|
+
const toggle = container.querySelector("button.account-dropdown-toggle");
|
|
217
|
+
expect(toggle).toBeInTheDocument();
|
|
218
|
+
expect(toggle).toHaveTextContent("admin@nypl.org");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("patron manager display", () => {
|
|
222
|
+
it("does not show the Patrons link for a librarian", () => {
|
|
223
|
+
renderHeader({ admin: librarian });
|
|
224
|
+
const links = screen.getAllByTestId("Link");
|
|
225
|
+
expect(links).toHaveLength(3);
|
|
226
|
+
expect(links.some((link) => link.textContent === "Patrons")).toBe(
|
|
227
|
+
false
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not show the Patrons link for a library manager", () => {
|
|
232
|
+
renderHeader({ admin: libraryManager });
|
|
233
|
+
const links = screen.getAllByTestId("Link");
|
|
234
|
+
expect(links).toHaveLength(4);
|
|
235
|
+
expect(links.some((link) => link.textContent === "Patrons")).toBe(
|
|
236
|
+
false
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("shows the Patrons link for a system admin", () => {
|
|
241
|
+
renderHeader({ admin: systemAdmin });
|
|
242
|
+
const links = screen.getAllByTestId("Link");
|
|
243
|
+
expect(links).toHaveLength(6);
|
|
244
|
+
expect(links[3]).toHaveTextContent("Patrons");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("troubleshooting display", () => {
|
|
249
|
+
it("does not show the Troubleshooting link for a librarian", () => {
|
|
250
|
+
renderHeader({ admin: librarian });
|
|
251
|
+
const links = screen.getAllByTestId("Link");
|
|
252
|
+
expect(links).toHaveLength(3);
|
|
253
|
+
expect(
|
|
254
|
+
links.some((link) => link.textContent === "Troubleshooting")
|
|
255
|
+
).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not show the Troubleshooting link for a library manager", () => {
|
|
259
|
+
renderHeader({ admin: libraryManager });
|
|
260
|
+
const links = screen.getAllByTestId("Link");
|
|
261
|
+
expect(links).toHaveLength(4);
|
|
262
|
+
expect(
|
|
263
|
+
links.some((link) => link.textContent === "Troubleshooting")
|
|
264
|
+
).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("shows the Troubleshooting link for a system admin", () => {
|
|
268
|
+
renderHeader({ admin: systemAdmin });
|
|
269
|
+
const links = screen.getAllByTestId("Link");
|
|
270
|
+
expect(links).toHaveLength(6);
|
|
271
|
+
expect(links[5]).toHaveTextContent("Troubleshooting");
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("behavior", () => {
|
|
277
|
+
it("fetches libraries on mount", () => {
|
|
278
|
+
const fetchLibraries = jest.fn();
|
|
279
|
+
renderHeader({ admin: libraryManager, props: { fetchLibraries } });
|
|
280
|
+
expect(fetchLibraries).toHaveBeenCalledTimes(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("does not fetch libraries on mount if a fetch is already in progress", () => {
|
|
284
|
+
const fetchLibraries = jest.fn();
|
|
285
|
+
renderHeader({
|
|
286
|
+
admin: libraryManager,
|
|
287
|
+
props: { fetchLibraries, isFetchingLibraries: true },
|
|
288
|
+
});
|
|
289
|
+
expect(fetchLibraries).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("navigates to the selected library's catalog when the dropdown changes", async () => {
|
|
293
|
+
const user = userEvent.setup();
|
|
294
|
+
const router = makeRouter();
|
|
295
|
+
const libraries = [
|
|
296
|
+
{ short_name: "nypl", name: "NYPL" },
|
|
297
|
+
{ short_name: "bpl" },
|
|
298
|
+
];
|
|
299
|
+
renderHeader({ router, props: { libraries } });
|
|
300
|
+
|
|
301
|
+
await user.selectOptions(screen.getByRole("combobox"), "bpl");
|
|
302
|
+
|
|
303
|
+
expect(router.push).toHaveBeenCalledTimes(1);
|
|
304
|
+
expect(router.push).toHaveBeenCalledWith(
|
|
305
|
+
"/admin/web/collection/bpl%2Fgroups"
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("toggles the account dropdown open and closed", async () => {
|
|
310
|
+
const user = userEvent.setup();
|
|
311
|
+
const admin = new Admin(
|
|
312
|
+
[{ role: "librarian", library: "nypl" }],
|
|
313
|
+
"admin@nypl.org"
|
|
314
|
+
);
|
|
315
|
+
const { container } = renderHeader({ admin });
|
|
316
|
+
|
|
317
|
+
expect(
|
|
318
|
+
container.querySelector("ul.dropdown-menu")
|
|
319
|
+
).not.toBeInTheDocument();
|
|
320
|
+
|
|
321
|
+
const toggle = container.querySelector(
|
|
322
|
+
"button.account-dropdown-toggle"
|
|
323
|
+
) as HTMLElement;
|
|
324
|
+
await user.click(toggle);
|
|
325
|
+
|
|
326
|
+
const menu = container.querySelector("ul.dropdown-menu") as HTMLElement;
|
|
327
|
+
expect(menu).toBeInTheDocument();
|
|
328
|
+
expect(menu.querySelectorAll("li")).toHaveLength(3);
|
|
329
|
+
expect(menu.querySelector("li.permissions")).toHaveTextContent(
|
|
330
|
+
"Logged in as a user"
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// The change-password entry is a react-router Link (mocked to a marker div).
|
|
334
|
+
const changePassword = within(menu).getByTestId("Link");
|
|
335
|
+
expect(changePassword).toHaveTextContent("Change password");
|
|
336
|
+
expect(changePassword).toHaveAttribute("data-to", "/admin/web/account/");
|
|
337
|
+
|
|
338
|
+
// The sign-out entry is a real anchor.
|
|
339
|
+
const signOut = within(menu).getByRole("link", { name: "Sign out" });
|
|
340
|
+
expect(signOut).toHaveAttribute("href", "/admin/sign_out");
|
|
341
|
+
|
|
342
|
+
await user.click(toggle);
|
|
343
|
+
expect(
|
|
344
|
+
container.querySelector("ul.dropdown-menu")
|
|
345
|
+
).not.toBeInTheDocument();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("labels the permission level in the account dropdown", async () => {
|
|
349
|
+
const user = userEvent.setup();
|
|
350
|
+
const openDropdownPermissions = async (admin: Admin) => {
|
|
351
|
+
const { container, unmount } = renderHeader({ admin });
|
|
352
|
+
const toggle = container.querySelector(
|
|
353
|
+
"button.account-dropdown-toggle"
|
|
354
|
+
) as HTMLElement;
|
|
355
|
+
await user.click(toggle);
|
|
356
|
+
const text = container.querySelector(
|
|
357
|
+
"ul.dropdown-menu li.permissions"
|
|
358
|
+
)?.textContent;
|
|
359
|
+
unmount();
|
|
360
|
+
return text;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
expect(
|
|
364
|
+
await openDropdownPermissions(
|
|
365
|
+
new Admin([{ role: "system", library: "nypl" }], "admin@nypl.org")
|
|
366
|
+
)
|
|
367
|
+
).toBe("Logged in as a system admin");
|
|
368
|
+
expect(
|
|
369
|
+
await openDropdownPermissions(
|
|
370
|
+
new Admin([{ role: "manager", library: "nypl" }], "admin@nypl.org")
|
|
371
|
+
)
|
|
372
|
+
).toBe("Logged in as an administrator");
|
|
373
|
+
expect(
|
|
374
|
+
await openDropdownPermissions(
|
|
375
|
+
new Admin([{ role: "librarian", library: "nypl" }], "admin@nypl.org")
|
|
376
|
+
)
|
|
377
|
+
).toBe("Logged in as a user");
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("connected (default export)", () => {
|
|
382
|
+
afterEach(() => {
|
|
383
|
+
jest.restoreAllMocks();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("wires libraries from the store and fetches them on mount", async () => {
|
|
387
|
+
// The connected default export reads `libraries`/`isFetchingLibraries` from
|
|
388
|
+
// the redux store and dispatches `fetchLibraries` on mount, which hits the
|
|
389
|
+
// network — stub fetch so mounting settles without a real request.
|
|
390
|
+
const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
391
|
+
new Response(JSON.stringify({ libraries: [] }), {
|
|
392
|
+
status: 200,
|
|
393
|
+
headers: { "Content-Type": "application/json" },
|
|
394
|
+
})
|
|
395
|
+
);
|
|
396
|
+
const store = buildStore();
|
|
397
|
+
|
|
398
|
+
render(
|
|
399
|
+
<Provider store={store}>
|
|
400
|
+
<LegacyContextProvider
|
|
401
|
+
context={{
|
|
402
|
+
editorStore: store,
|
|
403
|
+
csrfToken: "token",
|
|
404
|
+
library: () => "nypl",
|
|
405
|
+
admin: libraryManager,
|
|
406
|
+
router: makeRouter(),
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
<HeaderWithStore />
|
|
410
|
+
</LegacyContextProvider>
|
|
411
|
+
</Provider>
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(
|
|
415
|
+
await screen.findByRole("heading", { level: 1 })
|
|
416
|
+
).toHaveTextContent(title());
|
|
417
|
+
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
|
|
418
|
+
expect(fetchSpy.mock.calls[0][0]).toContain("/admin/libraries");
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
});
|