blob: b7a3b70a200919b75699d952d79577dbadba7107 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001// Copyright 2019 The Chromium Authors
Copybara854996b2021-09-07 19:36:02 +00002// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4import {assert} from 'chai';
5import sinon from 'sinon';
6import * as projectV0 from 'reducers/projectV0.js';
7import {stringValuesForIssueField} from 'shared/issue-fields.js';
8import {MrIssueList} from './mr-issue-list.js';
9
10let element;
11
12const listRowIsFocused = (element, i) => {
13 const focused = element.shadowRoot.activeElement;
14 assert.equal(focused.tagName.toUpperCase(), 'TR');
15 assert.equal(focused.dataset.index, `${i}`);
16};
17
18describe('mr-issue-list', () => {
19 beforeEach(() => {
20 element = document.createElement('mr-issue-list');
21 element.extractFieldValues = projectV0.extractFieldValuesFromIssue({});
22 document.body.appendChild(element);
23
24 sinon.stub(element, '_baseUrl').returns('/p/chromium/issues/list');
25 sinon.stub(element, '_page');
26 sinon.stub(window, 'open');
27 });
28
29 afterEach(() => {
30 document.body.removeChild(element);
31 window.open.restore();
32 });
33
34 it('initializes', () => {
35 assert.instanceOf(element, MrIssueList);
36 });
37
38 it('issue summaries render', async () => {
39 element.issues = [
40 {summary: 'test issue'},
41 {summary: 'I have a summary'},
42 ];
43 element.columns = ['Summary'];
44
45 await element.updateComplete;
46
47 const summaries = element.shadowRoot.querySelectorAll('.col-summary');
48
49 assert.equal(summaries.length, 2);
50
51 assert.equal(summaries[0].textContent.trim(), 'test issue');
52 assert.equal(summaries[1].textContent.trim(), 'I have a summary');
53 });
54
55 it('one word labels render in summary column', async () => {
56 element.issues = [
57 {
58 projectName: 'test',
59 localId: 1,
60 summary: 'test issue',
61 labelRefs: [
62 {label: 'ignore-multi-word-labels'},
63 {label: 'Security'},
64 {label: 'A11y'},
65 ],
66 },
67 ];
68 element.columns = ['Summary'];
69
70 await element.updateComplete;
71
72 const summary = element.shadowRoot.querySelector('.col-summary');
73 const labels = summary.querySelectorAll('.summary-label');
74
75 assert.equal(labels.length, 2);
76
77 assert.equal(labels[0].textContent.trim(), 'Security');
78 assert.include(labels[0].href,
79 '/p/test/issues/list?q=label%3ASecurity');
80 assert.equal(labels[1].textContent.trim(), 'A11y');
81 assert.include(labels[1].href,
82 '/p/test/issues/list?q=label%3AA11y');
83 });
84
85 it('blocking column renders issue links', async () => {
86 element.issues = [
87 {
88 projectName: 'test',
89 localId: 1,
90 blockingIssueRefs: [
91 {projectName: 'test', localId: 2},
92 {projectName: 'test', localId: 3},
93 ],
94 },
95 ];
96 element.columns = ['Blocking'];
97
98 await element.updateComplete;
99
100 const blocking = element.shadowRoot.querySelector('.col-blocking');
101 const link = blocking.querySelector('mr-issue-link');
102 assert.equal(link.href, '/p/test/issues/detail?id=2');
103 });
104
105 it('blockedOn column renders issue links', async () => {
106 element.issues = [
107 {
108 projectName: 'test',
109 localId: 1,
110 blockedOnIssueRefs: [{projectName: 'test', localId: 2}],
111 },
112 ];
113 element.columns = ['BlockedOn'];
114
115 await element.updateComplete;
116
117 const blocking = element.shadowRoot.querySelector('.col-blockedon');
118 const link = blocking.querySelector('mr-issue-link');
119 assert.equal(link.href, '/p/test/issues/detail?id=2');
120 });
121
122 it('mergedInto column renders issue link', async () => {
123 element.issues = [
124 {
125 projectName: 'test',
126 localId: 1,
127 mergedIntoIssueRef: {projectName: 'test', localId: 2},
128 },
129 ];
130 element.columns = ['MergedInto'];
131
132 await element.updateComplete;
133
134 const blocking = element.shadowRoot.querySelector('.col-mergedinto');
135 const link = blocking.querySelector('mr-issue-link');
136 assert.equal(link.href, '/p/test/issues/detail?id=2');
137 });
138
139 it('clicking issue link does not trigger _navigateToIssue', async () => {
140 sinon.stub(element, '_navigateToIssue');
141
142 // Prevent the page from actually navigating on the link click.
143 const clickIntercepter = sinon.spy((e) => {
144 e.preventDefault();
145 });
146 window.addEventListener('click', clickIntercepter);
147
148 element.issues = [
149 {projectName: 'test', localId: 1, summary: 'test issue'},
150 {projectName: 'test', localId: 2, summary: 'I have a summary'},
151 ];
152 element.columns = ['ID'];
153
154 await element.updateComplete;
155
156 const idLink = element.shadowRoot.querySelector('.col-id > mr-issue-link');
157
158 idLink.click();
159
160 sinon.assert.calledOnce(clickIntercepter);
161 sinon.assert.notCalled(element._navigateToIssue);
162
163 window.removeEventListener('click', clickIntercepter);
164 });
165
166 it('clicking issue row opens issue', async () => {
167 element.issues = [{
168 summary: 'click me',
169 localId: 22,
170 projectName: 'chromium',
171 }];
172 element.columns = ['Summary'];
173
174 await element.updateComplete;
175
176 const rowChild = element.shadowRoot.querySelector('.col-summary');
177 rowChild.click();
178
179 sinon.assert.calledWith(element._page, '/p/chromium/issues/detail?id=22');
180 sinon.assert.notCalled(window.open);
181 });
182
183 it('ctrl+click on row opens issue in new tab', async () => {
184 element.issues = [{
185 summary: 'click me',
186 localId: 24,
187 projectName: 'chromium',
188 }];
189 element.columns = ['Summary'];
190
191 await element.updateComplete;
192
193 const rowChild = element.shadowRoot.querySelector('.col-summary');
194 rowChild.dispatchEvent(new MouseEvent('click',
195 {ctrlKey: true, bubbles: true}));
196
197 sinon.assert.calledWith(window.open,
198 '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
199 });
200
201 it('meta+click on row opens issue in new tab', async () => {
202 element.issues = [{
203 summary: 'click me',
204 localId: 24,
205 projectName: 'chromium',
206 }];
207 element.columns = ['Summary'];
208
209 await element.updateComplete;
210
211 const rowChild = element.shadowRoot.querySelector('.col-summary');
212 rowChild.dispatchEvent(new MouseEvent('click',
213 {metaKey: true, bubbles: true}));
214
215 sinon.assert.calledWith(window.open,
216 '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
217 });
218
219 it('mouse wheel click on row opens issue in new tab', async () => {
220 element.issues = [{
221 summary: 'click me',
222 localId: 24,
223 projectName: 'chromium',
224 }];
225 element.columns = ['Summary'];
226
227 await element.updateComplete;
228
229 const rowChild = element.shadowRoot.querySelector('.col-summary');
230 rowChild.dispatchEvent(new MouseEvent('auxclick',
231 {button: 1, bubbles: true}));
232
233 sinon.assert.calledWith(window.open,
234 '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
235 });
236
237 it('right click on row does not navigate', async () => {
238 element.issues = [{
239 summary: 'click me',
240 localId: 24,
241 projectName: 'chromium',
242 }];
243 element.columns = ['Summary'];
244
245 await element.updateComplete;
246
247 const rowChild = element.shadowRoot.querySelector('.col-summary');
248 rowChild.dispatchEvent(new MouseEvent('auxclick',
249 {button: 2, bubbles: true}));
250
251 sinon.assert.notCalled(window.open);
252 });
253
254 it('AllLabels column renders', async () => {
255 element.issues = [
256 {labelRefs: [{label: 'test'}, {label: 'hello-world'}]},
257 {labelRefs: [{label: 'one-label'}]},
258 ];
259
260 element.columns = ['AllLabels'];
261
262 await element.updateComplete;
263
264 const labels = element.shadowRoot.querySelectorAll('.col-alllabels');
265
266 assert.equal(labels.length, 2);
267
268 assert.equal(labels[0].textContent.trim(), 'test, hello-world');
269 assert.equal(labels[1].textContent.trim(), 'one-label');
270 });
271
272 it('issues sorted into groups when groups defined', async () => {
273 element.issues = [
274 {ownerRef: {displayName: 'test@example.com'}},
275 {ownerRef: {displayName: 'test@example.com'}},
276 {ownerRef: {displayName: 'other.user@example.com'}},
277 {},
278 ];
279
280 element.columns = ['Owner'];
281 element.groups = ['Owner'];
282
283 await element.updateComplete;
284
285 const owners = element.shadowRoot.querySelectorAll('.col-owner');
286 assert.equal(owners.length, 4);
287
288 const groupHeaders = element.shadowRoot.querySelectorAll(
289 '.group-header');
290 assert.equal(groupHeaders.length, 3);
291
292 assert.include(groupHeaders[0].textContent,
293 '2 issues: Owner=test@example.com');
294 assert.include(groupHeaders[1].textContent,
295 '1 issue: Owner=other.user@example.com');
296 assert.include(groupHeaders[2].textContent, '1 issue: -has:Owner');
297 });
298
299 it('toggling group hides members', async () => {
300 element.issues = [
301 {ownerRef: {displayName: 'group1@example.com'}},
302 {ownerRef: {displayName: 'group2@example.com'}},
303 ];
304
305 element.columns = ['Owner'];
306 element.groups = ['Owner'];
307
308 await element.updateComplete;
309
310 const issueRows = element.shadowRoot.querySelectorAll('.list-row');
311 assert.equal(issueRows.length, 2);
312
313 assert.isFalse(issueRows[0].hidden);
314 assert.isFalse(issueRows[1].hidden);
315
316 const groupHeaders = element.shadowRoot.querySelectorAll(
317 '.group-header');
318 assert.equal(groupHeaders.length, 2);
319
320 // Toggle first group hidden.
321 groupHeaders[0].click();
322 await element.updateComplete;
323
324 assert.isTrue(issueRows[0].hidden);
325 assert.isFalse(issueRows[1].hidden);
326 });
327
328 it('reloadColspec navigates to page with new colspec', () => {
329 element.columns = ['ID', 'Summary'];
330 element._queryParams = {};
331
332 element.reloadColspec(['Summary', 'AllLabels']);
333
334 sinon.assert.calledWith(element._page,
335 '/p/chromium/issues/list?colspec=Summary%2BAllLabels');
336 });
337
338 it('updateSortSpec navigates to page with new sort option', async () => {
339 element.columns = ['ID', 'Summary'];
340 element._queryParams = {};
341
342 await element.updateComplete;
343
344 element.updateSortSpec('Summary', true);
345
346 sinon.assert.calledWith(element._page,
347 '/p/chromium/issues/list?sort=-summary');
348 });
349
350 it('updateSortSpec navigates to first page when on later page', async () => {
351 element.columns = ['ID', 'Summary'];
352 element._queryParams = {start: '100', q: 'owner:me'};
353
354 await element.updateComplete;
355
356 element.updateSortSpec('Summary', true);
357
358 sinon.assert.calledWith(element._page,
359 '/p/chromium/issues/list?q=owner%3Ame&sort=-summary');
360 });
361
362 it('updateSortSpec prepends new option to existing sort', async () => {
363 element.columns = ['ID', 'Summary', 'Owner'];
364 element._queryParams = {sort: '-summary+owner'};
365
366 await element.updateComplete;
367
368 element.updateSortSpec('ID');
369
370 sinon.assert.calledWith(element._page,
371 '/p/chromium/issues/list?sort=id%20-summary%20owner');
372 });
373
374 it('updateSortSpec removes existing instances of sorted column', async () => {
375 element.columns = ['ID', 'Summary', 'Owner'];
376 element._queryParams = {sort: '-summary+owner+owner'};
377
378 await element.updateComplete;
379
380 element.updateSortSpec('Owner', true);
381
382 sinon.assert.calledWith(element._page,
383 '/p/chromium/issues/list?sort=-owner%20-summary');
384 });
385
386 it('_uniqueValuesByColumn re-computed when columns update', async () => {
387 element.issues = [
388 {id: 1, projectName: 'chromium'},
389 {id: 2, projectName: 'chromium'},
390 {id: 3, projectName: 'chrOmiUm'},
391 {id: 1, projectName: 'other'},
392 ];
393 element.columns = [];
394 await element.updateComplete;
395
396 assert.deepEqual(element._uniqueValuesByColumn, new Map());
397
398 element.columns = ['project'];
399 await element.updateComplete;
400
401 assert.deepEqual(element._uniqueValuesByColumn,
402 new Map([['project', new Set(['chromium', 'chrOmiUm', 'other'])]]));
403 });
404
405 it('showOnly adds new search term to query', async () => {
406 element.currentQuery = 'owner:me';
407 element._queryParams = {};
408
409 await element.updateComplete;
410
411 element.showOnly('Priority', 'High');
412
413 sinon.assert.calledWith(element._page,
414 '/p/chromium/issues/list?q=owner%3Ame%20priority%3DHigh');
415 });
416
417 it('addColumn adds a column', () => {
418 element.columns = ['ID', 'Summary'];
419
420 sinon.stub(element, 'reloadColspec');
421
422 element.addColumn('AllLabels');
423
424 sinon.assert.calledWith(element.reloadColspec,
425 ['ID', 'Summary', 'AllLabels']);
426 });
427
428 it('removeColumn removes a column', () => {
429 element.columns = ['ID', 'Summary'];
430
431 sinon.stub(element, 'reloadColspec');
432
433 element.removeColumn(0);
434
435 sinon.assert.calledWith(element.reloadColspec, ['Summary']);
436 });
437
438 it('clicking hide column in column header removes column', async () => {
439 element.columns = ['ID', 'Summary'];
440
441 sinon.stub(element, 'removeColumn');
442
443 await element.updateComplete;
444
445 const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
446
447 dropdown.clickItem(0); // Hide column.
448
449 sinon.assert.calledWith(element.removeColumn, 1);
450 });
451
452 it('starring disabled when starringEnabled is false', async () => {
453 element.starringEnabled = false;
454 element.issues = [
455 {projectName: 'test', localId: 1, summary: 'test issue'},
456 {projectName: 'test', localId: 2, summary: 'I have a summary'},
457 ];
458
459 await element.updateComplete;
460
461 let stars = element.shadowRoot.querySelectorAll('mr-issue-star');
462 assert.equal(stars.length, 0);
463
464 element.starringEnabled = true;
465 await element.updateComplete;
466
467 stars = element.shadowRoot.querySelectorAll('mr-issue-star');
468 assert.equal(stars.length, 2);
469 });
470
471 describe('issue sorting and grouping enabled', () => {
472 beforeEach(() => {
473 element.sortingAndGroupingEnabled = true;
474 });
475
476 it('clicking sort up column header sets sort spec', async () => {
477 element.columns = ['ID', 'Summary'];
478
479 sinon.stub(element, 'updateSortSpec');
480
481 await element.updateComplete;
482
483 const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
484
485 dropdown.clickItem(0); // Sort up.
486
487 sinon.assert.calledWith(element.updateSortSpec, 'Summary');
488 });
489
490 it('clicking sort down column header sets sort spec', async () => {
491 element.columns = ['ID', 'Summary'];
492
493 sinon.stub(element, 'updateSortSpec');
494
495 await element.updateComplete;
496
497 const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
498
499 dropdown.clickItem(1); // Sort down.
500
501 sinon.assert.calledWith(element.updateSortSpec, 'Summary', true);
502 });
503
504 it('clicking group rows column header groups rows', async () => {
505 element.columns = ['Owner', 'Priority'];
506 element.groups = ['Status'];
507
508 sinon.spy(element, 'addGroupBy');
509
510 await element.updateComplete;
511
512 const dropdown = element.shadowRoot.querySelector('.dropdown-owner');
513 dropdown.clickItem(3); // Group rows.
514
515 sinon.assert.calledWith(element.addGroupBy, 0);
516
517 sinon.assert.calledWith(element._page,
518 '/p/chromium/issues/list?groupby=Owner%20Status&colspec=Priority');
519 });
520 });
521
522 describe('issue selection', () => {
523 beforeEach(() => {
524 element.selectionEnabled = true;
525 });
526
527 it('selections disabled when selectionEnabled is false', async () => {
528 element.selectionEnabled = false;
529 element.issues = [
530 {projectName: 'test', localId: 1, summary: 'test issue'},
531 {projectName: 'test', localId: 2, summary: 'I have a summary'},
532 ];
533
534 await element.updateComplete;
535
536 let checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
537 assert.equal(checkboxes.length, 0);
538
539 element.selectionEnabled = true;
540 await element.updateComplete;
541
542 checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
543 assert.equal(checkboxes.length, 2);
544 });
545
546 it('selected issues render selected attribute', async () => {
547 element.issues = [
548 {summary: 'issue 1', localId: 1, projectName: 'proj'},
549 {summary: 'another issue', localId: 2, projectName: 'proj'},
550 {summary: 'issue 2', localId: 3, projectName: 'proj'},
551 ];
552 element.columns = ['Summary'];
553
554 await element.updateComplete;
555
556 element._selectedIssues = new Set(['proj:1']);
557
558 await element.updateComplete;
559
560 const issues = element.shadowRoot.querySelectorAll('tr[selected]');
561
562 assert.equal(issues.length, 1);
563 assert.equal(issues[0].dataset.index, '0');
564 assert.include(issues[0].textContent, 'issue 1');
565 });
566
567 it('select all / none conditionally shows tooltip', async () => {
568 element.issues = [
569 {summary: 'issue 1', localId: 1, projectName: 'proj'},
570 {summary: 'issue 2', localId: 2, projectName: 'proj'},
571 ];
572
573 await element.updateComplete;
574 assert.deepEqual(element.selectedIssues, []);
575
576 const selectAll = element.shadowRoot.querySelector('.select-all');
577
578 // No issues selected, offer "Select All".
579 assert.equal(selectAll.title, 'Select All');
580 assert.equal(selectAll.getAttribute('aria-label'), 'Select All');
581
582 selectAll.click();
583
584 await element.updateComplete;
585
586 // Some issues selected, offer "Select None".
587 assert.equal(selectAll.title, 'Select None');
588 assert.equal(selectAll.getAttribute('aria-label'), 'Select None');
589 });
590
591 it('clicking select all selects all issues', async () => {
592 element.issues = [
593 {summary: 'issue 1', localId: 1, projectName: 'proj'},
594 {summary: 'issue 2', localId: 2, projectName: 'proj'},
595 ];
596
597 await element.updateComplete;
598
599 assert.deepEqual(element.selectedIssues, []);
600
601 const selectAll = element.shadowRoot.querySelector('.select-all');
602 selectAll.click();
603
604 assert.deepEqual(element.selectedIssues, [
605 {summary: 'issue 1', localId: 1, projectName: 'proj'},
606 {summary: 'issue 2', localId: 2, projectName: 'proj'},
607 ]);
608 });
609
610 it('when checked select all deselects all issues', async () => {
611 element.issues = [
612 {summary: 'issue 1', localId: 1, projectName: 'proj'},
613 {summary: 'issue 2', localId: 2, projectName: 'proj'},
614 ];
615
616 await element.updateComplete;
617
618 element._selectedIssues = new Set(['proj:1', 'proj:2']);
619
620 await element.updateComplete;
621
622 assert.deepEqual(element.selectedIssues, [
623 {summary: 'issue 1', localId: 1, projectName: 'proj'},
624 {summary: 'issue 2', localId: 2, projectName: 'proj'},
625 ]);
626
627 const selectAll = element.shadowRoot.querySelector('.select-all');
628 selectAll.click();
629
630 assert.deepEqual(element.selectedIssues, []);
631 });
632
633 it('selected issues added when issues checked', async () => {
634 element.issues = [
635 {summary: 'issue 1', localId: 1, projectName: 'proj'},
636 {summary: 'another issue', localId: 2, projectName: 'proj'},
637 {summary: 'issue 2', localId: 3, projectName: 'proj'},
638 ];
639
640 await element.updateComplete;
641
642 assert.deepEqual(element.selectedIssues, []);
643
644 const checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
645
646 assert.equal(checkboxes.length, 3);
647
648 checkboxes[2].dispatchEvent(new MouseEvent('click'));
649
650 await element.updateComplete;
651
652 assert.deepEqual(element.selectedIssues, [
653 {summary: 'issue 2', localId: 3, projectName: 'proj'},
654 ]);
655
656 checkboxes[0].dispatchEvent(new MouseEvent('click'));
657
658 await element.updateComplete;
659
660 assert.deepEqual(element.selectedIssues, [
661 {summary: 'issue 1', localId: 1, projectName: 'proj'},
662 {summary: 'issue 2', localId: 3, projectName: 'proj'},
663 ]);
664 });
665
666 it('shift+click selects issues in a range', async () => {
667 element.issues = [
668 {localId: 1, projectName: 'proj'},
669 {localId: 2, projectName: 'proj'},
670 {localId: 3, projectName: 'proj'},
671 {localId: 4, projectName: 'proj'},
672 {localId: 5, projectName: 'proj'},
673 ];
674
675 await element.updateComplete;
676
677 assert.deepEqual(element.selectedIssues, []);
678
679 const checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
680
681 // First click.
682 checkboxes[0].dispatchEvent(new MouseEvent('click'));
683
684 await element.updateComplete;
685
686 assert.deepEqual(element.selectedIssues, [
687 {localId: 1, projectName: 'proj'},
688 ]);
689
690 // Second click.
691 checkboxes[3].dispatchEvent(new MouseEvent('click', {shiftKey: true}));
692
693 await element.updateComplete;
694
695 assert.deepEqual(element.selectedIssues, [
696 {localId: 1, projectName: 'proj'},
697 {localId: 2, projectName: 'proj'},
698 {localId: 3, projectName: 'proj'},
699 {localId: 4, projectName: 'proj'},
700 ]);
701
702 // It's possible to chain Shift+Click operations.
703 checkboxes[2].dispatchEvent(new MouseEvent('click', {shiftKey: true}));
704
705 await element.updateComplete;
706
707 assert.deepEqual(element.selectedIssues, [
708 {localId: 1, projectName: 'proj'},
709 {localId: 2, projectName: 'proj'},
710 ]);
711 });
712
713 it('fires selectionChange events', async () => {
714 const listener = sinon.stub();
715 element.addEventListener('selectionChange', listener);
716
717 // Changing the issue list clears the selection and fires an event.
718 element.issues = [{localId: 1, projectName: 'proj'}];
719 await element.updateComplete;
720 // Selecting all/deselecting all fires an event.
721 element.shadowRoot.querySelector('.select-all').click();
722 await element.updateComplete;
723 // Selecting an individual issue fires an event.
724 element.shadowRoot.querySelectorAll('.issue-checkbox')[0].click();
725
726 sinon.assert.calledThrice(listener);
727 });
728 });
729
730 describe('cursor', () => {
731 beforeEach(() => {
732 element.issues = [
733 {localId: 1, projectName: 'chromium'},
734 {localId: 2, projectName: 'chromium'},
735 ];
736 });
737
738 it('empty when no initialCursor', () => {
739 assert.deepEqual(element.cursor, {});
740
741 element.initialCursor = '';
742 assert.deepEqual(element.cursor, {});
743 });
744
745 it('parses initialCursor value', () => {
746 element.initialCursor = '1';
747 element.projectName = 'chromium';
748
749 assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 1});
750
751 element.initialCursor = 'chromium:1';
752 assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 1});
753 });
754
755 it('overrides initialCursor with _localCursor', () => {
756 element.initialCursor = 'chromium:1';
757 element._localCursor = {projectName: 'gerrit', localId: 2};
758
759 assert.deepEqual(element.cursor, {projectName: 'gerrit', localId: 2});
760 });
761
762 it('initialCursor renders cursor and focuses element', async () => {
763 element.initialCursor = 'chromium:1';
764
765 await element.updateComplete;
766
767 const row = element.shadowRoot.querySelector('.row-0');
768 assert.isTrue(row.hasAttribute('cursored'));
769 listRowIsFocused(element, 0);
770 });
771
772 it('cursor value updated when row is focused', async () => {
773 element.initialCursor = 'chromium:1';
774
775 await element.updateComplete;
776
777 // HTMLElement.focus() seems to cause a timing related flake here.
778 element.shadowRoot.querySelector('.row-1').dispatchEvent(
779 new Event('focus'));
780
781 assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 2});
782 });
783 });
784
785 describe('hot keys', () => {
786 beforeEach(() => {
787 element.issues = [
788 {localId: 1, projectName: 'chromium'},
789 {localId: 2, projectName: 'chromium'},
790 {localId: 3, projectName: 'chromium'},
791 ];
792
793 element.selectionEnabled = true;
794
795 sinon.stub(element, '_navigateToIssue');
796 });
797
798 afterEach(() => {
799 element._navigateToIssue.restore();
800 });
801
802 it('global keydown listener removed on disconnect', async () => {
803 sinon.stub(element, '_boundRunListHotKeys');
804
805 await element.updateComplete;
806
807 window.dispatchEvent(new Event('keydown'));
808 sinon.assert.calledOnce(element._boundRunListHotKeys);
809
810 document.body.removeChild(element);
811
812 window.dispatchEvent(new Event('keydown'));
813 sinon.assert.calledOnce(element._boundRunListHotKeys);
814
815 document.body.appendChild(element);
816 });
817
818 it('pressing j defaults to first issue', async () => {
819 await element.updateComplete;
820
821 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
822
823 listRowIsFocused(element, 0);
824 });
825
826 it('pressing j focuses next issue', async () => {
827 element.initialCursor = 'chromium:1';
828
829 await element.updateComplete;
830
831 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
832
833 listRowIsFocused(element, 1);
834
835 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
836
837 listRowIsFocused(element, 2);
838 });
839
840 it('pressing j at the end of the list loops around', async () => {
841 await element.updateComplete;
842
843 element.shadowRoot.querySelector('.row-2').focus();
844
845 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
846
847 listRowIsFocused(element, 0);
848 });
849
850
851 it('pressing k defaults to last issue', async () => {
852 await element.updateComplete;
853
854 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
855
856 listRowIsFocused(element, 2);
857 });
858
859 it('pressing k focuses previous issue', async () => {
860 element.initialCursor = 'chromium:3';
861
862 await element.updateComplete;
863
864 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
865
866 listRowIsFocused(element, 1);
867
868 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
869
870 listRowIsFocused(element, 0);
871 });
872
873 it('pressing k at the start of the list loops around', async () => {
874 await element.updateComplete;
875
876 element.shadowRoot.querySelector('.row-0').focus();
877
878 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
879
880 listRowIsFocused(element, 2);
881 });
882
883 it('j and k keys treat row as focused if child is focused', async () => {
884 await element.updateComplete;
885
886 element.shadowRoot.querySelector('.row-1').querySelector(
887 'mr-issue-link').focus();
888
889 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
890 listRowIsFocused(element, 2);
891
892 element.shadowRoot.querySelector('.row-1').querySelector(
893 'mr-issue-link').focus();
894
895 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
896 listRowIsFocused(element, 0);
897 });
898
899 it('j and k keys stay on one element when one issue', async () => {
900 element.issues = [{localId: 2, projectName: 'chromium'}];
901 await element.updateComplete;
902
903 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
904 listRowIsFocused(element, 0);
905
906 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
907 listRowIsFocused(element, 0);
908
909 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
910 listRowIsFocused(element, 0);
911
912 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
913 listRowIsFocused(element, 0);
914 });
915
916 it('j and k no-op when event is from input', async () => {
917 const input = document.createElement('input');
918 document.body.appendChild(input);
919
920 await element.updateComplete;
921
922 input.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
923 assert.isNull(element.shadowRoot.activeElement);
924
925 input.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
926 assert.isNull(element.shadowRoot.activeElement);
927
928 document.body.removeChild(input);
929 });
930
931 it('j and k no-op when event is from shadowDOM input', async () => {
932 const input = document.createElement('input');
933 const root = document.createElement('div');
934
935 root.attachShadow({mode: 'open'});
936 root.shadowRoot.appendChild(input);
937
938 document.body.appendChild(root);
939
940 await element.updateComplete;
941
942 input.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
943 assert.isNull(element.shadowRoot.activeElement);
944
945 input.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
946 assert.isNull(element.shadowRoot.activeElement);
947
948 document.body.removeChild(root);
949 });
950
951 describe('starring issue', () => {
952 beforeEach(() => {
953 element.starringEnabled = true;
954 element.initialCursor = 'chromium:2';
955 });
956
957 it('pressing s stars focused issue', async () => {
958 sinon.stub(element, '_starIssue');
959 await element.updateComplete;
960
961 window.dispatchEvent(new KeyboardEvent('keydown', {key: 's'}));
962
963 sinon.assert.calledWith(element._starIssue,
964 {localId: 2, projectName: 'chromium'});
965 });
966
967 it('starIssue does not star issue while stars are fetched', () => {
968 sinon.stub(element, '_starIssueInternal');
969 element._fetchingStarredIssues = true;
970
971 element._starIssue({localId: 2, projectName: 'chromium'});
972
973 sinon.assert.notCalled(element._starIssueInternal);
974 });
975
976 it('starIssue does not star when issue is being starred', () => {
977 sinon.stub(element, '_starIssueInternal');
978 element._starringIssues = new Map([['chromium:2', {requesting: true}]]);
979
980 element._starIssue({localId: 2, projectName: 'chromium'});
981
982 sinon.assert.notCalled(element._starIssueInternal);
983 });
984
985 it('starIssue stars issue when issue is not being starred', () => {
986 sinon.stub(element, '_starIssueInternal');
987 element._starringIssues = new Map([
988 ['chromium:2', {requesting: false}],
989 ]);
990
991 element._starIssue({localId: 2, projectName: 'chromium'});
992
993 sinon.assert.calledWith(element._starIssueInternal,
994 {localId: 2, projectName: 'chromium'}, true);
995 });
996
997 it('starIssue unstars issue when issue is already starred', () => {
998 sinon.stub(element, '_starIssueInternal');
999 element._starredIssues = new Set(['chromium:2']);
1000
1001 element._starIssue({localId: 2, projectName: 'chromium'});
1002
1003 sinon.assert.calledWith(element._starIssueInternal,
1004 {localId: 2, projectName: 'chromium'}, false);
1005 });
1006 });
1007
1008 it('pressing x selects focused issue', async () => {
1009 element.initialCursor = 'chromium:2';
1010
1011 await element.updateComplete;
1012
1013 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'x'}));
1014
1015 await element.updateComplete;
1016
1017 assert.deepEqual(element.selectedIssues, [
1018 {localId: 2, projectName: 'chromium'},
1019 ]);
1020 });
1021
1022 it('pressing o navigates to focused issue', async () => {
1023 element.initialCursor = 'chromium:2';
1024
1025 await element.updateComplete;
1026
1027 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'o'}));
1028
1029 await element.updateComplete;
1030
1031 sinon.assert.calledOnce(element._navigateToIssue);
1032 sinon.assert.calledWith(element._navigateToIssue,
1033 {localId: 2, projectName: 'chromium'}, false);
1034 });
1035
1036 it('pressing shift+o opens focused issue in new tab', async () => {
1037 element.initialCursor = 'chromium:2';
1038
1039 await element.updateComplete;
1040
1041 window.dispatchEvent(new KeyboardEvent('keydown',
1042 {key: 'O', shiftKey: true}));
1043
1044 await element.updateComplete;
1045
1046 sinon.assert.calledOnce(element._navigateToIssue);
1047 sinon.assert.calledWith(element._navigateToIssue,
1048 {localId: 2, projectName: 'chromium'}, true);
1049 });
1050
1051 it('enter keydown on row navigates to issue', async () => {
1052 await element.updateComplete;
1053
1054 const row = element.shadowRoot.querySelector('.row-1');
1055
1056 row.dispatchEvent(
1057 new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
1058
1059 await element.updateComplete;
1060
1061 sinon.assert.calledOnce(element._navigateToIssue);
1062 sinon.assert.calledWith(
1063 element._navigateToIssue, {localId: 2, projectName: 'chromium'},
1064 false);
1065 });
1066
1067 it('ctrl+enter keydown on row navigates to issue in new tab', async () => {
1068 await element.updateComplete;
1069
1070 const row = element.shadowRoot.querySelector('.row-1');
1071
1072 // Note: metaKey would also work, but this is covered by click tests.
1073 row.dispatchEvent(new KeyboardEvent(
1074 'keydown', {key: 'Enter', ctrlKey: true, bubbles: true}));
1075
1076 await element.updateComplete;
1077
1078 sinon.assert.calledOnce(element._navigateToIssue);
1079 sinon.assert.calledWith(element._navigateToIssue,
1080 {localId: 2, projectName: 'chromium'}, true);
1081 });
1082
1083 it('enter keypress outside row is ignored', async () => {
1084 await element.updateComplete;
1085
1086 window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
1087
1088 await element.updateComplete;
1089
1090 sinon.assert.notCalled(element._navigateToIssue);
1091 });
1092 });
1093
1094 describe('_convertIssueToPlaintextArray', () => {
1095 it('returns an array with as many entries as this.columns.length', () => {
1096 element.columns = ['summary'];
1097 const result = element._convertIssueToPlaintextArray({
1098 summary: 'test issue',
1099 });
1100 assert.equal(element.columns.length, result.length);
1101 });
1102
1103 it('for column id uses issueRefToString', async () => {
1104 const projectName = 'some_project_name';
1105 const otherProjectName = 'some_other_project';
1106 const localId = '123';
1107 element.columns = ['ID'];
1108 element.projectName = projectName;
1109
1110 element.extractFieldValues = (issue, fieldName) =>
1111 stringValuesForIssueField(issue, fieldName, projectName);
1112
1113 let result;
1114 result = element._convertIssueToPlaintextArray({
1115 localId,
1116 projectName,
1117 });
1118 assert.equal(localId, result[0]);
1119
1120 result = element._convertIssueToPlaintextArray({
1121 localId,
1122 projectName: otherProjectName,
1123 });
1124 assert.equal(`${otherProjectName}:${localId}`, result[0]);
1125 });
1126
1127 it('uses extractFieldValues', () => {
1128 element.columns = ['summary', 'notsummary', 'anotherColumn'];
1129 element.extractFieldValues = sinon.fake.returns(['a', 'b']);
1130
1131 element._convertIssueToPlaintextArray({summary: 'test issue'});
1132 sinon.assert.callCount(element.extractFieldValues,
1133 element.columns.length);
1134 });
1135
1136 it('joins the result of extractFieldValues with ", "', () => {
1137 element.columns = ['notSummary'];
1138 element.extractFieldValues = sinon.fake.returns(['a', 'b']);
1139
1140 const result = element._convertIssueToPlaintextArray({
1141 summary: 'test issue',
1142 });
1143 assert.deepEqual(result, ['a, b']);
1144 });
1145 });
1146
1147 describe('_convertIssuesToPlaintextArrays', () => {
1148 it('maps this.issues with this._convertIssueToPlaintextArray', () => {
1149 element._convertIssueToPlaintextArray = sinon.fake.returns(['foobar']);
1150
1151 element.columns = ['summary'];
1152 element.issues = [
1153 {summary: 'test issue'},
1154 {summary: 'I have a summary'},
1155 ];
1156 const result = element._convertIssuesToPlaintextArrays();
1157
1158 assert.deepEqual([['foobar'], ['foobar']], result);
1159 sinon.assert.callCount(element._convertIssueToPlaintextArray,
1160 element.issues.length);
1161 });
1162 });
1163
1164 it('drag-and-drop', async () => {
1165 element.rerank = () => {};
1166 element.issues = [
1167 {projectName: 'project', localId: 123, summary: 'test issue'},
1168 {projectName: 'project', localId: 456, summary: 'I have a summary'},
1169 {projectName: 'project', localId: 789, summary: 'third issue'},
1170 ];
1171 await element.updateComplete;
1172
1173 const rows = element._getRows();
1174
1175 // Mouse down on the middle element!
1176 const secondRow = rows[1];
1177 const dragHandle = secondRow.firstElementChild;
1178 const mouseDown = new MouseEvent('mousedown', {clientX: 0, clientY: 0});
1179 dragHandle.dispatchEvent(mouseDown);
1180
1181 assert.deepEqual(element._dragging, true);
1182 assert.deepEqual(element.cursor, {projectName: 'project', localId: 456});
1183 assert.deepEqual(element.selectedIssues, [element.issues[1]]);
1184
1185 // Drag the middle element to the end!
1186 const mouseMove = new MouseEvent('mousemove', {clientX: 0, clientY: 100});
1187 window.dispatchEvent(mouseMove);
1188
1189 assert.deepEqual(rows[0].style['transform'], '');
1190 assert.deepEqual(rows[1].style['transform'], 'translate(0px, 100px)');
1191 assert.match(rows[2].style['transform'], /^translate\(0px, -\d+px\)$/);
1192
1193 // Mouse up!
1194 const mouseUp = new MouseEvent('mouseup', {clientX: 0, clientY: 100});
1195 window.dispatchEvent(mouseUp);
1196
1197 assert.deepEqual(element._dragging, false);
1198 assert.match(rows[1].style['transform'], /^translate\(0px, \d+px\)$/);
1199 });
1200
1201 describe('CSV download', () => {
1202 let _downloadCsvSpy;
1203 let convertStub;
1204
1205 beforeEach(() => {
1206 element.userDisplayName = 'notempty';
1207 _downloadCsvSpy = sinon.spy(element, '_downloadCsv');
1208 convertStub = sinon
1209 .stub(element, '_convertIssuesToPlaintextArrays')
1210 .returns([['']]);
1211 });
1212
1213 afterEach(() => {
1214 _downloadCsvSpy.restore();
1215 convertStub.restore();
1216 });
1217
1218 it('hides download link for anonymous users', async () => {
1219 element.userDisplayName = '';
1220 await element.updateComplete;
1221 const downloadLink = element.shadowRoot.querySelector('#download-link');
1222 assert.isNull(downloadLink);
1223 });
1224
1225 it('renders a #download-link', async () => {
1226 await element.updateComplete;
1227 const downloadLink = element.shadowRoot.querySelector('#download-link');
1228 assert.isNotNull(downloadLink);
1229 assert.equal('inline', window.getComputedStyle(downloadLink).display);
1230 });
1231
1232 it('renders a #hidden-data-link', async () => {
1233 await element.updateComplete;
1234 assert.isNotNull(element._dataLink);
1235 const expected = element.shadowRoot.querySelector('#hidden-data-link');
1236 assert.equal(expected, element._dataLink);
1237 });
1238
1239 it('hides #hidden-data-link', async () => {
1240 await element.updateComplete;
1241 const _dataLink = element.shadowRoot.querySelector('#hidden-data-link');
1242 assert.equal('none', window.getComputedStyle(_dataLink).display);
1243 });
1244
1245 it('calls _downloadCsv on click', async () => {
1246 await element.updateComplete;
1247 sinon.stub(element._dataLink, 'click');
1248
1249 const downloadLink = element.shadowRoot.querySelector('#download-link');
1250 downloadLink.click();
1251 await element.requestUpdate('_csvDataHref');
1252
1253 sinon.assert.calledOnce(_downloadCsvSpy);
1254 element._dataLink.click.restore();
1255 });
1256
1257 it('converts issues into arrays of plaintext data', async () => {
1258 await element.updateComplete;
1259 sinon.stub(element._dataLink, 'click');
1260
1261 const downloadLink = element.shadowRoot.querySelector('#download-link');
1262 downloadLink.click();
1263 await element.requestUpdate('_csvDataHref');
1264
1265 sinon.assert.calledOnce(convertStub);
1266 element._dataLink.click.restore();
1267 });
1268
1269 it('triggers _dataLink click after #downloadLink click', async () => {
1270 await element.updateComplete;
1271 const dataLinkStub = sinon.stub(element._dataLink, 'click');
1272
1273 const downloadLink = element.shadowRoot.querySelector('#download-link');
1274
1275 downloadLink.click();
1276
1277 await element.requestUpdate('_csvDataHref');
1278 sinon.assert.calledOnce(dataLinkStub);
1279
1280 element._dataLink.click.restore();
1281 });
1282
1283 it('triggers _csvDataHref update and _dataLink click', async () => {
1284 await element.updateComplete;
1285 assert.equal('', element._csvDataHref);
1286 const downloadStub = sinon.stub(element._dataLink, 'click');
1287
1288 const downloadLink = element.shadowRoot.querySelector('#download-link');
1289
1290 downloadLink.click();
1291 assert.notEqual('', element._csvDataHref);
1292 await element.requestUpdate('_csvDataHref');
1293 sinon.assert.calledOnce(downloadStub);
1294
1295 element._dataLink.click.restore();
1296 });
1297
1298 it('resets _csvDataHref', async () => {
1299 await element.updateComplete;
1300 assert.equal('', element._csvDataHref);
1301
1302 sinon.stub(element._dataLink, 'click');
1303 const downloadLink = element.shadowRoot.querySelector('#download-link');
1304 downloadLink.click();
1305 assert.notEqual('', element._csvDataHref);
1306
1307 await element.requestUpdate('_csvDataHref');
1308 assert.equal('', element._csvDataHref);
1309 element._dataLink.click.restore();
1310 });
1311
1312 it('does nothing for anonymous users', async () => {
1313 await element.updateComplete;
1314
1315 element.userDisplayName = '';
1316
1317 const downloadStub = sinon.stub(element._dataLink, 'click');
1318
1319 const downloadLink = element.shadowRoot.querySelector('#download-link');
1320
1321 downloadLink.click();
1322 await element.requestUpdate('_csvDataHref');
1323 sinon.assert.notCalled(downloadStub);
1324
1325 element._dataLink.click.restore();
1326 });
1327 });
1328});