@thepalaceproject/circulation-admin 1.43.0 → 1.44.0

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,446 @@
1
+ import * as React from "react";
2
+ import {
3
+ render,
4
+ screen,
5
+ within,
6
+ waitFor,
7
+ fireEvent,
8
+ act,
9
+ } from "@testing-library/react";
10
+ import userEvent from "@testing-library/user-event";
11
+
12
+ import { Lanes } from "../../../src/components/Lanes";
13
+ import { LaneData, CustomListData } from "../../../src/interfaces";
14
+
15
+ // Render react-router's Link as a marker so the lane tree and editor links
16
+ // render without a Router in context (see Lane.test.tsx / CustomListsSidebar.test.tsx).
17
+ jest.mock("react-router", () => ({
18
+ ...jest.requireActual("react-router"),
19
+ Link: (props) => (
20
+ <div data-testid="Link" data-to={props.to} className={props.className}>
21
+ {props.children}
22
+ </div>
23
+ ),
24
+ }));
25
+
26
+ // NOTE ON DRAG-AND-DROP COVERAGE
27
+ // ------------------------------
28
+ // `Lanes` reorders lanes with `react-beautiful-dnd`, and a real drag/drop cannot
29
+ // be simulated in jsdom. The outcomes of a drag are still covered:
30
+ // - `orderChanged` (and so the "Change Lane Order" panel), saving the reordered
31
+ // tree via `changeLaneOrder`, and cancelling it via `resetOrder` are all
32
+ // exercised in the "Lanes - reorder flow" describe below, which calls
33
+ // `Lanes.drag(...)` through a ref — the same callback LanesSidebar invokes on
34
+ // a real drop.
35
+ // - The drop boundary (a lane may only be dropped within its own parent) lives
36
+ // in LanesSidebar and is asserted in LanesSidebar.test.tsx.
37
+ // Only the pointer-level gestures themselves ("drags a top-level lane", "drags a
38
+ // sublane") have no jsdom-reachable trigger; the tree structure and expand/collapse
39
+ // behavior they produce are asserted instead (see "renders the draggable lane tree"
40
+ // and "expands and collapses lanes").
41
+ //
42
+ // The connected default export is not rendered here: it mounts with real
43
+ // dispatch props (fetchLanes/fetchCustomLists fire a network request on mount,
44
+ // which would reject and crash the worker) and would override the explicit
45
+ // `lanes`/`customLists`/dispatch stubs these behavior tests assert against.
46
+
47
+ const makeLanesData = (): LaneData[] => {
48
+ const subsublane: LaneData = {
49
+ id: 3,
50
+ display_name: "sublane 3",
51
+ visible: false,
52
+ count: 2,
53
+ sublanes: [],
54
+ custom_list_ids: [2],
55
+ inherit_parent_restrictions: false,
56
+ };
57
+ const sublane: LaneData = {
58
+ id: 2,
59
+ display_name: "sublane 2",
60
+ visible: false,
61
+ count: 3,
62
+ sublanes: [subsublane],
63
+ custom_list_ids: [2],
64
+ inherit_parent_restrictions: false,
65
+ };
66
+ return [
67
+ {
68
+ id: 1,
69
+ display_name: "lane 1",
70
+ visible: true,
71
+ count: 5,
72
+ sublanes: [sublane],
73
+ custom_list_ids: [1],
74
+ inherit_parent_restrictions: true,
75
+ },
76
+ {
77
+ id: 4,
78
+ display_name: "lane 4",
79
+ visible: true,
80
+ count: 1,
81
+ sublanes: [],
82
+ custom_list_ids: [],
83
+ inherit_parent_restrictions: false,
84
+ },
85
+ ];
86
+ };
87
+
88
+ const customListsData: CustomListData[] = [
89
+ { id: 1, name: "list 1", entry_count: 0, is_owner: true, is_shared: false },
90
+ { id: 2, name: "list 2", entry_count: 2, is_owner: true, is_shared: false },
91
+ ];
92
+
93
+ describe("Lanes", () => {
94
+ let fetchLanes: jest.Mock;
95
+ let fetchCustomLists: jest.Mock;
96
+ let editLane: jest.Mock;
97
+ let deleteLane: jest.Mock;
98
+ let showLane: jest.Mock;
99
+ let hideLane: jest.Mock;
100
+ let resetLanes: jest.Mock;
101
+ let changeLaneOrder: jest.Mock;
102
+ let confirmSpy: jest.SpyInstance;
103
+ let lanesData: LaneData[];
104
+
105
+ const makeProps = (overrides: Record<string, unknown> = {}) => ({
106
+ csrfToken: "token",
107
+ library: "library",
108
+ lanes: lanesData,
109
+ customLists: customListsData,
110
+ isFetching: false,
111
+ fetchLanes,
112
+ fetchCustomLists,
113
+ editLane,
114
+ deleteLane,
115
+ showLane,
116
+ hideLane,
117
+ resetLanes,
118
+ changeLaneOrder,
119
+ ...overrides,
120
+ });
121
+
122
+ beforeEach(() => {
123
+ lanesData = makeLanesData();
124
+ fetchLanes = jest.fn();
125
+ fetchCustomLists = jest.fn();
126
+ editLane = jest.fn().mockResolvedValue(undefined);
127
+ deleteLane = jest.fn().mockResolvedValue(undefined);
128
+ showLane = jest.fn().mockResolvedValue(undefined);
129
+ hideLane = jest.fn().mockResolvedValue(undefined);
130
+ resetLanes = jest.fn().mockResolvedValue(undefined);
131
+ changeLaneOrder = jest.fn().mockResolvedValue(undefined);
132
+ confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(true);
133
+ });
134
+
135
+ afterEach(() => {
136
+ jest.restoreAllMocks();
137
+ });
138
+
139
+ it("renders an error message from a bad form submission", () => {
140
+ const { container, rerender } = render(<Lanes {...makeProps()} />);
141
+ expect(container.querySelector(".alert-danger")).toBeNull();
142
+
143
+ rerender(
144
+ <Lanes
145
+ {...makeProps({
146
+ formError: { status: 500, response: "Error", url: "url" },
147
+ })}
148
+ />
149
+ );
150
+ expect(container.querySelector(".alert-danger")).toBeInTheDocument();
151
+ });
152
+
153
+ it("renders an error message from a loading error", () => {
154
+ const { container, rerender } = render(<Lanes {...makeProps()} />);
155
+ expect(container.querySelector(".alert-danger")).toBeNull();
156
+
157
+ rerender(
158
+ <Lanes
159
+ {...makeProps({
160
+ fetchError: { status: 500, response: "Error", url: "url" },
161
+ })}
162
+ />
163
+ );
164
+ expect(container.querySelector(".alert-danger")).toBeInTheDocument();
165
+ });
166
+
167
+ it("renders a loading indicator while fetching", () => {
168
+ const { container, rerender } = render(<Lanes {...makeProps()} />);
169
+ expect(container.querySelector(".loading")).toBeNull();
170
+
171
+ rerender(<Lanes {...makeProps({ isFetching: true })} />);
172
+ expect(container.querySelector(".loading")).toBeInTheDocument();
173
+ });
174
+
175
+ it("renders the create editor by default", () => {
176
+ const { rerender } = render(<Lanes {...makeProps()} />);
177
+
178
+ expect(screen.getByText("New top-level lane")).toBeInTheDocument();
179
+ expect(
180
+ screen.getByRole("button", { name: "Save lane" })
181
+ ).toBeInTheDocument();
182
+
183
+ // With an identifier, the create editor makes a sublane of that lane.
184
+ rerender(<Lanes {...makeProps({ identifier: "2" })} />);
185
+ expect(screen.getByText("New sublane of sublane 2")).toBeInTheDocument();
186
+ });
187
+
188
+ it("renders the edit editor for a lane", () => {
189
+ const { container } = render(
190
+ <Lanes {...makeProps({ editOrCreate: "edit", identifier: "2" })} />
191
+ );
192
+
193
+ // The editor header identifies the lane being edited (id 2).
194
+ expect(container.querySelector(".save-or-edit")).toHaveTextContent("ID-2");
195
+ expect(
196
+ screen.getByRole("button", { name: "Save lane" })
197
+ ).toBeInTheDocument();
198
+ expect(
199
+ screen.getByRole("button", { name: /Delete lane/ })
200
+ ).toBeInTheDocument();
201
+ expect(
202
+ container.querySelector(".lane-custom-lists-drag-and-drop")
203
+ ).toBeInTheDocument();
204
+ });
205
+
206
+ it("renders the reset confirmation form", () => {
207
+ render(<Lanes {...makeProps({ editOrCreate: "reset" })} />);
208
+
209
+ expect(
210
+ screen.getByRole("heading", { name: "Reset all lanes" })
211
+ ).toBeInTheDocument();
212
+ expect(screen.getByText(/cannot be undone/)).toBeInTheDocument();
213
+ });
214
+
215
+ it("enables the reset button only after typing RESET", async () => {
216
+ const user = userEvent.setup();
217
+ const { container } = render(
218
+ <Lanes {...makeProps({ editOrCreate: "reset" })} />
219
+ );
220
+
221
+ const resetButton = container.querySelector<HTMLButtonElement>(
222
+ "button.reset-button"
223
+ );
224
+ const input = container.querySelector<HTMLInputElement>(".reset input");
225
+
226
+ expect(resetButton).toBeDisabled();
227
+
228
+ await user.type(input, "RESET");
229
+ expect(resetButton).not.toBeDisabled();
230
+ });
231
+
232
+ it("resets the lanes only when RESET is confirmed", async () => {
233
+ const user = userEvent.setup();
234
+ const { container } = render(
235
+ <Lanes {...makeProps({ editOrCreate: "reset" })} />
236
+ );
237
+
238
+ const resetButton = container.querySelector<HTMLButtonElement>(
239
+ "button.reset-button"
240
+ );
241
+ const input = container.querySelector<HTMLInputElement>(".reset input");
242
+
243
+ // The wrong confirmation text leaves the button disabled and does nothing.
244
+ await user.type(input, "NOPE");
245
+ expect(resetButton).toBeDisabled();
246
+ fireEvent.click(resetButton);
247
+ expect(resetLanes).not.toHaveBeenCalled();
248
+
249
+ // Typing RESET enables the button and resets on click.
250
+ await user.clear(input);
251
+ await user.type(input, "RESET");
252
+ expect(resetButton).not.toBeDisabled();
253
+
254
+ await user.click(resetButton);
255
+ expect(resetLanes).toHaveBeenCalledTimes(1);
256
+ // fetchLanes runs once on mount and again after the reset resolves.
257
+ await waitFor(() => expect(fetchLanes).toHaveBeenCalledTimes(2));
258
+ });
259
+
260
+ it("edits a lane through the editor", async () => {
261
+ const user = userEvent.setup();
262
+ render(<Lanes {...makeProps({ editOrCreate: "edit", identifier: "2" })} />);
263
+
264
+ await user.click(screen.getByRole("button", { name: "Save lane" }));
265
+
266
+ expect(editLane).toHaveBeenCalledTimes(1);
267
+ await waitFor(() => expect(fetchLanes).toHaveBeenCalledTimes(2));
268
+ });
269
+
270
+ it("deletes a lane through the editor", async () => {
271
+ const user = userEvent.setup();
272
+ render(<Lanes {...makeProps({ editOrCreate: "edit", identifier: "2" })} />);
273
+
274
+ const deleteButton = screen.getByRole("button", { name: /Delete lane/ });
275
+
276
+ // Declining the confirmation does not delete.
277
+ confirmSpy.mockReturnValue(false);
278
+ await user.click(deleteButton);
279
+ expect(deleteLane).not.toHaveBeenCalled();
280
+
281
+ // Confirming deletes the lane and refetches.
282
+ confirmSpy.mockReturnValue(true);
283
+ await user.click(deleteButton);
284
+ expect(deleteLane).toHaveBeenCalledTimes(1);
285
+ expect(deleteLane).toHaveBeenCalledWith("2");
286
+ await waitFor(() => expect(fetchLanes).toHaveBeenCalledTimes(2));
287
+ });
288
+
289
+ it("renders the draggable lane tree", () => {
290
+ const { container } = render(<Lanes {...makeProps()} />);
291
+
292
+ const sidebar = container.querySelector<HTMLElement>(".lanes-sidebar");
293
+ expect(sidebar).toBeInTheDocument();
294
+
295
+ // The two top-level lanes render inside a droppable list, each as a
296
+ // draggable row.
297
+ expect(sidebar.querySelector("ul.droppable")).toBeInTheDocument();
298
+ expect(sidebar.querySelectorAll(".lane-info.draggable")).toHaveLength(2);
299
+ expect(within(sidebar).getByText("lane 1")).toBeInTheDocument();
300
+ expect(within(sidebar).getByText("lane 4")).toBeInTheDocument();
301
+ });
302
+
303
+ it("expands and collapses lanes to reveal sublanes", async () => {
304
+ const user = userEvent.setup();
305
+ const { container } = render(<Lanes {...makeProps()} />);
306
+ const sidebar = container.querySelector<HTMLElement>(".lanes-sidebar");
307
+
308
+ // Lane 1 starts expanded (so sublane 2 shows), but sublane 2 starts
309
+ // collapsed (so sublane 3 is hidden).
310
+ expect(within(sidebar).getByText("sublane 2")).toBeInTheDocument();
311
+ expect(within(sidebar).queryByText("sublane 3")).toBeNull();
312
+
313
+ const sublane2 = within(sidebar)
314
+ .getByText("sublane 2")
315
+ .closest(".lane-parent") as HTMLElement;
316
+ await user.click(sublane2.querySelector("button.expand-button"));
317
+ expect(within(sidebar).getByText("sublane 3")).toBeInTheDocument();
318
+
319
+ // Collapsing lane 1 hides all of its sublanes.
320
+ const lane1 = within(sidebar)
321
+ .getByText("lane 1")
322
+ .closest(".lane-parent") as HTMLElement;
323
+ await user.click(lane1.querySelector("button.collapse-button"));
324
+ expect(within(sidebar).queryByText("sublane 2")).toBeNull();
325
+ });
326
+ });
327
+
328
+ // The lane-reorder flow. `Lanes.drag` is the callback LanesSidebar invokes on a
329
+ // real drop (which jsdom can't perform), so we call it through a ref to drive the
330
+ // order-changed UI, then exercise save/reset and findParentOfLane.
331
+ describe("Lanes - reorder flow", () => {
332
+ const makeLanesData = (): LaneData[] => {
333
+ const subsublane: LaneData = {
334
+ id: 3,
335
+ display_name: "sublane 3",
336
+ visible: false,
337
+ count: 2,
338
+ sublanes: [],
339
+ custom_list_ids: [2],
340
+ inherit_parent_restrictions: false,
341
+ };
342
+ const sublane: LaneData = {
343
+ id: 2,
344
+ display_name: "sublane 2",
345
+ visible: false,
346
+ count: 3,
347
+ sublanes: [subsublane],
348
+ custom_list_ids: [2],
349
+ inherit_parent_restrictions: false,
350
+ };
351
+ return [
352
+ {
353
+ id: 1,
354
+ display_name: "lane 1",
355
+ visible: true,
356
+ count: 5,
357
+ sublanes: [sublane],
358
+ custom_list_ids: [1],
359
+ inherit_parent_restrictions: true,
360
+ },
361
+ ];
362
+ };
363
+
364
+ const props = (overrides: Record<string, unknown> = {}) => ({
365
+ csrfToken: "token",
366
+ library: "library",
367
+ lanes: makeLanesData(),
368
+ customLists: [],
369
+ isFetching: false,
370
+ fetchLanes: jest.fn(),
371
+ fetchCustomLists: jest.fn(),
372
+ editLane: jest.fn(),
373
+ deleteLane: jest.fn(),
374
+ showLane: jest.fn(),
375
+ hideLane: jest.fn(),
376
+ resetLanes: jest.fn(),
377
+ changeLaneOrder: jest.fn().mockResolvedValue(undefined),
378
+ ...overrides,
379
+ });
380
+
381
+ it("shows the reorder UI after a drag, then saves the reordered lanes and resets", async () => {
382
+ const user = userEvent.setup();
383
+ const changeLaneOrder = jest.fn().mockResolvedValue(undefined);
384
+ const fetchLanes = jest.fn();
385
+ const ref = React.createRef<any>();
386
+ // Two top-level lanes, so a reorder is observable in the saved payload.
387
+ const secondLane: LaneData = {
388
+ id: 4,
389
+ display_name: "lane 4",
390
+ visible: true,
391
+ count: 1,
392
+ sublanes: [],
393
+ custom_list_ids: [3],
394
+ inherit_parent_restrictions: true,
395
+ };
396
+ const initialLanes = [...makeLanesData(), secondLane];
397
+ const { container } = render(
398
+ <Lanes
399
+ ref={ref}
400
+ {...props({ changeLaneOrder, fetchLanes, lanes: initialLanes })}
401
+ />
402
+ );
403
+
404
+ // A drop hands the reordered tree to Lanes.drag (what LanesSidebar does),
405
+ // which stores it and flags orderChanged.
406
+ const originalOrder = ref.current.state.lanes;
407
+ const reordered = [...originalOrder].reverse();
408
+ act(() => ref.current.drag({ lanes: reordered, orderChanged: true }));
409
+ expect(container.querySelector(".order-change-info")).toBeInTheDocument();
410
+
411
+ fetchLanes.mockClear(); // ignore the fetch fired on mount
412
+ await user.click(
413
+ container.querySelector("button.save-lane-order-changes") as HTMLElement
414
+ );
415
+ // The exact reordered tree is persisted — not the original order.
416
+ expect(changeLaneOrder).toHaveBeenCalledTimes(1);
417
+ expect(changeLaneOrder).toHaveBeenCalledWith(reordered);
418
+ expect(changeLaneOrder.mock.calls[0][0]).not.toEqual(originalOrder);
419
+ // saveOrder() only fetches once changeLaneOrder resolves.
420
+ await waitFor(() => expect(fetchLanes).toHaveBeenCalledTimes(1));
421
+
422
+ // With a reorder pending in state, cancelling runs resetOrder(), which
423
+ // restores state.lanes from props and clears the panel.
424
+ act(() => ref.current.drag({ lanes: reordered, orderChanged: true }));
425
+ expect(ref.current.state.lanes).toEqual(reordered);
426
+
427
+ await user.click(
428
+ container.querySelector("button.cancel-lane-order-changes") as HTMLElement
429
+ );
430
+
431
+ expect(ref.current.state.lanes).toEqual(initialLanes);
432
+ expect(
433
+ container.querySelector(".order-change-info")
434
+ ).not.toBeInTheDocument();
435
+ });
436
+
437
+ it("finds the parent of a deeply nested lane", () => {
438
+ const ref = React.createRef<any>();
439
+ render(<Lanes ref={ref} {...props()} />);
440
+
441
+ // The subsublane (id 3) is a child of sublane 2, not a top-level lane, so the
442
+ // lookup recurses one level down.
443
+ const subsublane = { id: 3 } as LaneData;
444
+ expect(ref.current.findParentOfLane(subsublane).id).toBe(2);
445
+ });
446
+ });