@thepalaceproject/circulation-admin 1.43.0-post.9 → 1.44.0-post.10
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
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, within } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { LaneData } from "../../../src/interfaces";
|
|
6
|
+
import LanesSidebar from "../../../src/components/LanesSidebar";
|
|
7
|
+
|
|
8
|
+
// react-router's Link is rendered as a marker `<div data-testid="Link">` so we
|
|
9
|
+
// can assert its target (`data-to`) and className without a Router in context.
|
|
10
|
+
// Both LanesSidebar and its Lane children render Links.
|
|
11
|
+
jest.mock("react-router", () => ({
|
|
12
|
+
...jest.requireActual("react-router"),
|
|
13
|
+
Link: (props: any) => (
|
|
14
|
+
<div data-testid="Link" data-to={props.to} className={props.className}>
|
|
15
|
+
{props.children}
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// react-beautiful-dnd is mocked so that (a) we can capture the drag handlers
|
|
21
|
+
// LanesSidebar hands to DragDropContext and invoke them the way the library
|
|
22
|
+
// would — without simulating pixel-level drags — and (b) Droppable/Draggable
|
|
23
|
+
// still call their render-prop children with `provided`/`snapshot`, which is
|
|
24
|
+
// what gives draggable lanes their `.draggable` class and grab handle. A lane
|
|
25
|
+
// group of size one is rendered by LanesSidebar without a Draggable, so those
|
|
26
|
+
// lanes are (correctly) not draggable.
|
|
27
|
+
jest.mock("react-beautiful-dnd", () => {
|
|
28
|
+
const handlers: any = {};
|
|
29
|
+
return {
|
|
30
|
+
__handlers: handlers,
|
|
31
|
+
DragDropContext: ({ onDragStart, onDragEnd, children }: any) => {
|
|
32
|
+
handlers.onDragStart = onDragStart;
|
|
33
|
+
handlers.onDragEnd = onDragEnd;
|
|
34
|
+
handlers.droppables = []; // reset the per-render capture
|
|
35
|
+
return children;
|
|
36
|
+
},
|
|
37
|
+
Droppable: ({ droppableId, isDropDisabled, children }: any) => {
|
|
38
|
+
handlers.droppables.push({
|
|
39
|
+
droppableId,
|
|
40
|
+
isDropDisabled: !!isDropDisabled,
|
|
41
|
+
});
|
|
42
|
+
return children(
|
|
43
|
+
{ innerRef: () => undefined, droppableProps: {}, placeholder: null },
|
|
44
|
+
{ isDraggingOver: false }
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
Draggable: ({ children }: any) =>
|
|
48
|
+
children(
|
|
49
|
+
{
|
|
50
|
+
innerRef: () => undefined,
|
|
51
|
+
draggableProps: {},
|
|
52
|
+
dragHandleProps: {},
|
|
53
|
+
draggableStyle: {},
|
|
54
|
+
placeholder: null,
|
|
55
|
+
},
|
|
56
|
+
{ isDragging: false }
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const getHandlers = () =>
|
|
62
|
+
(jest.requireMock("react-beautiful-dnd") as any).__handlers;
|
|
63
|
+
|
|
64
|
+
const makeSubsublane = (): LaneData => ({
|
|
65
|
+
id: 3,
|
|
66
|
+
display_name: "SubSublane 1",
|
|
67
|
+
visible: false,
|
|
68
|
+
count: 2,
|
|
69
|
+
sublanes: [],
|
|
70
|
+
custom_list_ids: [2],
|
|
71
|
+
inherit_parent_restrictions: false,
|
|
72
|
+
});
|
|
73
|
+
const makeSublane = (): LaneData => ({
|
|
74
|
+
id: 2,
|
|
75
|
+
display_name: "Sublane 1",
|
|
76
|
+
visible: false,
|
|
77
|
+
count: 3,
|
|
78
|
+
sublanes: [makeSubsublane()],
|
|
79
|
+
custom_list_ids: [2],
|
|
80
|
+
inherit_parent_restrictions: false,
|
|
81
|
+
});
|
|
82
|
+
const makeLanes = (): LaneData[] => [
|
|
83
|
+
{
|
|
84
|
+
id: 1,
|
|
85
|
+
display_name: "Top Lane 1",
|
|
86
|
+
visible: true,
|
|
87
|
+
count: 5,
|
|
88
|
+
sublanes: [makeSublane()],
|
|
89
|
+
custom_list_ids: [1],
|
|
90
|
+
inherit_parent_restrictions: true,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 4,
|
|
94
|
+
display_name: "Top Lane 2",
|
|
95
|
+
visible: true,
|
|
96
|
+
count: 1,
|
|
97
|
+
sublanes: [],
|
|
98
|
+
custom_list_ids: [],
|
|
99
|
+
inherit_parent_restrictions: false,
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const renderSidebar = (extra: any = {}) => {
|
|
104
|
+
const lanes: LaneData[] = extra.lanes ?? makeLanes();
|
|
105
|
+
const drag = extra.drag ?? jest.fn();
|
|
106
|
+
const findLaneForIdentifier =
|
|
107
|
+
extra.findLaneForIdentifier ?? jest.fn().mockReturnValue(lanes[1]);
|
|
108
|
+
const findParentOfLane =
|
|
109
|
+
extra.findParentOfLane ?? jest.fn().mockReturnValue(null);
|
|
110
|
+
const props: any = {
|
|
111
|
+
orderChanged: false,
|
|
112
|
+
library: "library",
|
|
113
|
+
drag,
|
|
114
|
+
lanes,
|
|
115
|
+
findLaneForIdentifier,
|
|
116
|
+
findParentOfLane,
|
|
117
|
+
...extra.props,
|
|
118
|
+
};
|
|
119
|
+
const result = render(<LanesSidebar {...props} />);
|
|
120
|
+
const rerenderWith = (next: any = {}) =>
|
|
121
|
+
result.rerender(<LanesSidebar {...props} {...next} />);
|
|
122
|
+
return {
|
|
123
|
+
...result,
|
|
124
|
+
rerenderWith,
|
|
125
|
+
drag,
|
|
126
|
+
findLaneForIdentifier,
|
|
127
|
+
findParentOfLane,
|
|
128
|
+
lanes,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// --- DOM helpers -----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
// The <li>s of the top-level droppable list.
|
|
135
|
+
const droppableLis = (container: HTMLElement) =>
|
|
136
|
+
Array.from(container.querySelector("ul.droppable")!.children).filter(
|
|
137
|
+
(el) => el.tagName === "LI"
|
|
138
|
+
) as HTMLElement[];
|
|
139
|
+
// A lane's `.lane-parent` div (the first div in its <li>).
|
|
140
|
+
const parentDiv = (li: Element) =>
|
|
141
|
+
li.querySelector(":scope > div") as HTMLElement;
|
|
142
|
+
// A lane's own `.lane-info` (not those of its descendants).
|
|
143
|
+
const ownInfo = (lp: Element) =>
|
|
144
|
+
lp.querySelector(":scope > .lane-info") as HTMLElement;
|
|
145
|
+
const collapseBtn = (lp: Element) =>
|
|
146
|
+
ownInfo(lp).querySelector(".collapse-button") as HTMLElement | null;
|
|
147
|
+
const expandBtn = (lp: Element) =>
|
|
148
|
+
ownInfo(lp).querySelector(".expand-button") as HTMLElement | null;
|
|
149
|
+
// The first descendant sublane `.lane-parent`.
|
|
150
|
+
const firstChildParent = (lp: Element) =>
|
|
151
|
+
lp.querySelector("li > div") as HTMLElement | null;
|
|
152
|
+
// All descendant sublane `.lane-parent`s (used for counting when only the
|
|
153
|
+
// immediate level is rendered).
|
|
154
|
+
const childParents = (lp: Element) =>
|
|
155
|
+
Array.from(lp.querySelectorAll("li > div")) as HTMLElement[];
|
|
156
|
+
// Whether a lane's own lane-info carries the draggable class.
|
|
157
|
+
const isDraggable = (lp: Element) =>
|
|
158
|
+
ownInfo(lp).classList.contains("draggable");
|
|
159
|
+
// The lane's own display name (the last text node in its lane-info span).
|
|
160
|
+
const laneName = (lp: Element) => {
|
|
161
|
+
const span = lp.querySelector(":scope > .lane-info > span");
|
|
162
|
+
return (span?.lastChild?.textContent || "").trim();
|
|
163
|
+
};
|
|
164
|
+
const laneParentByName = (container: HTMLElement, name: string) =>
|
|
165
|
+
Array.from(container.querySelectorAll<HTMLElement>(".lane-parent")).find(
|
|
166
|
+
(lp) => laneName(lp) === name
|
|
167
|
+
)!;
|
|
168
|
+
// A lane's direct sublane <li>s.
|
|
169
|
+
const directSublanes = (lp: Element) => {
|
|
170
|
+
const ul = lp.querySelector(":scope > ul");
|
|
171
|
+
if (!ul) return [];
|
|
172
|
+
return Array.from(ul.children).filter((c) => c.tagName === "LI");
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
describe("LanesSidebar", () => {
|
|
176
|
+
it("renders create top-level lane link", () => {
|
|
177
|
+
const { container, rerenderWith } = renderSidebar();
|
|
178
|
+
const header = container.querySelector<HTMLElement>(".lanes-sidebar > div");
|
|
179
|
+
|
|
180
|
+
let create = within(header).getAllByTestId("Link")[0];
|
|
181
|
+
expect(create).toHaveClass("create-lane");
|
|
182
|
+
expect(create).toHaveAttribute(
|
|
183
|
+
"data-to",
|
|
184
|
+
"/admin/web/lanes/library/create"
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// the link is disabled if there are lane order changes
|
|
188
|
+
rerenderWith({ orderChanged: true });
|
|
189
|
+
create = within(header).getAllByTestId("Link")[0];
|
|
190
|
+
expect(create).toHaveClass("create-lane");
|
|
191
|
+
expect(create).not.toHaveAttribute("data-to");
|
|
192
|
+
expect(create).toHaveClass("disabled");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("renders reset link", () => {
|
|
196
|
+
const { container, rerenderWith } = renderSidebar();
|
|
197
|
+
const header = container.querySelector<HTMLElement>(".lanes-sidebar > div");
|
|
198
|
+
|
|
199
|
+
let reset = within(header).getAllByTestId("Link")[1];
|
|
200
|
+
expect(reset).toHaveClass("reset-lane");
|
|
201
|
+
expect(reset).toHaveAttribute("data-to", "/admin/web/lanes/library/reset");
|
|
202
|
+
|
|
203
|
+
rerenderWith({ orderChanged: true });
|
|
204
|
+
reset = within(header).getAllByTestId("Link")[1];
|
|
205
|
+
expect(reset).toHaveClass("reset-lane");
|
|
206
|
+
expect(reset).not.toHaveAttribute("data-to");
|
|
207
|
+
expect(reset).toHaveClass("disabled");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("renders active lane", () => {
|
|
211
|
+
const { container, rerenderWith } = renderSidebar();
|
|
212
|
+
|
|
213
|
+
const topLane1 = () => parentDiv(droppableLis(container)[0]);
|
|
214
|
+
const topLane2 = () => parentDiv(droppableLis(container)[1]);
|
|
215
|
+
const subLane1 = () => firstChildParent(topLane1())!;
|
|
216
|
+
|
|
217
|
+
expect(topLane1()).not.toHaveClass("active");
|
|
218
|
+
expect(topLane2()).not.toHaveClass("active");
|
|
219
|
+
expect(subLane1()).not.toHaveClass("active");
|
|
220
|
+
|
|
221
|
+
rerenderWith({ identifier: "1" });
|
|
222
|
+
expect(topLane1()).toHaveClass("active");
|
|
223
|
+
expect(topLane2()).not.toHaveClass("active");
|
|
224
|
+
expect(subLane1()).not.toHaveClass("active");
|
|
225
|
+
|
|
226
|
+
rerenderWith({ identifier: "2" });
|
|
227
|
+
expect(topLane1()).not.toHaveClass("active");
|
|
228
|
+
expect(topLane2()).not.toHaveClass("active");
|
|
229
|
+
expect(subLane1()).toHaveClass("active");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("drags and drops a top-level lane", () => {
|
|
233
|
+
const { drag, lanes } = renderSidebar();
|
|
234
|
+
const handlers = getHandlers();
|
|
235
|
+
|
|
236
|
+
// pick up Top Lane 2
|
|
237
|
+
handlers.onDragStart({
|
|
238
|
+
draggableId: "4",
|
|
239
|
+
source: { index: 1, droppableId: "top" },
|
|
240
|
+
});
|
|
241
|
+
expect(drag).toHaveBeenCalledTimes(1);
|
|
242
|
+
expect(drag.mock.calls[0][0]).toStrictEqual({
|
|
243
|
+
draggableId: "4",
|
|
244
|
+
draggingFrom: "top",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// drop it before Top Lane 1
|
|
248
|
+
handlers.onDragEnd({
|
|
249
|
+
draggableId: "4",
|
|
250
|
+
source: { index: 1, droppableId: "top" },
|
|
251
|
+
destination: { droppableId: "top", index: 0 },
|
|
252
|
+
});
|
|
253
|
+
expect(drag).toHaveBeenCalledTimes(2);
|
|
254
|
+
expect(drag.mock.calls[1][0]).toStrictEqual({
|
|
255
|
+
draggableId: null,
|
|
256
|
+
draggingFrom: null,
|
|
257
|
+
lanes: [lanes[1], lanes[0]],
|
|
258
|
+
orderChanged: true,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("drags and drops a sublane", () => {
|
|
263
|
+
const lanes = makeLanes();
|
|
264
|
+
const sublaneData = lanes[0].sublanes[0];
|
|
265
|
+
const newSublane: LaneData = {
|
|
266
|
+
id: 5,
|
|
267
|
+
display_name: "Sublane 2",
|
|
268
|
+
visible: true,
|
|
269
|
+
count: 0,
|
|
270
|
+
inherit_parent_restrictions: false,
|
|
271
|
+
sublanes: [],
|
|
272
|
+
custom_list_ids: [],
|
|
273
|
+
};
|
|
274
|
+
lanes[0].sublanes = [sublaneData, newSublane];
|
|
275
|
+
|
|
276
|
+
const drag = jest.fn();
|
|
277
|
+
renderSidebar({
|
|
278
|
+
lanes,
|
|
279
|
+
drag,
|
|
280
|
+
findLaneForIdentifier: jest.fn().mockReturnValue(newSublane),
|
|
281
|
+
findParentOfLane: jest.fn().mockReturnValue(lanes[0]),
|
|
282
|
+
});
|
|
283
|
+
const handlers = getHandlers();
|
|
284
|
+
|
|
285
|
+
// pick up Sublane 2
|
|
286
|
+
handlers.onDragStart({
|
|
287
|
+
draggableId: "5",
|
|
288
|
+
source: { index: 1, droppableId: "1" },
|
|
289
|
+
});
|
|
290
|
+
expect(drag).toHaveBeenCalledTimes(1);
|
|
291
|
+
expect(drag.mock.calls[0][0]).toStrictEqual({
|
|
292
|
+
draggableId: "5",
|
|
293
|
+
draggingFrom: "1",
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// drop it before Sublane 1
|
|
297
|
+
handlers.onDragEnd({
|
|
298
|
+
draggableId: "5",
|
|
299
|
+
source: { index: 1, droppableId: "1" },
|
|
300
|
+
destination: { droppableId: "1", index: 0 },
|
|
301
|
+
});
|
|
302
|
+
expect(drag).toHaveBeenCalledTimes(2);
|
|
303
|
+
expect(drag.mock.calls[1][0].draggableId).toBeNull();
|
|
304
|
+
expect(drag.mock.calls[1][0].draggingFrom).toBeNull();
|
|
305
|
+
expect(drag.mock.calls[1][0].orderChanged).toBe(true);
|
|
306
|
+
expect(drag.mock.calls[1][0].lanes[0].sublanes).toStrictEqual([
|
|
307
|
+
newSublane,
|
|
308
|
+
sublaneData,
|
|
309
|
+
]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("drops a lane back into its original position", () => {
|
|
313
|
+
const { drag } = renderSidebar();
|
|
314
|
+
const handlers = getHandlers();
|
|
315
|
+
|
|
316
|
+
handlers.onDragEnd({
|
|
317
|
+
draggableId: "1",
|
|
318
|
+
source: { index: 0, droppableId: "top" },
|
|
319
|
+
destination: { index: 0, droppableId: "top" },
|
|
320
|
+
});
|
|
321
|
+
expect(drag).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("renders and expands and collapses lanes and sublanes", async () => {
|
|
325
|
+
const user = userEvent.setup();
|
|
326
|
+
const { container } = renderSidebar();
|
|
327
|
+
|
|
328
|
+
const lis = droppableLis(container);
|
|
329
|
+
expect(lis).toHaveLength(2);
|
|
330
|
+
let topLane1 = parentDiv(lis[0]);
|
|
331
|
+
const topLane2 = parentDiv(lis[1]);
|
|
332
|
+
expect(topLane1).toHaveTextContent("Top Lane 1");
|
|
333
|
+
expect(topLane2).toHaveTextContent("Top Lane 2");
|
|
334
|
+
|
|
335
|
+
// both top-level lanes are expanded to start
|
|
336
|
+
expect(collapseBtn(topLane1)).toBeInTheDocument();
|
|
337
|
+
expect(expandBtn(topLane1)).not.toBeInTheDocument();
|
|
338
|
+
expect(collapseBtn(topLane2)).toBeInTheDocument();
|
|
339
|
+
expect(expandBtn(topLane2)).not.toBeInTheDocument();
|
|
340
|
+
|
|
341
|
+
// top lane 1 has one sublane which is collapsed
|
|
342
|
+
let subLane1 = firstChildParent(topLane1)!;
|
|
343
|
+
expect(subLane1).toHaveTextContent("Sublane 1");
|
|
344
|
+
expect(childParents(topLane1)).toHaveLength(1);
|
|
345
|
+
expect(expandBtn(subLane1)).toBeInTheDocument();
|
|
346
|
+
expect(collapseBtn(subLane1)).not.toBeInTheDocument();
|
|
347
|
+
|
|
348
|
+
// top lane 2 has no sublanes
|
|
349
|
+
expect(childParents(topLane2)).toHaveLength(0);
|
|
350
|
+
|
|
351
|
+
// sublane 1 has a sublane, but it's not shown while sublane 1 is collapsed
|
|
352
|
+
expect(childParents(subLane1)).toHaveLength(0);
|
|
353
|
+
|
|
354
|
+
// if we expand sublane 1, we can see its sublane below it
|
|
355
|
+
await user.click(expandBtn(subLane1)!);
|
|
356
|
+
topLane1 = parentDiv(droppableLis(container)[0]);
|
|
357
|
+
subLane1 = firstChildParent(topLane1)!;
|
|
358
|
+
expect(collapseBtn(subLane1)).toBeInTheDocument();
|
|
359
|
+
const subSubLane = firstChildParent(subLane1)!;
|
|
360
|
+
expect(subSubLane).toHaveTextContent("SubSublane 1");
|
|
361
|
+
expect(expandBtn(subSubLane)).toBeInTheDocument();
|
|
362
|
+
|
|
363
|
+
// if we collapse sublane 1, its sublane is hidden again
|
|
364
|
+
await user.click(collapseBtn(subLane1)!);
|
|
365
|
+
topLane1 = parentDiv(droppableLis(container)[0]);
|
|
366
|
+
subLane1 = firstChildParent(topLane1)!;
|
|
367
|
+
expect(expandBtn(subLane1)).toBeInTheDocument();
|
|
368
|
+
expect(childParents(subLane1)).toHaveLength(0);
|
|
369
|
+
|
|
370
|
+
// if we collapse lane 1, its sublane is hidden
|
|
371
|
+
await user.click(collapseBtn(topLane1)!);
|
|
372
|
+
topLane1 = parentDiv(droppableLis(container)[0]);
|
|
373
|
+
expect(expandBtn(topLane1)).toBeInTheDocument();
|
|
374
|
+
expect(childParents(topLane1)).toHaveLength(0);
|
|
375
|
+
|
|
376
|
+
// if we expand lane 1, its sublane is shown again
|
|
377
|
+
await user.click(expandBtn(topLane1)!);
|
|
378
|
+
topLane1 = parentDiv(droppableLis(container)[0]);
|
|
379
|
+
expect(collapseBtn(topLane1)).toBeInTheDocument();
|
|
380
|
+
expect(childParents(topLane1)).toHaveLength(1);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("renders draggable sublanes only if there's more than one", async () => {
|
|
384
|
+
const user = userEvent.setup();
|
|
385
|
+
// lane structure:
|
|
386
|
+
// top
|
|
387
|
+
// / \
|
|
388
|
+
// 1 4
|
|
389
|
+
// / \
|
|
390
|
+
// 2 5
|
|
391
|
+
// / / \
|
|
392
|
+
// 3 6 7
|
|
393
|
+
// |
|
|
394
|
+
// 8
|
|
395
|
+
//
|
|
396
|
+
// top, 1, and 5 are the only lanes with more than one sublane, so only
|
|
397
|
+
// their children (1, 4, 2, 5, 6, 7) are draggable.
|
|
398
|
+
const subsublane = makeSubsublane(); // id 3, "SubSublane 1"
|
|
399
|
+
const sublane2: LaneData = {
|
|
400
|
+
id: 2,
|
|
401
|
+
display_name: "sublane 2",
|
|
402
|
+
visible: false,
|
|
403
|
+
count: 3,
|
|
404
|
+
sublanes: [subsublane],
|
|
405
|
+
custom_list_ids: [2],
|
|
406
|
+
inherit_parent_restrictions: false,
|
|
407
|
+
};
|
|
408
|
+
const sublane8: LaneData = {
|
|
409
|
+
id: 8,
|
|
410
|
+
display_name: "sublane 8",
|
|
411
|
+
visible: true,
|
|
412
|
+
count: 6,
|
|
413
|
+
sublanes: [],
|
|
414
|
+
custom_list_ids: [2],
|
|
415
|
+
inherit_parent_restrictions: false,
|
|
416
|
+
};
|
|
417
|
+
const sublane6: LaneData = {
|
|
418
|
+
id: 6,
|
|
419
|
+
display_name: "sublane 6",
|
|
420
|
+
visible: true,
|
|
421
|
+
count: 6,
|
|
422
|
+
sublanes: [sublane8],
|
|
423
|
+
custom_list_ids: [2],
|
|
424
|
+
inherit_parent_restrictions: false,
|
|
425
|
+
};
|
|
426
|
+
const sublane7: LaneData = {
|
|
427
|
+
id: 7,
|
|
428
|
+
display_name: "sublane 7",
|
|
429
|
+
visible: true,
|
|
430
|
+
count: 6,
|
|
431
|
+
sublanes: [],
|
|
432
|
+
custom_list_ids: [2],
|
|
433
|
+
inherit_parent_restrictions: false,
|
|
434
|
+
};
|
|
435
|
+
const sublane5: LaneData = {
|
|
436
|
+
id: 5,
|
|
437
|
+
display_name: "sublane 5",
|
|
438
|
+
visible: true,
|
|
439
|
+
count: 6,
|
|
440
|
+
sublanes: [sublane6, sublane7],
|
|
441
|
+
custom_list_ids: [2],
|
|
442
|
+
inherit_parent_restrictions: false,
|
|
443
|
+
};
|
|
444
|
+
const lanes: LaneData[] = [
|
|
445
|
+
{
|
|
446
|
+
id: 1,
|
|
447
|
+
display_name: "lane 1",
|
|
448
|
+
visible: true,
|
|
449
|
+
count: 5,
|
|
450
|
+
sublanes: [sublane2, sublane5],
|
|
451
|
+
custom_list_ids: [1],
|
|
452
|
+
inherit_parent_restrictions: true,
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
id: 4,
|
|
456
|
+
display_name: "lane 4",
|
|
457
|
+
visible: true,
|
|
458
|
+
count: 1,
|
|
459
|
+
sublanes: [],
|
|
460
|
+
custom_list_ids: [],
|
|
461
|
+
inherit_parent_restrictions: false,
|
|
462
|
+
},
|
|
463
|
+
];
|
|
464
|
+
|
|
465
|
+
const { container } = renderSidebar({ lanes });
|
|
466
|
+
|
|
467
|
+
// Expand every collapsible lane so the whole tree is in the DOM.
|
|
468
|
+
for (let i = 0; i < 20; i++) {
|
|
469
|
+
const btns = Array.from(
|
|
470
|
+
container.querySelectorAll<HTMLElement>(".expand-button")
|
|
471
|
+
);
|
|
472
|
+
if (btns.length === 0) break;
|
|
473
|
+
for (const btn of btns) await user.click(btn);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const byName = (name: string) => laneParentByName(container, name);
|
|
477
|
+
|
|
478
|
+
// A lane is draggable only when it has a sibling (its parent group > 1).
|
|
479
|
+
expect(isDraggable(byName("lane 1"))).toBe(true);
|
|
480
|
+
expect(isDraggable(byName("lane 4"))).toBe(true);
|
|
481
|
+
expect(isDraggable(byName("sublane 2"))).toBe(true);
|
|
482
|
+
expect(isDraggable(byName("sublane 5"))).toBe(true);
|
|
483
|
+
expect(isDraggable(byName("SubSublane 1"))).toBe(false);
|
|
484
|
+
expect(isDraggable(byName("sublane 6"))).toBe(true);
|
|
485
|
+
expect(isDraggable(byName("sublane 7"))).toBe(true);
|
|
486
|
+
expect(isDraggable(byName("sublane 8"))).toBe(false);
|
|
487
|
+
|
|
488
|
+
// The tree renders each lane's direct children.
|
|
489
|
+
expect(directSublanes(byName("lane 1"))).toHaveLength(2); // 2 and 5
|
|
490
|
+
expect(directSublanes(byName("lane 4"))).toHaveLength(0);
|
|
491
|
+
expect(directSublanes(byName("sublane 2"))).toHaveLength(1); // SubSublane 1
|
|
492
|
+
expect(directSublanes(byName("sublane 5"))).toHaveLength(2); // 6 and 7
|
|
493
|
+
expect(directSublanes(byName("sublane 6"))).toHaveLength(1); // 8
|
|
494
|
+
expect(directSublanes(byName("sublane 7"))).toHaveLength(0);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// A tree with two sibling parent groups — each with >1 child, so each renders a
|
|
498
|
+
// nested drop target — plus the top-level list. The mocks stand in for Lanes:
|
|
499
|
+
// onDragStart reports the dragged id, and findLaneForIdentifier resolves the
|
|
500
|
+
// lane from it, so nothing is "in flight" until the sidebar reports a drag.
|
|
501
|
+
const renderDropBoundary = async (
|
|
502
|
+
user: ReturnType<typeof userEvent.setup>
|
|
503
|
+
) => {
|
|
504
|
+
const child = (id: number, name: string): LaneData => ({
|
|
505
|
+
id,
|
|
506
|
+
display_name: name,
|
|
507
|
+
visible: true,
|
|
508
|
+
count: 0,
|
|
509
|
+
sublanes: [],
|
|
510
|
+
custom_list_ids: [],
|
|
511
|
+
inherit_parent_restrictions: false,
|
|
512
|
+
});
|
|
513
|
+
const parentA: LaneData = {
|
|
514
|
+
id: 1,
|
|
515
|
+
display_name: "Parent A",
|
|
516
|
+
visible: true,
|
|
517
|
+
count: 0,
|
|
518
|
+
sublanes: [child(11, "A-1"), child(12, "A-2")],
|
|
519
|
+
custom_list_ids: [],
|
|
520
|
+
inherit_parent_restrictions: true,
|
|
521
|
+
};
|
|
522
|
+
const parentB: LaneData = {
|
|
523
|
+
id: 2,
|
|
524
|
+
display_name: "Parent B",
|
|
525
|
+
visible: true,
|
|
526
|
+
count: 0,
|
|
527
|
+
sublanes: [child(21, "B-1"), child(22, "B-2")],
|
|
528
|
+
custom_list_ids: [],
|
|
529
|
+
inherit_parent_restrictions: true,
|
|
530
|
+
};
|
|
531
|
+
const lanes = [parentA, parentB];
|
|
532
|
+
const allLanes = [...lanes, ...parentA.sublanes, ...parentB.sublanes];
|
|
533
|
+
|
|
534
|
+
let draggingId: string = null;
|
|
535
|
+
const drag = jest.fn(({ draggableId }) => {
|
|
536
|
+
draggingId = draggableId;
|
|
537
|
+
});
|
|
538
|
+
const findLaneForIdentifier = jest.fn(() =>
|
|
539
|
+
draggingId
|
|
540
|
+
? allLanes.find((l) => String(l.id) === String(draggingId))
|
|
541
|
+
: null
|
|
542
|
+
);
|
|
543
|
+
const findParentOfLane = jest.fn(
|
|
544
|
+
(lane: LaneData) =>
|
|
545
|
+
lanes.find((p) => p.sublanes.some((s) => s.id === lane?.id)) ?? null
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
const { container, rerenderWith } = renderSidebar({
|
|
549
|
+
lanes,
|
|
550
|
+
drag,
|
|
551
|
+
findLaneForIdentifier,
|
|
552
|
+
findParentOfLane,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Expand both parents so their nested drop targets render.
|
|
556
|
+
for (let i = 0; i < 20; i++) {
|
|
557
|
+
const btns = Array.from(
|
|
558
|
+
container.querySelectorAll<HTMLElement>(".expand-button")
|
|
559
|
+
);
|
|
560
|
+
if (btns.length === 0) break;
|
|
561
|
+
for (const btn of btns) await user.click(btn);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const byId = (id: string) =>
|
|
565
|
+
getHandlers().droppables.find((d: any) => d.droppableId === id);
|
|
566
|
+
|
|
567
|
+
// Report a drag start, then let the (stand-in) parent re-render the sidebar
|
|
568
|
+
// with that lane in flight — the same round trip the real Lanes makes.
|
|
569
|
+
const startDrag = (draggableId: string, from: string) => {
|
|
570
|
+
getHandlers().onDragStart({
|
|
571
|
+
draggableId,
|
|
572
|
+
source: { index: 0, droppableId: from },
|
|
573
|
+
});
|
|
574
|
+
rerenderWith();
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
return { byId, drag, startDrag };
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
it("enables dropping a sublane only within its own parent", async () => {
|
|
581
|
+
const user = userEvent.setup();
|
|
582
|
+
const { byId, drag, startDrag } = await renderDropBoundary(user);
|
|
583
|
+
|
|
584
|
+
// Before any drag, every list accepts drops.
|
|
585
|
+
expect(byId("1")?.isDropDisabled).toBe(false);
|
|
586
|
+
expect(byId("2")?.isDropDisabled).toBe(false);
|
|
587
|
+
expect(byId("top")?.isDropDisabled).toBe(false);
|
|
588
|
+
|
|
589
|
+
// Drag A-1, a child of Parent A.
|
|
590
|
+
startDrag("11", "1");
|
|
591
|
+
expect(drag).toHaveBeenCalledWith({
|
|
592
|
+
draggableId: "11",
|
|
593
|
+
draggingFrom: "1",
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Only its own parent accepts the drop; the sibling parent and the
|
|
597
|
+
// cross-level top list reject it.
|
|
598
|
+
expect(byId("1")?.isDropDisabled).toBe(false);
|
|
599
|
+
expect(byId("2")?.isDropDisabled).toBe(true);
|
|
600
|
+
expect(byId("top")?.isDropDisabled).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("keeps a dragged top-level lane in the top-level list", async () => {
|
|
604
|
+
const user = userEvent.setup();
|
|
605
|
+
const { byId, drag, startDrag } = await renderDropBoundary(user);
|
|
606
|
+
|
|
607
|
+
// Drag Parent A itself — a top-level lane, so it has no parent. This is the
|
|
608
|
+
// mirror of the sublane case: it may be reordered among the top-level lanes,
|
|
609
|
+
// but must not be droppable into any parent's sublane list (which would
|
|
610
|
+
// persist a top-level lane underneath another lane).
|
|
611
|
+
startDrag("1", "top");
|
|
612
|
+
expect(drag).toHaveBeenCalledWith({
|
|
613
|
+
draggableId: "1",
|
|
614
|
+
draggingFrom: "top",
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
expect(byId("top")?.isDropDisabled).toBe(false);
|
|
618
|
+
expect(byId("1")?.isDropDisabled).toBe(true);
|
|
619
|
+
expect(byId("2")?.isDropDisabled).toBe(true);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import Libraries from "../../../src/components/Libraries";
|
|
5
|
+
import { renderWithProviders } from "../testUtils/withProviders";
|
|
6
|
+
import buildStore from "../../../src/store";
|
|
7
|
+
|
|
8
|
+
// The connected default export is what the app mounts, so render that rather
|
|
9
|
+
// than the bare component: it is the only thing that exercises Libraries'
|
|
10
|
+
// mapStateToProps/mapDispatchToProps.
|
|
11
|
+
describe("Libraries - connected wiring", () => {
|
|
12
|
+
afterEach(() => jest.restoreAllMocks());
|
|
13
|
+
|
|
14
|
+
it("wires mapStateToProps/mapDispatchToProps and renders the fetched list", async () => {
|
|
15
|
+
jest.spyOn(globalThis, "fetch").mockImplementation(
|
|
16
|
+
async () =>
|
|
17
|
+
new Response(
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
libraries: [
|
|
20
|
+
{
|
|
21
|
+
uuid: "uuid-nypl",
|
|
22
|
+
short_name: "nypl",
|
|
23
|
+
name: "New York Public Library",
|
|
24
|
+
settings: {},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}),
|
|
28
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
renderWithProviders(<Libraries csrfToken="token" />, {
|
|
33
|
+
reduxProviderProps: { store: buildStore() },
|
|
34
|
+
appConfigSettings: { roles: [{ role: "system" }] },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The library and its edit control appear once the on-mount fetch resolves
|
|
38
|
+
// and mapStateToProps feeds the fetched data back in as props.
|
|
39
|
+
expect(
|
|
40
|
+
await screen.findByText("New York Public Library")
|
|
41
|
+
).toBeInTheDocument();
|
|
42
|
+
const editLink = await screen.findByRole("link", { name: /edit/i });
|
|
43
|
+
expect(editLink).toHaveAttribute(
|
|
44
|
+
"href",
|
|
45
|
+
"/admin/web/config/libraries/edit/uuid-nypl"
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|