@thepalaceproject/circulation-admin 1.43.0-post.11 → 1.43.0-post.15

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.
@@ -0,0 +1,493 @@
1
+ import * as React from "react";
2
+ import { render, act, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import ProtocolFormField from "../../../src/components/ProtocolFormField";
6
+
7
+ // InputList is always rendered by ProtocolFormField, which supplies the real
8
+ // `createEditableInput` and `labelAndDescription` callbacks it depends on.
9
+ // Rendering through ProtocolFormField exercises that wiring while letting us
10
+ // assert the DOM the InputList actually produces. The imperative `getValue()` /
11
+ // `clear()` API is exposed through ProtocolFormField's ref, which is how
12
+ // production calls it.
13
+
14
+ const setting = {
15
+ key: "setting",
16
+ label: "label",
17
+ description: "description",
18
+ type: "list",
19
+ };
20
+ const value = ["Thing 1", "Thing 2"];
21
+
22
+ const renderList = (
23
+ { setting: s = setting, value: v = value, ...rest }: any = {},
24
+ ref?: React.Ref<ProtocolFormField>
25
+ ) =>
26
+ render(
27
+ <ProtocolFormField
28
+ ref={ref}
29
+ setting={s}
30
+ value={v}
31
+ disabled={false}
32
+ {...rest}
33
+ />
34
+ );
35
+
36
+ // The list-item inputs live in the `ul.input-list-ul`; the "add" input lives in
37
+ // `.add-list-item-container`.
38
+ const itemInputs = (container: HTMLElement) =>
39
+ Array.from(
40
+ container.querySelectorAll<HTMLInputElement>(
41
+ "ul.input-list-ul input.form-control"
42
+ )
43
+ );
44
+ const addInput = (container: HTMLElement) =>
45
+ container.querySelector<HTMLInputElement>(
46
+ ".add-list-item-container span.add-list-item input"
47
+ );
48
+ const addButton = (container: HTMLElement) =>
49
+ container.querySelector<HTMLButtonElement>("button.add-list-item");
50
+ const removeButtons = (container: HTMLElement) =>
51
+ container.querySelectorAll("button.remove-btn");
52
+ const listItemWrappers = (container: HTMLElement) =>
53
+ container.querySelectorAll(".with-remove-button");
54
+
55
+ describe("InputList", () => {
56
+ it("renders a label and description", () => {
57
+ const { container } = renderList();
58
+ const labels = container.querySelectorAll("label");
59
+ expect(labels).toHaveLength(1);
60
+ expect(labels[0]).toHaveTextContent("label");
61
+
62
+ // The first `.description` is the one produced by labelAndDescription.
63
+ const description = container.querySelectorAll(".description")[0];
64
+ expect(description).toHaveTextContent("description");
65
+ });
66
+
67
+ it("renders a list of items", () => {
68
+ const { container } = renderList();
69
+ const inputs =
70
+ container.querySelectorAll<HTMLInputElement>("input.form-control");
71
+ // Two existing items plus the empty "add" input.
72
+ expect(inputs).toHaveLength(3);
73
+
74
+ expect(inputs[0]).toHaveValue("Thing 1");
75
+ expect(inputs[0]).toHaveAttribute("type", "text");
76
+ expect(inputs[0]).toHaveAttribute("name", "setting");
77
+
78
+ expect(inputs[1]).toHaveValue("Thing 2");
79
+ expect(inputs[1]).toHaveAttribute("type", "text");
80
+ expect(inputs[1]).toHaveAttribute("name", "setting");
81
+
82
+ expect(inputs[2]).toHaveValue("");
83
+ expect(inputs[2]).toHaveAttribute("type", "text");
84
+ expect(inputs[2]).toHaveAttribute("name", "setting");
85
+ });
86
+
87
+ it("optionally renders links", () => {
88
+ const urlBase = (itemName: string) => `admin/web/${itemName}`;
89
+ const { container } = renderList({ setting: { ...setting, urlBase } });
90
+ const links = container.querySelectorAll("a");
91
+ expect(links).toHaveLength(2);
92
+ links.forEach((link, idx) => {
93
+ expect(link).toHaveTextContent(`Thing ${idx + 1}`);
94
+ expect(link).toHaveAttribute("href", `admin/web/Thing ${idx + 1}`);
95
+ });
96
+ });
97
+
98
+ it("renders WithRemoveButton elements", () => {
99
+ const { container } = renderList();
100
+ const wrappers = listItemWrappers(container);
101
+ expect(wrappers).toHaveLength(2);
102
+ const removes = removeButtons(container);
103
+ expect(removes).toHaveLength(2);
104
+ removes.forEach((btn) => expect(btn).not.toBeDisabled());
105
+
106
+ const inputs = itemInputs(container);
107
+ expect(inputs[0]).toHaveValue("Thing 1");
108
+ expect(inputs[1]).toHaveValue("Thing 2");
109
+ });
110
+
111
+ it("optionally disables the remove buttons", () => {
112
+ const { container } = renderList({ disableButton: true });
113
+ const removes = removeButtons(container);
114
+ expect(removes).toHaveLength(2);
115
+ removes.forEach((btn) => expect(btn).toBeDisabled());
116
+ });
117
+
118
+ it("renders a button for adding a list item", () => {
119
+ const { container } = renderList();
120
+ const addContainer = container.querySelector(".add-list-item-container");
121
+ expect(addContainer).toBeInTheDocument();
122
+ expect(addInput(container)).toHaveValue("");
123
+ // The add container itself has no remove buttons.
124
+ expect(addContainer.querySelectorAll(".with-remove-button")).toHaveLength(
125
+ 0
126
+ );
127
+ expect(addButton(container)).toHaveTextContent("Add");
128
+ });
129
+
130
+ it("optionally renders a geographic tooltip with extra content", () => {
131
+ const valueWithObject = [{ "Thing 3": "extra information!" }];
132
+ const geographicSetting = { ...setting, format: "geographic" };
133
+ const { container } = renderList({
134
+ setting: geographicSetting,
135
+ value: valueWithObject,
136
+ });
137
+
138
+ const withAddOn = container.querySelectorAll(".with-add-on");
139
+ expect(withAddOn).toHaveLength(1);
140
+
141
+ const addOn = withAddOn[0].querySelectorAll(".input-group-addon");
142
+ expect(addOn).toHaveLength(1);
143
+
144
+ expect(container.querySelector("svg.locatorIcon")).toBeInTheDocument();
145
+
146
+ const toolTip = container.querySelector(".tool-tip");
147
+ expect(toolTip).toHaveClass("point-right");
148
+ expect(toolTip).toHaveTextContent("extra information!");
149
+
150
+ const input = withAddOn[0].querySelector("input");
151
+ expect(input).toHaveValue("Thing 3");
152
+ });
153
+
154
+ it("renders an autocomplete field for languages", () => {
155
+ const languageSetting = { ...setting, format: "language-code" };
156
+ const languages = {
157
+ eng: ["English"],
158
+ spa: ["Spanish", "Castilian"],
159
+ };
160
+ const { container } = renderList({
161
+ setting: languageSetting,
162
+ value: ["abc"],
163
+ additionalData: languages,
164
+ });
165
+ const languageInputs = container.querySelectorAll<HTMLInputElement>(
166
+ 'input[list="setting-autocomplete-list"]'
167
+ );
168
+ // One for the existing "abc" item, one for the empty "add" field.
169
+ expect(languageInputs).toHaveLength(2);
170
+
171
+ expect(languageInputs[0]).toHaveValue("abc");
172
+ expect(languageInputs[0]).toHaveAttribute("name", "setting");
173
+
174
+ expect(languageInputs[1]).toHaveValue("");
175
+ expect(languageInputs[1]).toHaveAttribute("name", "setting");
176
+ });
177
+
178
+ it("removes an item", async () => {
179
+ const user = userEvent.setup();
180
+ const { container } = renderList();
181
+ expect(listItemWrappers(container)).toHaveLength(2);
182
+
183
+ await user.click(removeButtons(container)[0]);
184
+
185
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(1));
186
+ });
187
+
188
+ it("adds a regular item", async () => {
189
+ const user = userEvent.setup();
190
+ const { container } = renderList();
191
+ expect(listItemWrappers(container)).toHaveLength(2);
192
+
193
+ await user.type(addInput(container), "Another thing...");
194
+ await user.click(addButton(container));
195
+
196
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
197
+ expect(addInput(container)).toHaveValue("");
198
+ });
199
+
200
+ it("adds and capitalizes an item", async () => {
201
+ const user = userEvent.setup();
202
+ // capitalize only runs when the setting has capitalize set; the geographic
203
+ // format is what makes two-letter tokens uppercase.
204
+ const capSetting = { ...setting, capitalize: true, format: "geographic" };
205
+ const { container } = renderList({ setting: capSetting, value: [] });
206
+
207
+ await user.type(addInput(container), "california");
208
+ await user.click(addButton(container));
209
+ await waitFor(() =>
210
+ expect(itemInputs(container)[0]).toHaveValue("California")
211
+ );
212
+
213
+ await user.type(addInput(container), "fl");
214
+ await user.click(addButton(container));
215
+ await waitFor(() => expect(itemInputs(container)[1]).toHaveValue("FL"));
216
+
217
+ await user.type(addInput(container), "new york city, ny");
218
+ await user.click(addButton(container));
219
+ await waitFor(() =>
220
+ expect(itemInputs(container)[2]).toHaveValue("New York City, NY")
221
+ );
222
+
223
+ expect(addInput(container)).toHaveValue("");
224
+ });
225
+
226
+ it("adds an autocompleted item", async () => {
227
+ const user = userEvent.setup();
228
+ const languageSetting = { ...setting, format: "language-code" };
229
+ const { container } = renderList({
230
+ setting: languageSetting,
231
+ value: ["abc"],
232
+ });
233
+
234
+ let autocompletes = container.querySelectorAll<HTMLInputElement>(
235
+ 'input[list="setting-autocomplete-list"]'
236
+ );
237
+ expect(autocompletes).toHaveLength(2);
238
+
239
+ await user.type(autocompletes[1], "Another language");
240
+ await user.click(addButton(container));
241
+
242
+ await waitFor(() => {
243
+ autocompletes = container.querySelectorAll<HTMLInputElement>(
244
+ 'input[list="setting-autocomplete-list"]'
245
+ );
246
+ expect(autocompletes).toHaveLength(3);
247
+ });
248
+ expect(autocompletes[2]).toHaveValue("");
249
+ });
250
+
251
+ it("does not add an empty input item", async () => {
252
+ const user = userEvent.setup();
253
+ const { container } = renderList();
254
+ expect(listItemWrappers(container)).toHaveLength(2);
255
+ // The add button is disabled while the input is blank.
256
+ expect(addButton(container)).toBeDisabled();
257
+
258
+ await user.type(addInput(container), "something new");
259
+ expect(addButton(container)).not.toBeDisabled();
260
+
261
+ await user.click(addButton(container));
262
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
263
+ // Once added, the input is cleared and the button is disabled again.
264
+ expect(addButton(container)).toBeDisabled();
265
+ });
266
+
267
+ it("optionally accepts an onChange prop", async () => {
268
+ const user = userEvent.setup();
269
+ const onChange = jest.fn();
270
+ const { container } = renderList({ onChange });
271
+ expect(onChange).not.toHaveBeenCalled();
272
+
273
+ await user.type(addInput(container), "test");
274
+ await user.click(addButton(container));
275
+ await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
276
+ expect(onChange.mock.calls[0][0]).toHaveProperty("listItems");
277
+
278
+ await user.click(removeButtons(container)[0]);
279
+ await waitFor(() => expect(onChange).toHaveBeenCalledTimes(2));
280
+ expect(onChange.mock.calls[1][0]).toHaveProperty("listItems");
281
+ });
282
+
283
+ it("optionally accepts a readOnly prop", () => {
284
+ const { container } = renderList({ readOnly: true });
285
+ const inputs = itemInputs(container);
286
+ expect(inputs[0]).toHaveAttribute("readonly");
287
+ expect(inputs[1]).toHaveAttribute("readonly");
288
+ });
289
+
290
+ it("gets the value", () => {
291
+ const ref = React.createRef<ProtocolFormField>();
292
+ renderList({}, ref);
293
+ expect(ref.current!.getValue()).toStrictEqual(value);
294
+ });
295
+
296
+ it("clears all the items", () => {
297
+ const ref = React.createRef<ProtocolFormField>();
298
+ const { container } = renderList({}, ref);
299
+ expect(listItemWrappers(container)).toHaveLength(2);
300
+
301
+ act(() => {
302
+ ref.current!.clear();
303
+ });
304
+
305
+ expect(listItemWrappers(container)).toHaveLength(0);
306
+ });
307
+
308
+ describe("dropdown menu", () => {
309
+ const menuOptions = [1, 2, 3].map((n) => (
310
+ <option value={`Option ${n}`} key={n} aria-selected={false}>
311
+ {`Option ${n}`}
312
+ </option>
313
+ ));
314
+ const menuSetting = {
315
+ ...setting,
316
+ type: "menu",
317
+ menuOptions,
318
+ description: null,
319
+ };
320
+
321
+ it("renders a dropdown menu", () => {
322
+ const { container } = renderList({ setting: menuSetting });
323
+ const menu = container.querySelectorAll("select");
324
+ expect(menu).toHaveLength(1);
325
+ const options = menu[0].querySelectorAll("option");
326
+ expect(options).toHaveLength(3);
327
+ options.forEach((option, idx) => {
328
+ expect(option).toHaveTextContent(`Option ${idx + 1}`);
329
+ });
330
+ });
331
+
332
+ it("adds an item from the menu", async () => {
333
+ const user = userEvent.setup();
334
+ const { container } = renderList({ setting: menuSetting });
335
+ expect(listItemWrappers(container)).toHaveLength(2);
336
+ // The select defaults to the first option.
337
+ expect(container.querySelector("select")).toHaveValue("Option 1");
338
+
339
+ await user.click(addButton(container));
340
+
341
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
342
+ const inputs = itemInputs(container);
343
+ expect(inputs[inputs.length - 1]).toHaveValue("Option 1");
344
+ });
345
+
346
+ it("doesn't lose options when nothing is selected", () => {
347
+ const narrowSetting = {
348
+ ...menuSetting,
349
+ default: null,
350
+ format: "narrow",
351
+ };
352
+ const { container } = renderList({
353
+ setting: narrowSetting,
354
+ value: null,
355
+ });
356
+ expect(container.querySelectorAll("option")).toHaveLength(3);
357
+ });
358
+
359
+ it("optionally eliminates already-selected options from the menu", async () => {
360
+ const user = userEvent.setup();
361
+ const { container, rerender } = renderList({ setting: menuSetting });
362
+ let options = container.querySelectorAll("option");
363
+ expect(options).toHaveLength(3);
364
+ expect(
365
+ Array.from(options).some((o) => o.textContent === "Option 1")
366
+ ).toBe(true);
367
+
368
+ // Adding an option while the format is not "narrow" leaves the menu intact.
369
+ await user.click(addButton(container));
370
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
371
+ options = container.querySelectorAll("option");
372
+ expect(options).toHaveLength(3);
373
+
374
+ // Switch to the "narrow" format, which prunes selected options.
375
+ const narrowSetting = { ...menuSetting, format: "narrow" };
376
+ rerender(
377
+ <ProtocolFormField
378
+ setting={narrowSetting}
379
+ value={value}
380
+ disabled={false}
381
+ />
382
+ );
383
+
384
+ await user.click(addButton(container));
385
+ await waitFor(() => {
386
+ options = container.querySelectorAll("option");
387
+ expect(options).toHaveLength(2);
388
+ });
389
+ expect(
390
+ Array.from(container.querySelectorAll("option")).some(
391
+ (o) => o.textContent === "Option 1"
392
+ )
393
+ ).toBe(false);
394
+ });
395
+
396
+ it("optionally renders a label", () => {
397
+ const settingWithLabel = {
398
+ ...menuSetting,
399
+ menuTitle: "Custom Menu Title",
400
+ };
401
+ const { container } = renderList({ setting: settingWithLabel });
402
+ const label = container.querySelector("select").closest("label");
403
+ expect(label).toHaveTextContent("Custom Menu Title");
404
+ });
405
+
406
+ it("optionally marks the menu as required", () => {
407
+ const { container, rerender } = renderList({ setting: menuSetting });
408
+ expect(container.querySelectorAll(".required-field")).toHaveLength(0);
409
+
410
+ const requiredSetting = {
411
+ ...menuSetting,
412
+ menuTitle: "title",
413
+ required: true,
414
+ };
415
+ rerender(
416
+ <ProtocolFormField
417
+ setting={requiredSetting}
418
+ value={value}
419
+ disabled={false}
420
+ />
421
+ );
422
+ const required = container.querySelectorAll(".required-field");
423
+ expect(required).toHaveLength(1);
424
+ expect(required[0]).toHaveTextContent("Required");
425
+ });
426
+
427
+ it("optionally renders an alternate value if there are no list items", () => {
428
+ const { container, rerender } = renderList({
429
+ setting: menuSetting,
430
+ altValue: "No list items!",
431
+ });
432
+ // There are still list items, so the placeholder isn't rendered yet.
433
+ expect(container.querySelectorAll(".input-list > span")).toHaveLength(0);
434
+
435
+ rerender(
436
+ <ProtocolFormField
437
+ setting={menuSetting}
438
+ value={[]}
439
+ altValue="No list items!"
440
+ disabled={false}
441
+ />
442
+ );
443
+ const placeholder = container.querySelectorAll(".input-list > span");
444
+ expect(placeholder).toHaveLength(1);
445
+ expect(placeholder[0]).toHaveTextContent("No list items!");
446
+ });
447
+
448
+ it("optionally renders an alternate value if all the available list items have already been added", () => {
449
+ const narrowSetting = { ...menuSetting, format: "narrow" };
450
+ const { container } = renderList({
451
+ setting: narrowSetting,
452
+ value: ["Option 1", "Option 2", "Option 3"],
453
+ onEmpty: "You've run out of options!",
454
+ });
455
+ expect(container.querySelector("select")).not.toBeInTheDocument();
456
+ const message = container.querySelector(".add-list-item-container span");
457
+ expect(message).toHaveTextContent("You've run out of options!");
458
+ });
459
+
460
+ it("renders option elements if necessary", () => {
461
+ // Without a menuOptions property, InputList builds the menu from `options`.
462
+ const options = [
463
+ { key: "key_1", label: "label_1" },
464
+ { key: "key_2", label: "label_2" },
465
+ ];
466
+ const optionsSetting = { ...menuSetting, options, menuOptions: null };
467
+ const { container } = renderList({ setting: optionsSetting });
468
+ const select = container.querySelector("select");
469
+ expect(select).toBeInTheDocument();
470
+ const opts = select.querySelectorAll("option");
471
+ expect(opts[0]).toHaveValue("key_1");
472
+ expect(opts[0]).toHaveTextContent("label_1");
473
+ expect(opts[1]).toHaveValue("key_2");
474
+ expect(opts[1]).toHaveTextContent("label_2");
475
+ });
476
+
477
+ it("renders the default values", () => {
478
+ const defaultSetting = {
479
+ ...menuSetting,
480
+ default: ["Option 1", "Option 2"],
481
+ };
482
+ const { container } = renderList({
483
+ setting: defaultSetting,
484
+ value: null,
485
+ });
486
+ const inputs = itemInputs(container);
487
+ expect(inputs[0]).toHaveValue("Option 1");
488
+ expect(inputs[1]).toHaveValue("Option 2");
489
+ // The remaining option is still available in the menu.
490
+ expect(container.querySelector("select")).toBeInTheDocument();
491
+ });
492
+ });
493
+ });
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
3
4
  import { stub } from "sinon";
4
5
  import { LaneData } from "../../../src/interfaces";
5
6
  import Lane from "../../../src/components/Lane";
@@ -13,7 +14,7 @@ import Lane from "../../../src/components/Lane";
13
14
  jest.mock("react-router", () => ({
14
15
  ...jest.requireActual("react-router"),
15
16
  Link: (props) => (
16
- <div data-testid="Link" data-to={props.to}>
17
+ <div data-testid="Link" data-to={props.to} className={props.className}>
17
18
  {props.children}
18
19
  </div>
19
20
  ),
@@ -36,7 +37,7 @@ function createLaneData(displayName: string, isAutomated: boolean): LaneData {
36
37
  }
37
38
 
38
39
  describe("Lane", () => {
39
- it("renders an edit link on a custom lane", () => {
40
+ it("renders an edit link to the lane's edit page", () => {
40
41
  const laneData = createLaneData("Custom Lane", false);
41
42
 
42
43
  render(
@@ -52,19 +53,24 @@ describe("Lane", () => {
52
53
 
53
54
  const editLink = screen.getAllByTestId("Link")[0];
54
55
 
55
- expect(editLink).toHaveAttribute("data-to", expect.stringMatching(/edit/i));
56
56
  expect(editLink).toHaveTextContent(/edit/i);
57
+ // The full destination — a regression that dropped the lane id or used the
58
+ // wrong library segment would still contain "edit".
59
+ expect(editLink).toHaveAttribute(
60
+ "data-to",
61
+ "/admin/web/lanes/test_library/edit/1"
62
+ );
57
63
  });
58
64
 
59
- it("renders an edit link on an automated lane", async () => {
60
- const laneData = createLaneData("Automated Lane", true);
65
+ it("disables the edit link when lane order has changed", () => {
66
+ const laneData = createLaneData("Custom Lane", false);
61
67
 
62
68
  render(
63
69
  <Lane
64
70
  lane={laneData}
65
71
  active={false}
66
72
  library="test_library"
67
- orderChanged={false}
73
+ orderChanged={true}
68
74
  renderLanes={renderLanes}
69
75
  toggleLaneVisibility={toggleLaneVisibility}
70
76
  />
@@ -72,7 +78,124 @@ describe("Lane", () => {
72
78
 
73
79
  const editLink = screen.getAllByTestId("Link")[0];
74
80
 
75
- expect(editLink).toHaveAttribute("data-to", expect.stringMatching(/edit/i));
81
+ // With pending order changes the link has no destination and is disabled.
76
82
  expect(editLink).toHaveTextContent(/edit/i);
83
+ expect(editLink).not.toHaveAttribute("data-to");
84
+ expect(editLink).toHaveClass("disabled");
85
+ });
86
+ });
87
+
88
+ // The visibility toggle, the prop-sync in componentWillReceiveProps, and
89
+ // expand/collapse — none of which the disclosure tests above exercise.
90
+ describe("Lane - visibility toggle and expansion", () => {
91
+ const makeLane = (visible: boolean): LaneData => ({
92
+ id: 1,
93
+ display_name: "Top Lane",
94
+ visible,
95
+ count: 5,
96
+ sublanes: [],
97
+ custom_list_ids: [1],
98
+ inherit_parent_restrictions: true,
99
+ });
100
+
101
+ const renderLane = (overrides: Record<string, unknown> = {}) => {
102
+ const toggle = jest.fn();
103
+ const result = render(
104
+ <Lane
105
+ lane={makeLane(true)}
106
+ active={false}
107
+ library="lib"
108
+ orderChanged={false}
109
+ renderLanes={jest.fn()}
110
+ toggleLaneVisibility={toggle}
111
+ {...overrides}
112
+ />
113
+ );
114
+ return { ...result, toggle };
115
+ };
116
+
117
+ it("hides a visible lane when the hide button is clicked", async () => {
118
+ const user = userEvent.setup();
119
+ const { container, toggle } = renderLane();
120
+ const hideButton = container.querySelector(
121
+ "button.hide-lane"
122
+ ) as HTMLButtonElement;
123
+ expect(hideButton).toBeInTheDocument();
124
+
125
+ await user.click(hideButton);
126
+
127
+ expect(toggle).toHaveBeenCalledWith(
128
+ expect.objectContaining({ id: 1 }),
129
+ false
130
+ );
131
+ });
132
+
133
+ it("shows a hidden lane when the show button is clicked", async () => {
134
+ const user = userEvent.setup();
135
+ const { container, toggle } = renderLane({ lane: makeLane(false) });
136
+ const showButton = container.querySelector(
137
+ "button.show-lane"
138
+ ) as HTMLButtonElement;
139
+ expect(showButton).toBeInTheDocument();
140
+
141
+ await user.click(showButton);
142
+
143
+ expect(toggle).toHaveBeenCalledWith(
144
+ expect.objectContaining({ id: 1 }),
145
+ true
146
+ );
147
+ });
148
+
149
+ it("disables the visibility toggle when lane order has changed", () => {
150
+ const { container } = renderLane({ orderChanged: true });
151
+ expect(container.querySelector("button.hide-lane")).toBeDisabled();
152
+ });
153
+
154
+ it("cannot show a lane whose parent is hidden", () => {
155
+ const { container } = renderLane({
156
+ lane: makeLane(false),
157
+ parent: { visible: false },
158
+ });
159
+ expect(container.querySelector("button.show-lane")).toBeDisabled();
160
+ });
161
+
162
+ it("syncs its visibility button when the lane prop changes", () => {
163
+ const { container, rerender } = renderLane();
164
+ expect(container.querySelector("button.hide-lane")).toBeInTheDocument();
165
+
166
+ rerender(
167
+ <Lane
168
+ lane={makeLane(false)}
169
+ active={false}
170
+ library="lib"
171
+ orderChanged={false}
172
+ renderLanes={jest.fn()}
173
+ toggleLaneVisibility={jest.fn()}
174
+ />
175
+ );
176
+ expect(container.querySelector("button.show-lane")).toBeInTheDocument();
177
+ });
178
+
179
+ it("hides the create and edit links when collapsed", async () => {
180
+ const user = userEvent.setup();
181
+ renderLane();
182
+ // A top-level lane starts expanded, so both links show.
183
+ expect(screen.getAllByTestId("Link").length).toBeGreaterThanOrEqual(2);
184
+
185
+ await user.click(
186
+ screen.getByRole("button", { name: /expand or collapse/i })
187
+ );
188
+ expect(screen.queryAllByTestId("Link")).toHaveLength(0);
189
+ });
190
+
191
+ it("renders a create-sublane link when expanded", () => {
192
+ renderLane();
193
+ const createLink = screen
194
+ .getAllByTestId("Link")
195
+ .find((link) => link.getAttribute("data-to")?.includes("/create/"));
196
+ expect(createLink).toHaveAttribute(
197
+ "data-to",
198
+ "/admin/web/lanes/lib/create/1"
199
+ );
77
200
  });
78
201
  });