@thepalaceproject/circulation-admin 1.43.0 → 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.
Files changed (133) hide show
  1. package/CLAUDE.md +28 -7
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/eslint.config.mjs +9 -1
  5. package/jest.config.js +22 -8
  6. package/package.json +6 -20
  7. package/tests/jest/actions.test.ts +962 -0
  8. package/tests/jest/components/AccountPage.test.tsx +41 -0
  9. package/tests/jest/components/AdvancedSearchBooleanFilter.test.tsx +231 -0
  10. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +93 -20
  11. package/tests/jest/components/AdvancedSearchFilter.test.tsx +142 -0
  12. package/tests/jest/components/AdvancedSearchFilterInput.test.tsx +212 -0
  13. package/tests/jest/components/AdvancedSearchFilterViewer.test.tsx +52 -0
  14. package/tests/jest/components/AdvancedSearchValueFilter.test.tsx +123 -0
  15. package/tests/jest/components/Announcement.test.tsx +76 -0
  16. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  17. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  18. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  19. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  20. package/tests/jest/components/BookDetails.test.tsx +133 -0
  21. package/tests/jest/components/BookDetailsContainer.test.tsx +116 -0
  22. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  23. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  24. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  25. package/tests/jest/components/BookEditor.test.tsx +20 -21
  26. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  27. package/tests/jest/components/CatalogServices.test.tsx +100 -0
  28. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  29. package/tests/jest/components/CirculationEventsDownloadForm.test.tsx +41 -0
  30. package/tests/jest/components/Classifications.test.tsx +166 -0
  31. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  32. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  33. package/tests/jest/components/Collections.test.tsx +335 -7
  34. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  35. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  36. package/tests/jest/components/Complaints.test.tsx +259 -0
  37. package/tests/jest/components/ConfigPage.test.tsx +58 -0
  38. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  39. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  40. package/tests/jest/components/Contributors.test.tsx +157 -0
  41. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  42. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  43. package/tests/jest/components/CustomListPage.test.tsx +57 -0
  44. package/tests/jest/components/CustomListSearch.test.tsx +275 -0
  45. package/tests/jest/components/CustomLists.test.tsx +621 -4
  46. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  47. package/tests/jest/components/CustomListsSidebar.test.tsx +251 -0
  48. package/tests/jest/components/DashboardPage.test.tsx +76 -0
  49. package/tests/jest/components/DebugAuthentication.test.tsx +1 -5
  50. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  51. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  52. package/tests/jest/components/DiagnosticsTabContainer.test.tsx +147 -0
  53. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  54. package/tests/jest/components/EditableConfigList.test.tsx +606 -9
  55. package/tests/jest/components/EditableInput.test.tsx +250 -1
  56. package/tests/jest/components/EditorField.test.tsx +95 -0
  57. package/tests/jest/components/EntryPointsContainer.test.tsx +97 -0
  58. package/tests/jest/components/EntryPointsTabs.test.tsx +88 -0
  59. package/tests/jest/components/ErrorMessage.test.tsx +97 -0
  60. package/tests/jest/components/Footer.test.tsx +141 -0
  61. package/tests/jest/components/GenreForm.test.tsx +152 -0
  62. package/tests/jest/components/Header.test.tsx +421 -0
  63. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  64. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  65. package/tests/jest/components/InputList.test.tsx +493 -0
  66. package/tests/jest/components/InventoryReportRequestModal.test.tsx +16 -42
  67. package/tests/jest/components/JsonField.test.tsx +20 -0
  68. package/tests/jest/components/Lane.test.tsx +132 -10
  69. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  70. package/tests/jest/components/LaneEditor.test.tsx +377 -13
  71. package/tests/jest/components/LanePage.test.tsx +68 -0
  72. package/tests/jest/components/Lanes.test.tsx +446 -0
  73. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  74. package/tests/jest/components/LanguageField.test.tsx +40 -0
  75. package/tests/jest/components/Libraries.test.tsx +48 -0
  76. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  77. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  78. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  79. package/tests/jest/components/LoadButton.test.tsx +49 -0
  80. package/tests/jest/components/ManagePatrons.test.tsx +73 -0
  81. package/tests/jest/components/ManagePatronsForm.test.tsx +180 -0
  82. package/tests/jest/components/ManagePatronsTabContainer.test.tsx +143 -0
  83. package/tests/jest/components/MetadataServices.test.tsx +95 -0
  84. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  85. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  86. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  87. package/tests/jest/components/PatronBlockingRulesEditor.test.tsx +3 -1
  88. package/tests/jest/components/PatronInfo.test.tsx +54 -0
  89. package/tests/jest/components/ProtocolFormField.test.tsx +479 -7
  90. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  91. package/tests/jest/components/SaveButton.test.tsx +47 -0
  92. package/tests/jest/components/SelfTestResult.test.tsx +164 -0
  93. package/tests/jest/components/SelfTests.test.tsx +601 -0
  94. package/tests/jest/components/SelfTestsCategory.test.tsx +200 -0
  95. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  96. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  97. package/tests/jest/components/ServiceWithRegistrationsEditForm.test.tsx +110 -0
  98. package/tests/jest/components/SetupPage.test.tsx +41 -0
  99. package/tests/jest/components/SingleStatListItem.test.tsx +62 -0
  100. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  101. package/tests/jest/components/Stats.test.tsx +4 -15
  102. package/tests/jest/components/TabContainer.test.tsx +68 -0
  103. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  104. package/tests/jest/components/Timestamp.test.tsx +74 -0
  105. package/tests/jest/components/ToolTip.test.tsx +46 -0
  106. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  107. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  108. package/tests/jest/components/TroubleshootingTabContainer.test.tsx +68 -0
  109. package/tests/jest/components/UpdatingLoader.test.tsx +36 -0
  110. package/tests/jest/components/WelcomePage.test.tsx +43 -0
  111. package/tests/jest/components/WithEditButton.test.tsx +55 -0
  112. package/tests/jest/components/WithRemoveButton.test.tsx +95 -0
  113. package/tests/jest/editorAdapter.test.ts +245 -0
  114. package/tests/jest/features/book.test.ts +75 -80
  115. package/tests/jest/fixtures/classificationsData.ts +45 -0
  116. package/tests/jest/fixtures/genreData.ts +811 -0
  117. package/tests/jest/index.test.tsx +50 -0
  118. package/tests/jest/models/Admin.test.ts +206 -0
  119. package/tests/jest/reducers/bookCoverPreview.test.ts +81 -0
  120. package/tests/jest/reducers/classifications.test.ts +161 -0
  121. package/tests/jest/reducers/collections.test.ts +96 -0
  122. package/tests/jest/reducers/complaints.test.ts +166 -0
  123. package/tests/jest/reducers/createFetchEditReducer.test.ts +220 -0
  124. package/tests/jest/reducers/createRegisterLibraryReducer.test.ts +51 -0
  125. package/tests/jest/reducers/customListDetails.test.ts +120 -0
  126. package/tests/jest/reducers/customListEditor.test.ts +2823 -0
  127. package/tests/jest/reducers/managePatrons.test.ts +71 -0
  128. package/tests/jest/testUtils/formDataShim.test.ts +135 -0
  129. package/tests/jest/testUtils/formDataShim.ts +112 -0
  130. package/tests/jest/utils/sharedFunctions.test.ts +121 -0
  131. package/tsconfig.json +3 -4
  132. package/testReporter.js +0 -29
  133. package/tests/jest/README.md +0 -6
@@ -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,40 @@
1
+ import * as React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+
4
+ import LanguageField from "../../../src/components/LanguageField";
5
+
6
+ describe("LanguageField", () => {
7
+ const languages = {
8
+ eng: ["English"],
9
+ es: ["Spanish", "Castilian"],
10
+ spa: ["Spanish", "Castilian"],
11
+ };
12
+
13
+ it("renders an autocomplete field", () => {
14
+ const { container } = render(
15
+ <LanguageField name="language" languages={languages} />
16
+ );
17
+
18
+ // An <input> with a `list` attribute has the implicit ARIA role "combobox".
19
+ const input = screen.getByRole("combobox");
20
+ expect(input).toHaveAttribute("name", "language");
21
+ expect(input).toHaveAttribute("list", "language-autocomplete-list");
22
+ expect(
23
+ container.querySelector("datalist#language-autocomplete-list")
24
+ ).toBeInTheDocument();
25
+ });
26
+
27
+ it("renders a unique, ordered list of language names as options", () => {
28
+ const { container } = render(
29
+ <LanguageField name="language" languages={languages} />
30
+ );
31
+
32
+ const options = Array.from(
33
+ container.querySelectorAll<HTMLOptionElement>(
34
+ "#language-autocomplete-list option"
35
+ )
36
+ ).map((option) => option.value);
37
+
38
+ expect(options).toStrictEqual(["English", "Spanish", "Castilian"]);
39
+ });
40
+ });