blob: 3e0a279ddc891761ac1fe8a71fcbc38ea1773fbc [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6
7import page from 'page';
8import {connectStore, store} from 'reducers/base.js';
9import * as issueV0 from 'reducers/issueV0.js';
10import * as sitewide from 'reducers/sitewide.js';
11import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
12import 'elements/framework/links/mr-crbug-link/mr-crbug-link.js';
13import 'elements/framework/mr-dropdown/mr-dropdown.js';
14import 'elements/framework/mr-star/mr-issue-star.js';
15import {constructHref, prepareDataForDownload} from './list-to-csv-helpers.js';
16import {
17 issueRefToUrl,
18 issueRefToString,
19 issueStringToRef,
20 issueToIssueRef,
21 issueToIssueRefString,
22 labelRefsToOneWordLabels,
23} from 'shared/convertersV0.js';
24import {isTextInput, findDeepEventTarget} from 'shared/dom-helpers.js';
25import {
26 urlWithNewParams,
27 pluralize,
28 setHasAny,
29 objectValuesForKeys,
30} from 'shared/helpers.js';
31import {SHARED_STYLES} from 'shared/shared-styles.js';
32import {parseColSpec, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
33import './mr-show-columns-dropdown.js';
34
35/**
36 * Column to display name mapping dictionary
37 * @type {Object<string, string>}
38 */
39const COLUMN_DISPLAY_NAMES = Object.freeze({
40 'summary': 'Summary + Labels',
41});
42
43/** @const {number} Button property value of DOM click event */
44const PRIMARY_BUTTON = 0;
45/** @const {number} Button property value of DOM auxclick event */
46const MIDDLE_BUTTON = 1;
47
48/** @const {string} A short transition to ease movement of list items. */
49const EASE_OUT_TRANSITION = 'transform 0.05s cubic-bezier(0, 0, 0.2, 1)';
50
51/**
52 * Really high cardinality attributes like ID and Summary are unlikely to be
53 * useful if grouped, so it's better to just hide the option.
54 * @const {Set<string>}
55 */
56const UNGROUPABLE_COLUMNS = new Set(['id', 'summary']);
57
58/**
59 * Columns that should render as issue links.
60 * @const {Set<string>}
61 */
62const ISSUE_COLUMNS = new Set(['id', 'mergedinto', 'blockedon', 'blocking']);
63
64/**
65 * `<mr-issue-list>`
66 *
67 * A list of issues intended to be used in multiple contexts.
68 * @extends {LitElement}
69 */
70export class MrIssueList extends connectStore(LitElement) {
71 /** @override */
72 static get styles() {
73 return [
74 SHARED_STYLES,
75 css`
76 :host {
77 width: 100%;
78 font-size: var(--chops-main-font-size);
79 }
80 table {
81 width: 100%;
82 }
83 .edit-widget-container {
84 display: flex;
85 flex-wrap: no-wrap;
86 align-items: center;
87 }
88 mr-issue-star {
89 --mr-star-size: 18px;
90 margin-bottom: 1px;
91 margin-left: 4px;
92 }
93 input[type="checkbox"] {
94 cursor: pointer;
95 margin: 0 4px;
96 width: 16px;
97 height: 16px;
98 border-radius: 2px;
99 box-sizing: border-box;
100 appearance: none;
101 -webkit-appearance: none;
102 border: 2px solid var(--chops-gray-400);
103 position: relative;
104 background: var(--chops-white);
105 }
106 th input[type="checkbox"] {
107 border-color: var(--chops-gray-500);
108 }
109 input[type="checkbox"]:checked {
110 background: var(--chops-primary-accent-color);
111 border-color: var(--chops-primary-accent-color);
112 }
113 input[type="checkbox"]:checked::after {
114 left: 1px;
115 top: 2px;
116 position: absolute;
117 content: "";
118 width: 8px;
119 height: 4px;
120 border: 2px solid white;
121 border-right: none;
122 border-top: none;
123 transform: rotate(-45deg);
124 }
125 td, th.group-header {
126 padding: 4px 8px;
127 text-overflow: ellipsis;
128 border-bottom: var(--chops-normal-border);
129 cursor: pointer;
130 font-weight: normal;
131 }
132 .group-header-content {
133 height: 100%;
134 width: 100%;
135 align-items: center;
136 display: flex;
137 }
138 th.group-header i.material-icons {
139 font-size: var(--chops-icon-font-size);
140 color: var(--chops-primary-icon-color);
141 margin-right: 4px;
142 }
143 td.ignore-navigation {
144 cursor: default;
145 }
146 th {
147 background: var(--chops-table-header-bg);
148 white-space: nowrap;
149 text-align: left;
150 border-bottom: var(--chops-normal-border);
151 }
152 th.selection-header {
153 padding: 3px 8px;
154 }
155 th > mr-dropdown, th > mr-show-columns-dropdown {
156 font-weight: normal;
157 color: var(--chops-link-color);
158 --mr-dropdown-icon-color: var(--chops-link-color);
159 --mr-dropdown-anchor-padding: 3px 8px;
160 --mr-dropdown-anchor-font-weight: bold;
161 --mr-dropdown-menu-min-width: 150px;
162 }
163 tr {
164 padding: 0 8px;
165 }
166 tr[selected] {
167 background: var(--chops-selected-bg);
168 }
169 td:first-child, th:first-child {
170 border-left: 4px solid transparent;
171 }
172 tr[cursored] > td:first-child {
173 border-left: 4px solid var(--chops-blue-700);
174 }
175 mr-crbug-link {
176 /* We need the shortlink to be hidden but still accessible.
177 * The opacity attribute visually hides a link while still
178 * keeping it in the DOM.opacity. */
179 --mr-crbug-link-opacity: 0;
180 --mr-crbug-link-opacity-focused: 1;
181 }
182 td:hover > mr-crbug-link {
183 --mr-crbug-link-opacity: 1;
184 }
185 .col-summary, .header-summary {
186 /* Setting a table cell to 100% width makes it take up
187 * all remaining space in the table, not the full width of
188 * the table. */
189 width: 100%;
190 }
191 .summary-label {
192 display: inline-block;
193 margin: 0 2px;
194 color: var(--chops-green-800);
195 text-decoration: none;
196 font-size: 90%;
197 }
198 .summary-label:hover {
199 text-decoration: underline;
200 }
201 td.draggable i {
202 opacity: 0;
203 }
204 td.draggable {
205 color: var(--chops-primary-icon-color);
206 cursor: grab;
207 padding-left: 0;
208 padding-right: 0;
209 }
210 tr.dragged {
211 opacity: 0.74;
212 }
213 tr:hover td.draggable i {
214 opacity: 1;
215 }
216 .csv-download-container {
217 border-bottom: none;
218 text-align: end;
219 cursor: default;
220 }
221 #hidden-data-link {
222 display: none;
223 }
224 @media (min-width: 1024px) {
225 .first-row th {
226 position: sticky;
227 top: var(--monorail-header-height);
228 z-index: 10;
229 }
230 }
231 `,
232 ];
233 }
234
235 /** @override */
236 render() {
237 const selectAllChecked = this._selectedIssues.size > 0;
238 const checkboxLabel = `Select ${selectAllChecked ? 'None' : 'All'}`;
239
240 return html`
241 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
242 <table cellspacing="0">
243 <thead>
244 <tr class="first-row">
245 ${this.rerank ? html`<th></th>` : ''}
246 <th class="selection-header">
247 <div class="edit-widget-container">
248 ${this.selectionEnabled ? html`
249 <input
250 class="select-all"
251 .checked=${selectAllChecked}
252 type="checkbox"
253 aria-label=${checkboxLabel}
254 title=${checkboxLabel}
255 @change=${this._selectAll}
256 />
257 ` : ''}
258 </div>
259 </th>
260 ${this.columns.map((column, i) => this._renderHeader(column, i))}
261 <th style="z-index: ${this.highestZIndex};">
262 <mr-show-columns-dropdown
263 title="Show columns"
264 menuAlignment="right"
265 .columns=${this.columns}
266 .issues=${this.issues}
267 .defaultFields=${this.defaultFields}
268 ></mr-show-columns-dropdown>
269 </th>
270 </tr>
271 </thead>
272 <tbody>
273 ${this._renderIssues()}
274 </tbody>
275 ${this.userDisplayName && html`
276 <tfoot><tr><td colspan=999 class="csv-download-container">
277 <a id="download-link" aria-label="Download page as CSV"
278 @click=${this._downloadCsv} href>CSV</a>
279 <a id="hidden-data-link" download="${this.projectName}-issues.csv"
280 href=${this._csvDataHref}></a>
281 </td></tr></tfoot>
282 `}
283 </table>
284 `;
285 }
286
287 /**
288 * @param {string} column
289 * @param {number} i The index of the column in the table.
290 * @return {TemplateResult} html for header for the i-th column.
291 * @private
292 */
293 _renderHeader(column, i) {
294 // zIndex is used to render the z-index property in descending order
295 const zIndex = this.highestZIndex - i;
296 const colKey = column.toLowerCase();
297 const name = colKey in COLUMN_DISPLAY_NAMES ? COLUMN_DISPLAY_NAMES[colKey] :
298 column;
299 return html`
300 <th style="z-index: ${zIndex};" class="header-${colKey}">
301 <mr-dropdown
302 class="dropdown-${colKey}"
303 .text=${name}
304 .items=${this._headerActions(column, i)}
305 menuAlignment="left"
306 ></mr-dropdown>
307 </th>`;
308 }
309
310 /**
311 * @param {string} column
312 * @param {number} i The index of the column in the table.
313 * @return {Array<Object>} Available actions for the column.
314 * @private
315 */
316 _headerActions(column, i) {
317 const columnKey = column.toLowerCase();
318
319 const isGroupable = this.sortingAndGroupingEnabled &&
320 !UNGROUPABLE_COLUMNS.has(columnKey);
321
322 let showOnly = [];
323 if (isGroupable) {
324 const values = [...this._uniqueValuesByColumn.get(columnKey)];
325 if (values.length) {
326 showOnly = [{
327 text: 'Show only',
328 items: values.map((v) => ({
329 text: v,
330 handler: () => this.showOnly(column, v),
331 })),
332 }];
333 }
334 }
335 const sortingActions = this.sortingAndGroupingEnabled ? [
336 {
337 text: 'Sort up',
338 handler: () => this.updateSortSpec(column),
339 },
340 {
341 text: 'Sort down',
342 handler: () => this.updateSortSpec(column, true),
343 },
344 ] : [];
345 const actions = [
346 ...sortingActions,
347 ...showOnly,
348 {
349 text: 'Hide column',
350 handler: () => this.removeColumn(i),
351 },
352 ];
353 if (isGroupable) {
354 actions.push({
355 text: 'Group rows',
356 handler: () => this.addGroupBy(i),
357 });
358 }
359 return actions;
360 }
361
362 /**
363 * @return {TemplateResult}
364 */
365 _renderIssues() {
366 // Keep track of all the groups that we've seen so far to create
367 // group headers as needed.
368 const {issues, groupedIssues} = this;
369
370 if (groupedIssues) {
371 // Make sure issues in groups are rendered with unique indices across
372 // groups to make sure hot keys and the like still work.
373 let indexOffset = 0;
374 return html`${groupedIssues.map(({groupName, issues}) => {
375 const template = html`
376 ${this._renderGroup(groupName, issues, indexOffset)}
377 `;
378 indexOffset += issues.length;
379 return template;
380 })}`;
381 }
382
383 return html`
384 ${issues.map((issue, i) => this._renderRow(issue, i))}
385 `;
386 }
387
388 /**
389 * @param {string} groupName
390 * @param {Array<Issue>} issues
391 * @param {number} iOffset
392 * @return {TemplateResult}
393 * @private
394 */
395 _renderGroup(groupName, issues, iOffset) {
396 if (!this.groups.length) return html``;
397
398 const count = issues.length;
399 const groupKey = groupName.toLowerCase();
400 const isHidden = this._hiddenGroups.has(groupKey);
401
402 return html`
403 <tr>
404 <th
405 class="group-header"
406 colspan="${this.numColumns}"
407 @click=${() => this._toggleGroup(groupKey)}
408 aria-expanded=${(!isHidden).toString()}
409 >
410 <div class="group-header-content">
411 <i
412 class="material-icons"
413 title=${isHidden ? 'Show' : 'Hide'}
414 >${isHidden ? 'add' : 'remove'}</i>
415 ${count} ${pluralize(count, 'issue')}: ${groupName}
416 </div>
417 </th>
418 </tr>
419 ${issues.map((issue, i) => this._renderRow(issue, iOffset + i, isHidden))}
420 `;
421 }
422
423 /**
424 * @param {string} groupKey Lowercase group key.
425 * @private
426 */
427 _toggleGroup(groupKey) {
428 if (this._hiddenGroups.has(groupKey)) {
429 this._hiddenGroups.delete(groupKey);
430 } else {
431 this._hiddenGroups.add(groupKey);
432 }
433
434 // Lit-element's default hasChanged check does not notice when Sets mutate.
435 this.requestUpdate('_hiddenGroups');
436 }
437
438 /**
439 * @param {Issue} issue
440 * @param {number} i Index within the list of issues
441 * @param {boolean=} isHidden
442 * @return {TemplateResult}
443 */
444 _renderRow(issue, i, isHidden = false) {
445 const rowSelected = this._selectedIssues.has(issueRefToString(issue));
446 const id = issueRefToString(issue);
447 const cursorId = issueRefToString(this.cursor);
448 const hasCursor = cursorId === id;
449 const dragged = this._dragging && rowSelected;
450
451 return html`
452 <tr
453 class="row-${i} list-row ${dragged ? 'dragged' : ''}"
454 ?selected=${rowSelected}
455 ?cursored=${hasCursor}
456 ?hidden=${isHidden}
457 data-issue-ref=${id}
458 data-index=${i}
459 data-name=${issue.name}
460 @focus=${this._setRowAsCursorOnFocus}
461 @click=${this._clickIssueRow}
462 @auxclick=${this._clickIssueRow}
463 @keydown=${this._keydownIssueRow}
464 tabindex="0"
465 >
466 ${this.rerank ? html`
467 <td class="draggable ignore-navigation"
468 @mousedown=${this._onMouseDown}>
469 <i class="material-icons" title="Drag issue">drag_indicator</i>
470 </td>
471 ` : ''}
472 <td class="ignore-navigation">
473 <div class="edit-widget-container">
474 ${this.selectionEnabled ? html`
475 <input
476 class="issue-checkbox"
477 .value=${id}
478 .checked=${rowSelected}
479 type="checkbox"
480 data-index=${i}
481 aria-label="Select Issue ${issue.localId}"
482 @change=${this._selectIssue}
483 @click=${this._selectIssueRange}
484 />
485 ` : ''}
486 ${this.starringEnabled ? html`
487 <mr-issue-star
488 .issueRef=${issueToIssueRef(issue)}
489 ></mr-issue-star>
490 ` : ''}
491 </div>
492 </td>
493
494 ${this.columns.map((column) => html`
495 <td class="col-${column.toLowerCase()}">
496 ${this._renderCell(column, issue)}
497 </td>
498 `)}
499
500 <td>
501 <mr-crbug-link .issue=${issue}></mr-crbug-link>
502 </td>
503 </tr>
504 `;
505 }
506
507 /**
508 * @param {string} column
509 * @param {Issue} issue
510 * @return {TemplateResult} Html for the given column for the given issue.
511 * @private
512 */
513 _renderCell(column, issue) {
514 const columnName = column.toLowerCase();
515 if (columnName === 'summary') {
516 return html`
517 ${issue.summary}
518 ${labelRefsToOneWordLabels(issue.labelRefs).map(({label}) => html`
519 <a
520 class="summary-label"
521 href="/p/${issue.projectName}/issues/list?q=label%3A${label}"
522 >${label}</a>
523 `)}
524 `;
525 }
526 const values = this.extractFieldValues(issue, column);
527
528 if (!values.length) return EMPTY_FIELD_VALUE;
529
530 // TODO(zhangtiff): Make this based on the "ISSUE" field type rather than a
531 // hardcoded list of issue fields.
532 if (ISSUE_COLUMNS.has(columnName)) {
533 return values.map((issueRefString, i) => {
534 const issue = this._issueForRefString(issueRefString, this.projectName);
535 return html`
536 <mr-issue-link
537 .projectName=${this.projectName}
538 .issue=${issue}
539 .queryParams=${this._queryParams}
540 short
541 ></mr-issue-link>${values.length - 1 > i ? ', ' : ''}
542 `;
543 });
544 }
545 return values.join(', ');
546 }
547
548 /** @override */
549 static get properties() {
550 return {
551 /**
552 * Array of columns to display.
553 */
554 columns: {type: Array},
555 /**
556 * Array of built in fields that are available outside of project
557 * configuration.
558 */
559 defaultFields: {type: Array},
560 /**
561 * A function that takes in an issue and a field name and returns the
562 * value for that field in the issue. This function accepts custom fields,
563 * built in fields, and ad hoc fields computed from label prefixes.
564 */
565 extractFieldValues: {type: Object},
566 /**
567 * Array of columns that are used as groups for issues.
568 */
569 groups: {type: Array},
570 /**
571 * List of issues to display.
572 */
573 issues: {type: Array},
574 /**
575 * A Redux action creator that calls the API to rerank the issues
576 * in the list. If set, reranking is enabled for this issue list.
577 */
578 rerank: {type: Object},
579 /**
580 * Whether issues should be selectable or not.
581 */
582 selectionEnabled: {type: Boolean},
583 /**
584 * Whether issues should be sortable and groupable or not. This will
585 * change how column headers will be displayed. The ability to sort and
586 * group are currently coupled.
587 */
588 sortingAndGroupingEnabled: {type: Boolean},
589 /**
590 * Whether to show issue starring or not.
591 */
592 starringEnabled: {type: Boolean},
593 /**
594 * A query representing the current set of matching issues in the issue
595 * list. Does not necessarily match queryParams.q since queryParams.q can
596 * be empty while currentQuery is set to a default project query.
597 */
598 currentQuery: {type: String},
599 /**
600 * Object containing URL parameters to be preserved when issue links are
601 * clicked. This Object is only used for the purpose of preserving query
602 * parameters across links, not for the purpose of evaluating the query
603 * parameters themselves to get values like columns, sort, or q. This
604 * separation is important because we don't want to tightly couple this
605 * list component with a specific URL system.
606 * @private
607 */
608 _queryParams: {type: Object},
609 /**
610 * The initial cursor that a list view uses. This attribute allows users
611 * of the list component to specify and control the cursor. When the
612 * initialCursor attribute updates, the list focuses the element specified
613 * by the cursor.
614 */
615 initialCursor: {type: String},
616 /**
617 * Logged in user's display name
618 */
619 userDisplayName: {type: String},
620 /**
621 * IssueRef Object specifying which issue the user is currently focusing.
622 */
623 _localCursor: {type: Object},
624 /**
625 * Set of group keys that are currently hidden.
626 */
627 _hiddenGroups: {type: Object},
628 /**
629 * Set of all selected issues where each entry is an issue ref string.
630 */
631 _selectedIssues: {type: Object},
632 /**
633 * List of unique phase names for all phases in issues.
634 */
635 _phaseNames: {type: Array},
636 /**
637 * True iff the user is dragging issues.
638 */
639 _dragging: {type: Boolean},
640 /**
641 * CSV data in data HREF format, used to download csv
642 */
643 _csvDataHref: {type: String},
644 /**
645 * Function to get a full Issue object for a given ref string.
646 */
647 _issueForRefString: {type: Object},
648 };
649 };
650
651 /** @override */
652 constructor() {
653 super();
654 /** @type {Array<Issue>} */
655 this.issues = [];
656 // TODO(jojwang): monorail:6336#c8, when ezt listissues page is fully
657 // deprecated, remove phaseNames from mr-issue-list.
658 this._phaseNames = [];
659 /** @type {IssueRef} */
660 this._localCursor;
661 /** @type {IssueRefString} */
662 this.initialCursor;
663 /** @type {Set<IssueRefString>} */
664 this._selectedIssues = new Set();
665 /** @type {string} */
666 this.projectName;
667 /** @type {Object} */
668 this._queryParams = {};
669 /** @type {string} */
670 this.currentQuery = '';
671 /**
672 * @param {Array<String>} items
673 * @param {number} index
674 * @return {Promise<void>}
675 */
676 this.rerank = null;
677 /** @type {boolean} */
678 this.selectionEnabled = false;
679 /** @type {boolean} */
680 this.sortingAndGroupingEnabled = false;
681 /** @type {boolean} */
682 this.starringEnabled = false;
683 /** @type {Array} */
684 this.columns = ['ID', 'Summary'];
685 /** @type {Array<string>} */
686 this.defaultFields = [];
687 /** @type {Array} */
688 this.groups = [];
689 this.userDisplayName = '';
690
691 /** @type {function(KeyboardEvent): void} */
692 this._boundRunListHotKeys = this._runListHotKeys.bind(this);
693 /** @type {function(MouseEvent): void} */
694 this._boundOnMouseMove = this._onMouseMove.bind(this);
695 /** @type {function(MouseEvent): void} */
696 this._boundOnMouseUp = this._onMouseUp.bind(this);
697
698 /**
699 * @param {Issue} _issue
700 * @param {string} _fieldName
701 * @return {Array<string>}
702 */
703 this.extractFieldValues = (_issue, _fieldName) => [];
704
705 /**
706 * @param {IssueRefString} _issueRefString
707 * @param {string} projectName The currently viewed project.
708 * @return {Issue}
709 */
710 this._issueForRefString = (_issueRefString, projectName) =>
711 issueStringToRef(_issueRefString, projectName);
712
713 this._hiddenGroups = new Set();
714
715 this._starredIssues = new Set();
716 this._fetchingStarredIssues = false;
717 this._starringIssues = new Map();
718
719 this._uniqueValuesByColumn = new Map();
720
721 this._dragging = false;
722 this._mouseX = null;
723 this._mouseY = null;
724
725 /** @type {number} */
726 this._lastSelectedCheckbox = -1;
727
728 // Expose page.js for stubbing.
729 this._page = page;
730 /** @type {string} page data in csv format as data href */
731 this._csvDataHref = '';
732 };
733
734 /** @override */
735 stateChanged(state) {
736 this._starredIssues = issueV0.starredIssues(state);
737 this._fetchingStarredIssues =
738 issueV0.requests(state).fetchStarredIssues.requesting;
739 this._starringIssues = issueV0.starringIssues(state);
740
741 this._phaseNames = (issueV0.issueListPhaseNames(state) || []);
742 this._queryParams = sitewide.queryParams(state);
743
744 this._issueForRefString = issueV0.issueForRefString(state);
745 }
746
747 /** @override */
748 firstUpdated() {
749 // Only attach an event listener once the DOM has rendered.
750 window.addEventListener('keydown', this._boundRunListHotKeys);
751 this._dataLink = this.shadowRoot.querySelector('#hidden-data-link');
752 }
753
754 /** @override */
755 disconnectedCallback() {
756 super.disconnectedCallback();
757
758 window.removeEventListener('keydown', this._boundRunListHotKeys);
759 }
760
761 /**
762 * @override
763 * @fires CustomEvent#selectionChange
764 */
765 update(changedProperties) {
766 if (changedProperties.has('issues')) {
767 // Clear selected issues to avoid an ever-growing Set size. In the future,
768 // we may want to consider saving selections across issue reloads, though,
769 // such as in the case or list refreshing.
770 this._selectedIssues = new Set();
771 this.dispatchEvent(new CustomEvent('selectionChange'));
772
773 // Clear group toggle state when the list of issues changes to prevent an
774 // ever-growing Set size.
775 this._hiddenGroups = new Set();
776
777 this._lastSelectedCheckbox = -1;
778 }
779
780 const valuesByColumnArgs = ['issues', 'columns', 'extractFieldValues'];
781 if (setHasAny(changedProperties, valuesByColumnArgs)) {
782 this._uniqueValuesByColumn = this._computeUniqueValuesByColumn(
783 ...objectValuesForKeys(this, valuesByColumnArgs));
784 }
785
786 super.update(changedProperties);
787 }
788
789 /** @override */
790 updated(changedProperties) {
791 if (changedProperties.has('initialCursor')) {
792 const ref = issueStringToRef(this.initialCursor, this.projectName);
793 const row = this._getRowFromIssueRef(ref);
794 if (row) {
795 row.focus();
796 }
797 }
798 }
799
800 /**
801 * Iterates through all issues in a list to sort unique values
802 * across columns, for use in the "Show only" feature.
803 * @param {Array} issues
804 * @param {Array} columns
805 * @param {function(Issue, string): Array<string>} fieldExtractor
806 * @return {Map} Map where each entry has a String key for the
807 * lowercase column name and a Set value, continuing all values for
808 * that column.
809 */
810 _computeUniqueValuesByColumn(issues, columns, fieldExtractor) {
811 const valueMap = new Map(
812 columns.map((col) => [col.toLowerCase(), new Set()]));
813
814 issues.forEach((issue) => {
815 columns.forEach((col) => {
816 const key = col.toLowerCase();
817 const valueSet = valueMap.get(key);
818
819 const values = fieldExtractor(issue, col);
820 // Note: This allows multiple casings of the same values to be added
821 // to the Set.
822 values.forEach((v) => valueSet.add(v));
823 });
824 });
825 return valueMap;
826 }
827
828 /**
829 * Used for dynamically computing z-index to ensure column dropdowns overlap
830 * properly.
831 */
832 get highestZIndex() {
833 return this.columns.length + 10;
834 }
835
836 /**
837 * The number of columns displayed in the table. This is the count of
838 * customized columns + number of built in columns.
839 */
840 get numColumns() {
841 return this.columns.length + 2;
842 }
843
844 /**
845 * Sort issues into groups if groups are defined. The grouping feature is used
846 * when the "groupby" URL parameter is set in the list view.
847 */
848 get groupedIssues() {
849 if (!this.groups || !this.groups.length) return;
850
851 const issuesByGroup = new Map();
852
853 this.issues.forEach((issue) => {
854 const groupName = this._groupNameForIssue(issue);
855 const groupKey = groupName.toLowerCase();
856
857 if (!issuesByGroup.has(groupKey)) {
858 issuesByGroup.set(groupKey, {groupName, issues: [issue]});
859 } else {
860 const entry = issuesByGroup.get(groupKey);
861 entry.issues.push(issue);
862 }
863 });
864 return [...issuesByGroup.values()];
865 }
866
867 /**
868 * The currently selected issue, with _localCursor overriding initialCursor.
869 *
870 * @return {IssueRef} The currently selected issue.
871 */
872 get cursor() {
873 if (this._localCursor) {
874 return this._localCursor;
875 }
876 if (this.initialCursor) {
877 return issueStringToRef(this.initialCursor, this.projectName);
878 }
879 return {};
880 }
881
882 /**
883 * Computes the name of the group that an issue belongs to. Issues are grouped
884 * by fields that the user specifies and group names are generated using a
885 * combination of an issue's field values for all specified groups.
886 *
887 * @param {Issue} issue
888 * @return {string}
889 */
890 _groupNameForIssue(issue) {
891 const groups = this.groups;
892 const keyPieces = [];
893
894 groups.forEach((group) => {
895 const values = this.extractFieldValues(issue, group);
896 if (!values.length) {
897 keyPieces.push(`-has:${group}`);
898 } else {
899 values.forEach((v) => {
900 keyPieces.push(`${group}=${v}`);
901 });
902 }
903 });
904
905 return keyPieces.join(' ');
906 }
907
908 /**
909 * @return {Array<Issue>} Selected issues in the order they appear.
910 */
911 get selectedIssues() {
912 return this.issues.filter((issue) =>
913 this._selectedIssues.has(issueToIssueRefString(issue)));
914 }
915
916 /**
917 * Update the search query to filter values matching a specific one.
918 *
919 * @param {string} column name of the column being filtered.
920 * @param {string} value value of the field to filter by.
921 */
922 showOnly(column, value) {
923 column = column.toLowerCase();
924
925 // TODO(zhangtiff): Handle edge cases where column names are not
926 // mapped directly to field names. For example, "AllLabels", should
927 // query for "Labels".
928 const querySegment = `${column}=${value}`;
929
930 let query = this.currentQuery.trim();
931
932 if (!query.includes(querySegment)) {
933 query += ' ' + querySegment;
934
935 this._updateQueryParams({q: query.trim()}, ['start']);
936 }
937 }
938
939 /**
940 * Update sort parameter in the URL based on user input.
941 *
942 * @param {string} column name of the column to be sorted.
943 * @param {boolean} descending descending or ascending order.
944 */
945 updateSortSpec(column, descending = false) {
946 column = column.toLowerCase();
947 const oldSpec = this._queryParams.sort || '';
948 const columns = parseColSpec(oldSpec.toLowerCase());
949
950 // Remove any old instances of the same sort spec.
951 const newSpec = columns.filter(
952 (c) => c && c !== column && c !== `-${column}`);
953
954 newSpec.unshift(`${descending ? '-' : ''}${column}`);
955
956 this._updateQueryParams({sort: newSpec.join(' ')}, ['start']);
957 }
958
959 /**
960 * Updates the groupby URL parameter to include a new column to group.
961 *
962 * @param {number} i index of the column to be grouped.
963 */
964 addGroupBy(i) {
965 const groups = [...this.groups];
966 const columns = [...this.columns];
967 const groupedColumn = columns[i];
968 columns.splice(i, 1);
969
970 groups.unshift(groupedColumn);
971
972 this._updateQueryParams({
973 groupby: groups.join(' '),
974 colspec: columns.join('+'),
975 }, ['start']);
976 }
977
978 /**
979 * Removes the column at a particular index.
980 *
981 * @param {number} i the issue column to be removed.
982 */
983 removeColumn(i) {
984 const columns = [...this.columns];
985 columns.splice(i, 1);
986 this.reloadColspec(columns);
987 }
988
989 /**
990 * Adds a new column to a particular index.
991 *
992 * @param {string} name of the new column added.
993 */
994 addColumn(name) {
995 this.reloadColspec([...this.columns, name]);
996 }
997
998 /**
999 * Reflects changes to the columns of an issue list to the URL, through
1000 * frontend routing.
1001 *
1002 * @param {Array} newColumns the new colspec to set in the URL.
1003 */
1004 reloadColspec(newColumns) {
1005 this._updateQueryParams({colspec: newColumns.join('+')});
1006 }
1007
1008 /**
1009 * Navigates to the same URL as the current page, but with query
1010 * params updated.
1011 *
1012 * @param {Object} newParams keys and values of the queryParams
1013 * Object to be updated.
1014 * @param {Array} deletedParams keys to be cleared from queryParams.
1015 */
1016 _updateQueryParams(newParams = {}, deletedParams = []) {
1017 const url = urlWithNewParams(this._baseUrl(), this._queryParams, newParams,
1018 deletedParams);
1019 this._page(url);
1020 }
1021
1022 /**
1023 * Get the current URL of the page, without query params. Useful for
1024 * test stubbing.
1025 *
1026 * @return {string} the URL of the list page, without params.
1027 */
1028 _baseUrl() {
1029 return window.location.pathname;
1030 }
1031
1032 /**
1033 * Run issue list hot keys. This event handler needs to be bound globally
1034 * because a list cursor can be defined even when no element in the list is
1035 * focused.
1036 * @param {KeyboardEvent} e
1037 */
1038 _runListHotKeys(e) {
1039 if (!this.issues || !this.issues.length) return;
1040 const target = findDeepEventTarget(e);
1041 if (!target || isTextInput(target)) return;
1042
1043 const key = e.key;
1044
1045 const activeRow = this._getCursorElement();
1046
1047 let i = -1;
1048 if (activeRow) {
1049 i = Number.parseInt(activeRow.dataset.index);
1050
1051 const issue = this.issues[i];
1052
1053 switch (key) {
1054 case 's': // Star focused issue.
1055 this._starIssue(issueToIssueRef(issue));
1056 return;
1057 case 'x': // Toggle selection of focused issue.
1058 const issueRefString = issueToIssueRefString(issue);
1059 this._updateSelectedIssues([issueRefString],
1060 !this._selectedIssues.has(issueRefString));
1061 return;
1062 case 'o': // Open current issue.
1063 case 'O': // Open current issue in new tab.
1064 this._navigateToIssue(issue, e.shiftKey);
1065 return;
1066 }
1067 }
1068
1069 // Move up and down the issue list.
1070 // 'j' moves 'down'.
1071 // 'k' moves 'up'.
1072 if (key === 'j' || key === 'k') {
1073 if (key === 'j') { // Navigate down the list.
1074 i += 1;
1075 if (i >= this.issues.length) {
1076 i = 0;
1077 }
1078 } else if (key === 'k') { // Navigate up the list.
1079 i -= 1;
1080 if (i < 0) {
1081 i = this.issues.length - 1;
1082 }
1083 }
1084
1085 const nextRow = this.shadowRoot.querySelector(`.row-${i}`);
1086 this._setRowAsCursor(nextRow);
1087 }
1088 }
1089
1090 /**
1091 * @return {HTMLTableRowElement}
1092 */
1093 _getCursorElement() {
1094 const cursor = this.cursor;
1095 if (cursor) {
1096 // If there's a cursor set, use that instead of focus.
1097 return this._getRowFromIssueRef(cursor);
1098 }
1099 return;
1100 }
1101
1102 /**
1103 * @param {FocusEvent} e
1104 */
1105 _setRowAsCursorOnFocus(e) {
1106 this._setRowAsCursor(/** @type {HTMLTableRowElement} */ (e.target));
1107 }
1108
1109 /**
1110 *
1111 * @param {HTMLTableRowElement} row
1112 */
1113 _setRowAsCursor(row) {
1114 this._localCursor = issueStringToRef(row.dataset.issueRef,
1115 this.projectName);
1116 row.focus();
1117 }
1118
1119 /**
1120 * @param {IssueRef} ref The issueRef to query for.
1121 * @return {HTMLTableRowElement}
1122 */
1123 _getRowFromIssueRef(ref) {
1124 return this.shadowRoot.querySelector(
1125 `.list-row[data-issue-ref="${issueRefToString(ref)}"]`);
1126 }
1127
1128 /**
1129 * Returns an Array containing every <tr> in the list, excluding the header.
1130 * @return {Array<HTMLTableRowElement>}
1131 */
1132 _getRows() {
1133 return Array.from(this.shadowRoot.querySelectorAll('.list-row'));
1134 }
1135
1136 /**
1137 * Returns an Array containing every selected <tr> in the list.
1138 * @return {Array<HTMLTableRowElement>}
1139 */
1140 _getSelectedRows() {
1141 return this._getRows().filter((row) => {
1142 return this._selectedIssues.has(row.dataset.issueRef);
1143 });
1144 }
1145
1146 /**
1147 * @param {IssueRef} issueRef Issue to star
1148 */
1149 _starIssue(issueRef) {
1150 if (!this.starringEnabled) return;
1151 const issueKey = issueRefToString(issueRef);
1152
1153 // TODO(zhangtiff): Find way to share star disabling logic more.
1154 const isStarring = this._starringIssues.has(issueKey) &&
1155 this._starringIssues.get(issueKey).requesting;
1156 const starEnabled = !this._fetchingStarredIssues && !isStarring;
1157 if (starEnabled) {
1158 const newIsStarred = !this._starredIssues.has(issueKey);
1159 this._starIssueInternal(issueRef, newIsStarred);
1160 }
1161 }
1162
1163 /**
1164 * Wrap store.dispatch and issue.star, for testing.
1165 *
1166 * @param {IssueRef} issueRef the issue being starred.
1167 * @param {boolean} newIsStarred whether to star or unstar the issue.
1168 * @private
1169 */
1170 _starIssueInternal(issueRef, newIsStarred) {
1171 store.dispatch(issueV0.star(issueRef, newIsStarred));
1172 }
1173 /**
1174 * @param {Event} e
1175 * @fires CustomEvent#open-dialog
1176 * @private
1177 */
1178 _selectAll(e) {
1179 const checkbox = /** @type {HTMLInputElement} */ (e.target);
1180
1181 if (checkbox.checked) {
1182 this._selectedIssues = new Set(this.issues.map(issueRefToString));
1183 } else {
1184 this._selectedIssues = new Set();
1185 }
1186 this.dispatchEvent(new CustomEvent('selectionChange'));
1187 }
1188
1189 // TODO(zhangtiff): Implement Shift+Click to select a range of checkboxes
1190 // for the 'x' hot key.
1191 /**
1192 * @param {MouseEvent} e
1193 * @private
1194 */
1195 _selectIssueRange(e) {
1196 if (!this.selectionEnabled) return;
1197
1198 const checkbox = /** @type {HTMLInputElement} */ (e.target);
1199
1200 const index = Number.parseInt(checkbox.dataset.index);
1201 if (Number.isNaN(index)) {
1202 console.error('Issue checkbox has invalid data-index attribute.');
1203 return;
1204 }
1205
1206 const lastIndex = this._lastSelectedCheckbox;
1207 if (e.shiftKey && lastIndex >= 0) {
1208 const newCheckedState = checkbox.checked;
1209
1210 const start = Math.min(lastIndex, index);
1211 const end = Math.max(lastIndex, index) + 1;
1212
1213 const updatedIssueKeys = this.issues.slice(start, end).map(
1214 issueToIssueRefString);
1215 this._updateSelectedIssues(updatedIssueKeys, newCheckedState);
1216 }
1217
1218 this._lastSelectedCheckbox = index;
1219 }
1220
1221 /**
1222 * @param {Event} e
1223 * @private
1224 */
1225 _selectIssue(e) {
1226 if (!this.selectionEnabled) return;
1227
1228 const checkbox = /** @type {HTMLInputElement} */ (e.target);
1229 const issueKey = checkbox.value;
1230
1231 this._updateSelectedIssues([issueKey], checkbox.checked);
1232 }
1233
1234 /**
1235 * @param {Array<IssueRefString>} issueKeys Stringified issue refs.
1236 * @param {boolean} selected
1237 * @fires CustomEvent#selectionChange
1238 * @private
1239 */
1240 _updateSelectedIssues(issueKeys, selected) {
1241 let hasChanges = false;
1242
1243 issueKeys.forEach((issueKey) => {
1244 const oldSelection = this._selectedIssues.has(issueKey);
1245
1246 if (selected) {
1247 this._selectedIssues.add(issueKey);
1248 } else if (this._selectedIssues.has(issueKey)) {
1249 this._selectedIssues.delete(issueKey);
1250 }
1251
1252 const newSelection = this._selectedIssues.has(issueKey);
1253
1254 hasChanges = hasChanges || newSelection !== oldSelection;
1255 });
1256
1257
1258 if (hasChanges) {
1259 this.requestUpdate('_selectedIssues');
1260 this.dispatchEvent(new CustomEvent('selectionChange'));
1261 }
1262 }
1263
1264 /**
1265 * Handles 'Enter' being pressed when a row is focused.
1266 * Note we install the 'Enter' listener on the row rather than the window so
1267 * 'Enter' behaves as expected when the focus is on other elements.
1268 *
1269 * @param {KeyboardEvent} e
1270 * @private
1271 */
1272 _keydownIssueRow(e) {
1273 if (e.key === 'Enter') {
1274 this._maybeOpenIssueRow(e);
1275 }
1276 }
1277
1278 /**
1279 * Handles mouseDown to start drag events.
1280 * @param {MouseEvent} event
1281 * @private
1282 */
1283 _onMouseDown(event) {
1284 event.cancelable && event.preventDefault();
1285
1286 this._mouseX = event.clientX;
1287 this._mouseY = event.clientY;
1288
1289 this._setRowAsCursor(event.currentTarget.parentNode);
1290 this._startDrag();
1291
1292 // We add the event listeners to window because the mouse can go out of the
1293 // bounds of the target element. window.mouseUp still triggers even if the
1294 // mouse is outside the browser window.
1295 window.addEventListener('mousemove', this._boundOnMouseMove);
1296 window.addEventListener('mouseup', this._boundOnMouseUp);
1297 }
1298
1299 /**
1300 * Handles mouseMove to continue drag events.
1301 * @param {MouseEvent} event
1302 * @private
1303 */
1304 _onMouseMove(event) {
1305 event.cancelable && event.preventDefault();
1306
1307 const x = event.clientX - this._mouseX;
1308 const y = event.clientY - this._mouseY;
1309 this._continueDrag(x, y);
1310 }
1311
1312 /**
1313 * Handles mouseUp to end drag events.
1314 * @param {MouseEvent} event
1315 * @private
1316 */
1317 _onMouseUp(event) {
1318 event.cancelable && event.preventDefault();
1319
1320 window.removeEventListener('mousemove', this._boundOnMouseMove);
1321 window.removeEventListener('mouseup', this._boundOnMouseUp);
1322
1323 this._endDrag(event.clientY - this._mouseY);
1324 }
1325
1326 /**
1327 * Gives a visual indicator that we've started dragging an issue row.
1328 * @private
1329 */
1330 _startDrag() {
1331 this._dragging = true;
1332
1333 // If the dragged row is not selected, select it.
1334 // TODO(dtu): Allow dragging an existing selection for multi-drag.
1335 const issueRefString = issueRefToString(this.cursor);
1336 this._selectedIssues = new Set();
1337 this._updateSelectedIssues([issueRefString], true);
1338 }
1339
1340 /**
1341 * @param {number} x The x-distance the cursor has moved since mouseDown.
1342 * @param {number} y The y-distance the cursor has moved since mouseDown.
1343 * @private
1344 */
1345 _continueDrag(x, y) {
1346 // Unselected rows: Transition them to their new positions.
1347 const [rows, initialIndex, finalIndex] = this._computeRerank(y);
1348 this._translateRows(rows, initialIndex, finalIndex);
1349
1350 // Selected rows: Stick them to the cursor. No transition.
1351 for (const row of this._getSelectedRows()) {
1352 row.style.transform = `translate(${x}px, ${y}px`;
1353 };
1354 }
1355
1356 /**
1357 * @param {number} y The y-distance the cursor has moved since mouseDown.
1358 * @private
1359 */
1360 async _endDrag(y) {
1361 this._dragging = false;
1362
1363 // Unselected rows: Transition them to their new positions.
1364 const [rows, initialIndex, finalIndex] = this._computeRerank(y);
1365 const targetTranslation =
1366 this._translateRows(rows, initialIndex, finalIndex);
1367
1368 // Selected rows: Transition them to their final positions
1369 // and reset their opacity.
1370 const selectedRows = this._getSelectedRows();
1371 for (const row of selectedRows) {
1372 row.style.transition = EASE_OUT_TRANSITION;
1373 row.style.transform = `translate(0px, ${targetTranslation}px)`;
1374 };
1375
1376 // Submit the change.
1377 const items = selectedRows.map((row) => row.dataset.name);
1378 await this.rerank(items, finalIndex);
1379
1380 // Reset the transforms.
1381 for (const row of this._getRows()) {
1382 row.style.transition = '';
1383 row.style.transform = '';
1384 };
1385
1386 // Set the cursor to the new row.
1387 // In order to focus the correct element, we need the DOM to be in sync
1388 // with the issue list. We modified this.issues, so wait for a re-render.
1389 await this.updateComplete;
1390 const selector = `.list-row[data-index="${finalIndex}"]`;
1391 this.shadowRoot.querySelector(selector).focus();
1392 }
1393
1394 /**
1395 * Computes the starting and ending indices of the cursor row,
1396 * given how far the mouse has been dragged in the y-direction.
1397 * The indices assume the cursor row has been removed from the list.
1398 * @param {number} y The y-distance the cursor has moved since mouseDown.
1399 * @return {[Array<HTMLTableRowElement>, number, number]} A tuple containing:
1400 * An Array of table rows with the cursor row removed.
1401 * The initial index of the cursor row.
1402 * The final index of the cursor row.
1403 * @private
1404 */
1405 _computeRerank(y) {
1406 const row = this._getCursorElement();
1407 const rows = this._getRows();
1408 const listTop = row.parentNode.offsetTop;
1409
1410 // Find the initial index of the cursor row.
1411 // TODO(dtu): If we support multi-drag, this should be the adjusted index of
1412 // the first selected row after collapsing spaces in the selected group.
1413 const initialIndex = rows.indexOf(row);
1414 rows.splice(initialIndex, 1);
1415
1416 // Compute the initial and final y-positions of the top
1417 // of the cursor row relative to the top of the list.
1418 const initialY = row.offsetTop - listTop;
1419 const finalY = initialY + y;
1420
1421 // Compute the final index of the cursor row.
1422 // The break points are the halfway marks of each row.
1423 let finalIndex = 0;
1424 for (finalIndex = 0; finalIndex < rows.length; ++finalIndex) {
1425 const rowTop = rows[finalIndex].offsetTop - listTop -
1426 (finalIndex >= initialIndex ? row.scrollHeight : 0);
1427 const breakpoint = rowTop + rows[finalIndex].scrollHeight / 2;
1428 if (breakpoint > finalY) {
1429 break;
1430 }
1431 }
1432
1433 return [rows, initialIndex, finalIndex];
1434 }
1435
1436 /**
1437 * @param {Array<HTMLTableRowElement>} rows Array of table rows with the
1438 * cursor row removed.
1439 * @param {number} initialIndex The initial index of the cursor row.
1440 * @param {number} finalIndex The final index of the cursor row.
1441 * @return {number} The number of pixels the cursor row moved.
1442 * @private
1443 */
1444 _translateRows(rows, initialIndex, finalIndex) {
1445 const firstIndex = Math.min(initialIndex, finalIndex);
1446 const lastIndex = Math.max(initialIndex, finalIndex);
1447
1448 const rowHeight = this._getCursorElement().scrollHeight;
1449 const translation = initialIndex < finalIndex ? -rowHeight : rowHeight;
1450
1451 let targetTranslation = 0;
1452 for (let i = 0; i < rows.length; ++i) {
1453 rows[i].style.transition = EASE_OUT_TRANSITION;
1454 if (i >= firstIndex && i < lastIndex) {
1455 rows[i].style.transform = `translate(0px, ${translation}px)`;
1456 targetTranslation += rows[i].scrollHeight;
1457 } else {
1458 rows[i].style.transform = '';
1459 }
1460 }
1461
1462 return initialIndex < finalIndex ? targetTranslation : -targetTranslation;
1463 }
1464
1465 /**
1466 * Handle click and auxclick on issue row.
1467 * @param {MouseEvent} event
1468 * @private
1469 */
1470 _clickIssueRow(event) {
1471 if (event.button === PRIMARY_BUTTON || event.button === MIDDLE_BUTTON) {
1472 this._maybeOpenIssueRow(
1473 event, /* openNewTab= */ event.button === MIDDLE_BUTTON);
1474 }
1475 }
1476
1477 /**
1478 * Checks that the given event should not be ignored, then navigates to the
1479 * issue associated with the row.
1480 *
1481 * @param {MouseEvent|KeyboardEvent} rowEvent A click or 'enter' on a row.
1482 * @param {boolean=} openNewTab Forces opening in a new tab
1483 * @private
1484 */
1485 _maybeOpenIssueRow(rowEvent, openNewTab = false) {
1486 const path = rowEvent.composedPath();
1487 const containsIgnoredElement = path.find(
1488 (node) => (node.tagName || '').toUpperCase() === 'A' ||
1489 (node.classList && node.classList.contains('ignore-navigation')));
1490 if (containsIgnoredElement) return;
1491
1492 const row = /** @type {HTMLTableRowElement} */ (rowEvent.currentTarget);
1493
1494 const i = Number.parseInt(row.dataset.index);
1495
1496 if (i >= 0 && i < this.issues.length) {
1497 this._navigateToIssue(this.issues[i], openNewTab || rowEvent.metaKey ||
1498 rowEvent.ctrlKey);
1499 }
1500 }
1501
1502 /**
1503 * @param {Issue} issue
1504 * @param {boolean} newTab
1505 * @private
1506 */
1507 _navigateToIssue(issue, newTab) {
1508 const link = issueRefToUrl(issueToIssueRef(issue),
1509 this._queryParams);
1510
1511 if (newTab) {
1512 // Whether the link opens in a new tab or window is based on the
1513 // user's browser preferences.
1514 window.open(link, '_blank', 'noopener');
1515 } else {
1516 this._page(link);
1517 }
1518 }
1519
1520 /**
1521 * Convert an issue's data into an array of strings, where the columns
1522 * match this.columns. Extracting data like _renderCell.
1523 * @param {Issue} issue
1524 * @return {Array<string>}
1525 * @private
1526 */
1527 _convertIssueToPlaintextArray(issue) {
1528 return this.columns.map((column) => {
1529 return this.extractFieldValues(issue, column).join(', ');
1530 });
1531 }
1532
1533 /**
1534 * Convert each Issue into array of strings, where the columns
1535 * match this.columns.
1536 * @return {Array<Array<string>>}
1537 * @private
1538 */
1539 _convertIssuesToPlaintextArrays() {
1540 return this.issues.map(this._convertIssueToPlaintextArray.bind(this));
1541 }
1542
1543 /**
1544 * Download content as csv. Conversion to CSV only on button click
1545 * instead of on data change because CSV download is not often used.
1546 * @param {MouseEvent} event
1547 * @private
1548 */
1549 async _downloadCsv(event) {
1550 event.preventDefault();
1551
1552 if (this.userDisplayName) {
1553 // convert issues to array of arrays of strings
1554 const issueData = this._convertIssuesToPlaintextArrays();
1555
1556 // convert the data into csv formatted string.
1557 const csvDataString = prepareDataForDownload(issueData, this.columns);
1558
1559 // construct data href
1560 const href = constructHref(csvDataString);
1561
1562 // modify a tag's href
1563 this._csvDataHref = href;
1564 await this.requestUpdate('_csvDataHref');
1565
1566 // click to trigger download
1567 this._dataLink.click();
1568
1569 // reset dataHref
1570 this._csvDataHref = '';
1571 }
1572 }
1573};
1574
1575customElements.define('mr-issue-list', MrIssueList);