@thepalaceproject/circulation-admin 1.43.0-post.9 → 1.44.0-post.3
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/CLAUDE.md +22 -10
- package/README.md +11 -5
- package/dist/circulation-admin.js +1 -1
- package/jest.config.js +11 -44
- package/package.json +1 -5
- package/tests/jest/components/AdvancedSearchBuilder.test.tsx +111 -0
- package/tests/jest/components/Announcement.test.tsx +76 -0
- package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
- package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
- package/tests/jest/components/Autocomplete.test.tsx +67 -0
- package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
- package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
- package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
- package/tests/jest/components/BookEditForm.test.tsx +352 -0
- package/tests/jest/components/BookEditor.test.tsx +20 -21
- package/tests/jest/components/CatalogPage.test.tsx +158 -0
- package/tests/jest/components/CatalogServices.test.tsx +38 -1
- package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
- package/tests/jest/components/Classifications.test.tsx +166 -0
- package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
- package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
- package/tests/jest/components/Collections.test.tsx +335 -7
- package/tests/jest/components/ColorPicker.test.tsx +58 -0
- package/tests/jest/components/ComplaintForm.test.tsx +171 -0
- package/tests/jest/components/Complaints.test.tsx +259 -0
- package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
- package/tests/jest/components/ContextProvider.test.tsx +138 -0
- package/tests/jest/components/Contributors.test.tsx +157 -0
- package/tests/jest/components/CustomListEditor.test.tsx +367 -0
- package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
- package/tests/jest/components/CustomLists.test.tsx +621 -4
- package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
- package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
- package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
- 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/EditorField.test.tsx +95 -0
- package/tests/jest/components/GenreForm.test.tsx +152 -0
- 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/InputList.test.tsx +493 -0
- package/tests/jest/components/Lane.test.tsx +132 -10
- package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
- package/tests/jest/components/LaneEditor.test.tsx +377 -9
- package/tests/jest/components/Lanes.test.tsx +446 -0
- package/tests/jest/components/LanesSidebar.test.tsx +621 -0
- package/tests/jest/components/Libraries.test.tsx +48 -0
- package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
- package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
- package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
- package/tests/jest/components/MetadataServices.test.tsx +37 -1
- package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
- package/tests/jest/components/PairedMenus.test.tsx +220 -0
- package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
- package/tests/jest/components/ProtocolFormField.test.tsx +443 -7
- package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
- package/tests/jest/components/SelfTests.test.tsx +601 -0
- package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
- package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
- package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
- package/tests/jest/components/Stats.test.tsx +0 -7
- package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
- package/tests/jest/components/ToolTip.test.tsx +46 -0
- package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
- package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
- package/tests/jest/features/book.test.ts +75 -80
- package/tests/jest/fixtures/classificationsData.ts +45 -0
- package/tests/jest/fixtures/genreData.ts +811 -0
- package/tests/jest/README.md +0 -6
- package/tests/jest/legacy-setup.ts +0 -13
- package/tests/jest/legacyJsdomEnvironment.js +0 -49
|
@@ -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,95 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { RichUtils } from "draft-js";
|
|
4
|
+
|
|
5
|
+
import EditorField from "../../../src/components/EditorField";
|
|
6
|
+
|
|
7
|
+
describe("EditorField", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
jest.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("renders the three inline-style buttons and toggles the matching style on mouse-down", () => {
|
|
13
|
+
// There is no DOM-observable effect of toggling an inline style over a
|
|
14
|
+
// collapsed (unfocused) selection, so we drive the real mouse-down interaction
|
|
15
|
+
// and observe the actual `RichUtils.toggleInlineStyle` call the button triggers.
|
|
16
|
+
const toggleSpy = jest.spyOn(RichUtils, "toggleInlineStyle");
|
|
17
|
+
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<EditorField content="This is a summary." disabled={false} />
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const buttons = Array.from(container.querySelectorAll("button"));
|
|
23
|
+
expect(buttons).toHaveLength(3);
|
|
24
|
+
const [bold, italic, underline] = buttons;
|
|
25
|
+
|
|
26
|
+
expect(bold).toHaveTextContent("Bold");
|
|
27
|
+
expect(bold.querySelector("b")).toBeInTheDocument();
|
|
28
|
+
expect(italic).toHaveTextContent("Italic");
|
|
29
|
+
expect(italic.querySelector("i")).toBeInTheDocument();
|
|
30
|
+
expect(underline).toHaveTextContent("Underline");
|
|
31
|
+
expect(underline.querySelector("u")).toBeInTheDocument();
|
|
32
|
+
|
|
33
|
+
expect(toggleSpy).not.toHaveBeenCalled();
|
|
34
|
+
|
|
35
|
+
fireEvent.mouseDown(bold);
|
|
36
|
+
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(toggleSpy.mock.calls[0][1]).toBe("BOLD");
|
|
38
|
+
|
|
39
|
+
fireEvent.mouseDown(italic);
|
|
40
|
+
expect(toggleSpy).toHaveBeenCalledTimes(2);
|
|
41
|
+
expect(toggleSpy.mock.calls[1][1]).toBe("ITALIC");
|
|
42
|
+
|
|
43
|
+
fireEvent.mouseDown(underline);
|
|
44
|
+
expect(toggleSpy).toHaveBeenCalledTimes(3);
|
|
45
|
+
expect(toggleSpy.mock.calls[2][1]).toBe("UNDERLINE");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("exposes the current content as HTML through getValue()", () => {
|
|
49
|
+
// `getValue()` is EditorField's real imperative API (BookEditForm reads it via a
|
|
50
|
+
// ref), so mirror that: render with a ref and call it.
|
|
51
|
+
const ref = React.createRef<EditorField>();
|
|
52
|
+
render(
|
|
53
|
+
<EditorField ref={ref} content="This is a summary." disabled={false} />
|
|
54
|
+
);
|
|
55
|
+
expect(ref.current?.getValue()).toBe("<p>This is a summary.</p>");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls back to the default content when the content is empty", () => {
|
|
59
|
+
const expected = "<p>Update the content for this field.</p>";
|
|
60
|
+
|
|
61
|
+
const emptyParagraphRef = React.createRef<EditorField>();
|
|
62
|
+
render(
|
|
63
|
+
<EditorField ref={emptyParagraphRef} content="<p></p>" disabled={false} />
|
|
64
|
+
);
|
|
65
|
+
expect(emptyParagraphRef.current?.getValue()).toBe(expected);
|
|
66
|
+
|
|
67
|
+
const emptyStringRef = React.createRef<EditorField>();
|
|
68
|
+
render(<EditorField ref={emptyStringRef} content="" disabled={false} />);
|
|
69
|
+
expect(emptyStringRef.current?.getValue()).toBe(expected);
|
|
70
|
+
|
|
71
|
+
const undefinedRef = React.createRef<EditorField>();
|
|
72
|
+
render(
|
|
73
|
+
<EditorField ref={undefinedRef} content={undefined} disabled={false} />
|
|
74
|
+
);
|
|
75
|
+
expect(undefinedRef.current?.getValue()).toBe(expected);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("disables the toolbar buttons and the editor when disabled", () => {
|
|
79
|
+
const { container, rerender } = render(
|
|
80
|
+
<EditorField content="This is a summary." disabled={false} />
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const buttons = () => Array.from(container.querySelectorAll("button"));
|
|
84
|
+
const editable = () => container.querySelector("[contenteditable]");
|
|
85
|
+
|
|
86
|
+
expect(buttons()).toHaveLength(3);
|
|
87
|
+
buttons().forEach((button) => expect(button).not.toBeDisabled());
|
|
88
|
+
expect(editable()).toHaveAttribute("contenteditable", "true");
|
|
89
|
+
|
|
90
|
+
rerender(<EditorField content="This is a summary." disabled={true} />);
|
|
91
|
+
|
|
92
|
+
buttons().forEach((button) => expect(button).toBeDisabled());
|
|
93
|
+
expect(editable()).toHaveAttribute("contenteditable", "false");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import GenreForm from "../../../src/components/GenreForm";
|
|
6
|
+
import genreData from "../fixtures/genreData";
|
|
7
|
+
|
|
8
|
+
const genreOptions = Object.keys(genreData["Fiction"]).map(
|
|
9
|
+
(name) => genreData["Fiction"][name]
|
|
10
|
+
);
|
|
11
|
+
const nonfictionOptions = Object.keys(genreData["Nonfiction"]).map(
|
|
12
|
+
(name) => genreData["Nonfiction"][name]
|
|
13
|
+
);
|
|
14
|
+
const bookGenres = ["Adventure", "Epic Fantasy"];
|
|
15
|
+
|
|
16
|
+
const renderForm = (props: Record<string, unknown> = {}) => {
|
|
17
|
+
const addGenre = jest.fn();
|
|
18
|
+
const result = render(
|
|
19
|
+
<GenreForm
|
|
20
|
+
genreOptions={genreOptions}
|
|
21
|
+
bookGenres={bookGenres}
|
|
22
|
+
addGenre={addGenre}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
return { ...result, addGenre };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const genreSelect = (container: HTMLElement) =>
|
|
30
|
+
container.querySelector("select[name='genre']") as HTMLSelectElement;
|
|
31
|
+
const genreOptionEls = (container: HTMLElement) =>
|
|
32
|
+
Array.from(
|
|
33
|
+
genreSelect(container).querySelectorAll<HTMLOptionElement>("option")
|
|
34
|
+
);
|
|
35
|
+
const subgenreSelect = (container: HTMLElement) =>
|
|
36
|
+
container.querySelector(
|
|
37
|
+
"select[name='subgenre']"
|
|
38
|
+
) as HTMLSelectElement | null;
|
|
39
|
+
|
|
40
|
+
describe("GenreForm", () => {
|
|
41
|
+
describe("rendering", () => {
|
|
42
|
+
it("shows multiple select with top-level genres", () => {
|
|
43
|
+
const { container } = renderForm();
|
|
44
|
+
const topLevelGenres = genreOptions.filter(
|
|
45
|
+
(genre) => genre.parents.length === 0
|
|
46
|
+
);
|
|
47
|
+
const options = genreOptionEls(container);
|
|
48
|
+
|
|
49
|
+
expect(options).toHaveLength(topLevelGenres.length);
|
|
50
|
+
options.forEach((option, i) => {
|
|
51
|
+
const name = topLevelGenres[i].name;
|
|
52
|
+
const hasGenre = bookGenres.indexOf(name) !== -1;
|
|
53
|
+
const hasSubgenre = topLevelGenres[i].subgenres.length > 0;
|
|
54
|
+
const displayName = name + (hasSubgenre ? " >" : "");
|
|
55
|
+
expect(option.value).toBe(name);
|
|
56
|
+
expect(option.disabled).toBe(hasGenre);
|
|
57
|
+
expect(option.textContent).toBe(displayName);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("behavior", () => {
|
|
63
|
+
it("shows multiple select with subgenres when genre is selected", () => {
|
|
64
|
+
const { container, rerender } = renderForm();
|
|
65
|
+
|
|
66
|
+
// Nothing is selected yet: no subgenre select and no Add button.
|
|
67
|
+
expect(subgenreSelect(container)).toBeNull();
|
|
68
|
+
expect(screen.queryByRole("button", { name: "Add" })).toBeNull();
|
|
69
|
+
|
|
70
|
+
// Select a genre with no subgenres (the first option). The component wires
|
|
71
|
+
// selection to `onClick` on each <option>, so fire a click there.
|
|
72
|
+
const firstGenre = genreOptionEls(container)[0];
|
|
73
|
+
fireEvent.click(firstGenre);
|
|
74
|
+
expect(firstGenre).toHaveAttribute("aria-selected", "true");
|
|
75
|
+
expect(subgenreSelect(container)).toBeNull();
|
|
76
|
+
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
|
77
|
+
|
|
78
|
+
// Select a genre that has subgenres.
|
|
79
|
+
const fantasy = genreOptionEls(container).find(
|
|
80
|
+
(option) => option.value === "Fantasy"
|
|
81
|
+
);
|
|
82
|
+
fireEvent.click(fantasy);
|
|
83
|
+
expect(fantasy).toHaveAttribute("aria-selected", "true");
|
|
84
|
+
|
|
85
|
+
const subSelect = subgenreSelect(container);
|
|
86
|
+
expect(subSelect).not.toBeNull();
|
|
87
|
+
const subOptions = Array.from(
|
|
88
|
+
subSelect.querySelectorAll<HTMLOptionElement>("option")
|
|
89
|
+
);
|
|
90
|
+
const subgenres = [...genreData["Fiction"]["Fantasy"].subgenres].sort();
|
|
91
|
+
|
|
92
|
+
expect(subOptions).toHaveLength(subgenres.length);
|
|
93
|
+
subOptions.forEach((option, i) => {
|
|
94
|
+
const name = subgenres[i];
|
|
95
|
+
const hasGenre = bookGenres.indexOf(name) !== -1;
|
|
96
|
+
expect(option.value).toBe(name);
|
|
97
|
+
expect(option.disabled).toBe(hasGenre);
|
|
98
|
+
expect(option.textContent).toBe(name);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Selecting a subgenre marks it as selected.
|
|
102
|
+
fireEvent.click(subOptions[0]);
|
|
103
|
+
expect(
|
|
104
|
+
subgenreSelect(container).querySelector('option[value="Epic Fantasy"]')
|
|
105
|
+
).toHaveAttribute("aria-selected", "true");
|
|
106
|
+
|
|
107
|
+
// Changing the genre options (fiction -> nonfiction) drops the subgenres,
|
|
108
|
+
// since the selected genre no longer exists in the new options.
|
|
109
|
+
rerender(
|
|
110
|
+
<GenreForm
|
|
111
|
+
genreOptions={nonfictionOptions}
|
|
112
|
+
bookGenres={bookGenres}
|
|
113
|
+
addGenre={jest.fn()}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
expect(subgenreSelect(container)).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("shows add button only if genre is selected", () => {
|
|
120
|
+
const { container } = renderForm();
|
|
121
|
+
expect(screen.queryByRole("button", { name: "Add" })).toBeNull();
|
|
122
|
+
|
|
123
|
+
const womensFiction = genreOptionEls(container).find(
|
|
124
|
+
(option) => option.value === "Women's Fiction"
|
|
125
|
+
);
|
|
126
|
+
fireEvent.click(womensFiction);
|
|
127
|
+
|
|
128
|
+
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("adds genre", async () => {
|
|
132
|
+
const user = userEvent.setup();
|
|
133
|
+
const { container, addGenre } = renderForm();
|
|
134
|
+
|
|
135
|
+
const scienceFiction = genreOptionEls(container).find(
|
|
136
|
+
(option) => option.value === "Science Fiction"
|
|
137
|
+
);
|
|
138
|
+
fireEvent.click(scienceFiction);
|
|
139
|
+
|
|
140
|
+
const spaceOpera = Array.from(
|
|
141
|
+
subgenreSelect(container).querySelectorAll<HTMLOptionElement>("option")
|
|
142
|
+
).find((option) => option.value === "Space Opera");
|
|
143
|
+
fireEvent.click(spaceOpera);
|
|
144
|
+
|
|
145
|
+
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
146
|
+
|
|
147
|
+
// The subgenre takes precedence over the top-level genre.
|
|
148
|
+
expect(addGenre).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(addGenre.mock.calls[0][0]).toBe("Space Opera");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|