blob: 0b568f863bfff6fceb4ed824fd583af3126519c2 [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.
4
5import {LitElement, html, css} from 'lit-element';
6import page from 'page';
7import qs from 'qs';
8import {store, connectStore} from 'reducers/base.js';
9import * as issueV0 from 'reducers/issueV0.js';
10import * as projectV0 from 'reducers/projectV0.js';
11import * as userV0 from 'reducers/userV0.js';
12import * as sitewide from 'reducers/sitewide.js';
13import * as ui from 'reducers/ui.js';
14import {prpcClient} from 'prpc-client-instance.js';
15import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js';
16import {DEFAULT_ISSUE_FIELD_LIST, parseColSpec} from 'shared/issue-fields.js';
17import {
18 shouldWaitForDefaultQuery,
19 urlWithNewParams,
20 userIsMember,
21} from 'shared/helpers.js';
22import {SHARED_STYLES} from 'shared/shared-styles.js';
23import 'elements/framework/dialogs/mr-change-columns/mr-change-columns.js';
24// eslint-disable-next-line max-len
25import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js';
26import 'elements/framework/mr-button-bar/mr-button-bar.js';
27import 'elements/framework/mr-dropdown/mr-dropdown.js';
28import 'elements/framework/mr-issue-list/mr-issue-list.js';
29import '../mr-mode-selector/mr-mode-selector.js';
30
31export const DEFAULT_ISSUES_PER_PAGE = 100;
32const PARAMS_THAT_TRIGGER_REFRESH = ['sort', 'groupby', 'num',
33 'start'];
34const SNACKBAR_LOADING = 'Loading issues...';
35
36/**
37 * `<mr-list-page>`
38 *
39 * Container page for the list view
40 */
41export class MrListPage extends connectStore(LitElement) {
42 /** @override */
43 static get styles() {
44 return [
45 SHARED_STYLES,
46 css`
47 :host {
48 display: block;
49 box-sizing: border-box;
50 width: 100%;
51 padding: 0.5em 8px;
52 }
53 .container-loading,
54 .container-no-issues {
55 width: 100%;
56 box-sizing: border-box;
57 padding: 0 8px;
58 font-size: var(--chops-main-font-size);
59 }
60 .container-no-issues {
61 display: flex;
62 flex-direction: column;
63 align-items: center;
64 justify-content: center;
65 }
66 .container-no-issues p {
67 margin: 0.5em;
68 }
69 .no-issues-block {
70 display: block;
71 padding: 1em 16px;
72 margin-top: 1em;
73 flex-grow: 1;
74 width: 300px;
75 max-width: 100%;
76 text-align: center;
77 background: var(--chops-primary-accent-bg);
78 border-radius: 8px;
79 border-bottom: var(--chops-normal-border);
80 }
81 .no-issues-block[hidden] {
82 display: none;
83 }
84 .list-controls {
85 display: flex;
86 align-items: center;
87 justify-content: space-between;
88 width: 100%;
89 padding: 0.5em 0;
90 }
91 .right-controls {
92 flex-grow: 0;
93 display: flex;
94 align-items: center;
95 justify-content: flex-end;
96 }
97 .next-link, .prev-link {
98 display: inline-block;
99 margin: 0 8px;
100 }
101 mr-mode-selector {
102 margin-left: 8px;
103 }
104 `,
105 ];
106 }
107
108 /** @override */
109 render() {
110 const selectedRefs = this.selectedIssues.map(
111 ({localId, projectName}) => ({localId, projectName}));
112
113 return html`
114 ${this._renderControls()}
115 ${this._renderListBody()}
116 <mr-update-issue-hotlists-dialog
117 .issueRefs=${selectedRefs}
118 @saveSuccess=${this._showHotlistSaveSnackbar}
119 ></mr-update-issue-hotlists-dialog>
120 <mr-change-columns
121 .columns=${this.columns}
122 .queryParams=${this._queryParams}
123 ></mr-change-columns>
124 `;
125 }
126
127 /**
128 * @return {TemplateResult}
129 */
130 _renderListBody() {
131 if (!this._issueListLoaded) {
132 return html`
133 <div class="container-loading">
134 Loading...
135 </div>
136 `;
137 } else if (!this.totalIssues) {
138 return html`
139 <div class="container-no-issues">
140 <p>
141 The search query:
142 </p>
143 <strong>${this._queryParams.q}</strong>
144 <p>
145 did not generate any results.
146 </p>
147 <div class="no-issues-block">
148 Type a new query in the search box above
149 </div>
150 <a
151 href=${this._urlWithNewParams({can: 2, q: ''})}
152 class="no-issues-block view-all-open"
153 >
154 View all open issues
155 </a>
156 <a
157 href=${this._urlWithNewParams({can: 1})}
158 class="no-issues-block consider-closed"
159 ?hidden=${this._queryParams.can === '1'}
160 >
161 Consider closed issues
162 </a>
163 </div>
164 `;
165 }
166
167 return html`
168 <mr-issue-list
169 .issues=${this.issues}
170 .projectName=${this.projectName}
171 .queryParams=${this._queryParams}
172 .initialCursor=${this._queryParams.cursor}
173 .currentQuery=${this.currentQuery}
174 .currentCan=${this.currentCan}
175 .columns=${this.columns}
176 .defaultFields=${DEFAULT_ISSUE_FIELD_LIST}
177 .extractFieldValues=${this._extractFieldValues}
178 .groups=${this.groups}
179 .userDisplayName=${this.userDisplayName}
180 ?selectionEnabled=${this.editingEnabled}
181 ?sortingAndGroupingEnabled=${true}
182 ?starringEnabled=${this.starringEnabled}
183 @selectionChange=${this._setSelectedIssues}
184 ></mr-issue-list>
185 `;
186 }
187
188 /**
189 * @return {TemplateResult}
190 */
191 _renderControls() {
192 const maxItems = this.maxItems;
193 const startIndex = this.startIndex;
194 const end = Math.min(startIndex + maxItems, this.totalIssues);
195 const hasNext = end < this.totalIssues;
196 const hasPrev = startIndex > 0;
197
198 return html`
199 <div class="list-controls">
200 <div>
201 ${this.editingEnabled ? html`
202 <mr-button-bar .items=${this._actions}></mr-button-bar>
203 ` : ''}
204 </div>
205
206 <div class="right-controls">
207 ${hasPrev ? html`
208 <a
209 href=${this._urlWithNewParams({start: startIndex - maxItems})}
210 class="prev-link"
211 >
212 &lsaquo; Prev
213 </a>
214 ` : ''}
215 <div class="issue-count" ?hidden=${!this.totalIssues}>
216 ${startIndex + 1} - ${end} of ${this.totalIssuesDisplay}
217 </div>
218 ${hasNext ? html`
219 <a
220 href=${this._urlWithNewParams({start: startIndex + maxItems})}
221 class="next-link"
222 >
223 Next &rsaquo;
224 </a>
225 ` : ''}
226 <mr-mode-selector
227 .projectName=${this.projectName}
228 .queryParams=${this._queryParams}
229 value="list"
230 ></mr-mode-selector>
231 </div>
232 </div>
233 `;
234 }
235
236 /** @override */
237 static get properties() {
238 return {
239 issues: {type: Array},
240 totalIssues: {type: Number},
241 /** @private {Object} */
242 _queryParams: {type: Object},
243 projectName: {type: String},
244 _fetchingIssueList: {type: Boolean},
245 _issueListLoaded: {type: Boolean},
246 selectedIssues: {type: Array},
247 columns: {type: Array},
248 userDisplayName: {type: String},
249 /**
250 * The current search string the user is querying for.
251 */
252 currentQuery: {type: String},
253 /**
254 * The current canned query the user is searching for.
255 */
256 currentCan: {type: String},
257 /**
258 * A function that takes in an issue and a field name and returns the
259 * value for that field in the issue. This function accepts custom fields,
260 * built in fields, and ad hoc fields computed from label prefixes.
261 */
262 _extractFieldValues: {type: Object},
263 _isLoggedIn: {type: Boolean},
264 _currentUser: {type: Object},
265 _usersProjects: {type: Object},
266 _fetchIssueListError: {type: String},
267 _presentationConfigLoaded: {type: Boolean},
268 };
269 };
270
271 /** @override */
272 constructor() {
273 super();
274 this.issues = [];
275 this._fetchingIssueList = false;
276 this._issueListLoaded = false;
277 this.selectedIssues = [];
278 this._queryParams = {};
279 this.columns = [];
280 this._usersProjects = new Map();
281 this._presentationConfigLoaded = false;
282
283 this._boundRefresh = this.refresh.bind(this);
284
285 this._actions = [
286 {icon: 'edit', text: 'Bulk edit', handler: this.bulkEdit.bind(this)},
287 {
288 icon: 'add', text: 'Add to hotlist',
289 handler: this.addToHotlist.bind(this),
290 },
291 {
292 icon: 'table_chart', text: 'Change columns',
293 handler: this.openColumnsDialog.bind(this),
294 },
295 {icon: 'more_vert', text: 'More actions...', items: [
296 {text: 'Flag as spam', handler: () => this._flagIssues(true)},
297 {text: 'Un-flag as spam', handler: () => this._flagIssues(false)},
298 ]},
299 ];
300
301 /**
302 * @param {Issue} _issue
303 * @param {string} _fieldName
304 * @return {Array<string>}
305 */
306 this._extractFieldValues = (_issue, _fieldName) => [];
307
308 // Expose page.js for test stubbing.
309 this.page = page;
310 };
311
312 /** @override */
313 connectedCallback() {
314 super.connectedCallback();
315
316 window.addEventListener('refreshList', this._boundRefresh);
317
318 // TODO(zhangtiff): Consider if we can make this page title more useful for
319 // the list view.
320 store.dispatch(sitewide.setPageTitle('Issues'));
321 }
322
323 /** @override */
324 disconnectedCallback() {
325 super.disconnectedCallback();
326
327 window.removeEventListener('refreshList', this._boundRefresh);
328
329 this._hideIssueLoadingSnackbar();
330 }
331
332 /** @override */
333 updated(changedProperties) {
334 this._measureIssueListLoadTime(changedProperties);
335
336 if (changedProperties.has('_fetchingIssueList')) {
337 const wasFetching = changedProperties.get('_fetchingIssueList');
338 const isFetching = this._fetchingIssueList;
339 // Show a snackbar if waiting for issues to load but only when there's
340 // already a different, non-empty issue list loaded. This approach avoids
341 // clearing the issue list for a loading screen.
342 if (isFetching && this.totalIssues > 0) {
343 this._showIssueLoadingSnackbar();
344 }
345 if (wasFetching && !isFetching) {
346 this._hideIssueLoadingSnackbar();
347 }
348 }
349
350 if (changedProperties.has('userDisplayName')) {
351 store.dispatch(issueV0.fetchStarredIssues());
352 }
353
354 if (changedProperties.has('_fetchIssueListError') &&
355 this._fetchIssueListError) {
356 this._showIssueErrorSnackbar(this._fetchIssueListError);
357 }
358
359 const shouldRefresh = this._shouldRefresh(changedProperties);
360 if (shouldRefresh) this.refresh();
361 }
362
363 /**
364 * Tracks the start and end times of an issues list render and
365 * records an issue list load time.
366 * @param {Map} changedProperties
367 */
368 async _measureIssueListLoadTime(changedProperties) {
369 if (!changedProperties.has('issues')) {
370 return;
371 }
372
373 if (!changedProperties.get('issues')) {
374 // Ignore initial initialization from the constructer where
375 // 'issues' is set from undefined to an empty array.
376 return;
377 }
378
379 const fullAppLoad = ui.navigationCount(store.getState()) == 1;
380 const startMark = fullAppLoad ? undefined : 'start load issue list page';
381
382 await Promise.all(_subtreeUpdateComplete(this));
383
384 const endMark = 'finish load list of issues';
385 performance.mark(endMark);
386
387 const measurementType = fullAppLoad ? 'from outside app' : 'within app';
388 const measurementName = `load list of issues (${measurementType})`;
389 performance.measure(measurementName, startMark, endMark);
390
391 const measurement = performance.getEntriesByName(
392 measurementName)[0].duration;
393 window.getTSMonClient().recordIssueListLoadTiming(measurement, fullAppLoad);
394
395 // Be sure to clear this mark even on full page navigation.
396 performance.clearMarks('start load issue list page');
397 performance.clearMarks(endMark);
398 performance.clearMeasures(measurementName);
399 }
400
401 /**
402 * Considers if list-page should fetch ListIssues
403 * @param {Map} changedProperties
404 * @return {boolean}
405 */
406 _shouldRefresh(changedProperties) {
407 const wait = shouldWaitForDefaultQuery(this._queryParams);
408 if (wait && !this._presentationConfigLoaded) {
409 return false;
410 } else if (wait && this._presentationConfigLoaded &&
411 changedProperties.has('_presentationConfigLoaded')) {
412 return true;
413 } else if (changedProperties.has('projectName') ||
414 changedProperties.has('currentQuery') ||
415 changedProperties.has('currentCan')) {
416 return true;
417 } else if (changedProperties.has('_queryParams')) {
418 const oldParams = changedProperties.get('_queryParams') || {};
419
420 const shouldRefresh = PARAMS_THAT_TRIGGER_REFRESH.some((param) => {
421 const oldValue = oldParams[param];
422 const newValue = this._queryParams[param];
423 return oldValue !== newValue;
424 });
425 return shouldRefresh;
426 }
427 return false;
428 }
429
430 // TODO(crbug.com/monorail/6933): Remove the need for this wrapper.
431 /** Dispatches a Redux action to show an issues loading snackbar. */
432 _showIssueLoadingSnackbar() {
433 store.dispatch(ui.showSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST,
434 SNACKBAR_LOADING, 0));
435 }
436
437 /** Dispatches a Redux action to hide the issue loading snackbar. */
438 _hideIssueLoadingSnackbar() {
439 store.dispatch(ui.hideSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST));
440 }
441
442 /**
443 * Shows a snackbar telling the user their issue loading failed.
444 * @param {string} error The error to display.
445 */
446 _showIssueErrorSnackbar(error) {
447 store.dispatch(ui.showSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST_ERROR,
448 error));
449 }
450
451 /**
452 * Refreshes the list of issues show.
453 */
454 refresh() {
455 store.dispatch(issueV0.fetchIssueList(this.projectName, {
456 ...this._queryParams,
457 q: this.currentQuery,
458 can: this.currentCan,
459 maxItems: this.maxItems,
460 start: this.startIndex,
461 }));
462 }
463
464 /** @override */
465 stateChanged(state) {
466 this.projectName = projectV0.viewedProjectName(state);
467 this._isLoggedIn = userV0.isLoggedIn(state);
468 this._currentUser = userV0.currentUser(state);
469 this._usersProjects = userV0.projectsPerUser(state);
470
471 this.issues = issueV0.issueList(state) || [];
472 this.totalIssues = issueV0.totalIssues(state) || 0;
473 this._fetchingIssueList = issueV0.requests(state).fetchIssueList.requesting;
474 this._issueListLoaded = issueV0.issueListLoaded(state);
475
476 const error = issueV0.requests(state).fetchIssueList.error;
477 this._fetchIssueListError = error ? error.message : '';
478
479 this.currentQuery = sitewide.currentQuery(state);
480 this.currentCan = sitewide.currentCan(state);
481 this.columns =
482 sitewide.currentColumns(state) || projectV0.defaultColumns(state);
483
484 this._queryParams = sitewide.queryParams(state);
485
486 this._extractFieldValues = projectV0.extractFieldValuesFromIssue(state);
487 this._presentationConfigLoaded =
488 projectV0.viewedPresentationConfigLoaded(state);
489 }
490
491 /**
492 * @return {string} Display text of total issue number.
493 */
494 get totalIssuesDisplay() {
495 if (this.totalIssues === 1) {
496 return `${this.totalIssues}`;
497 } else if (this.totalIssues === SERVER_LIST_ISSUES_LIMIT) {
498 // Server has hard limit up to 100,000 list results
499 return `100,000+`;
500 }
501 return `${this.totalIssues}`;
502 }
503
504 /**
505 * @return {boolean} Whether the user is able to star the issues in the list.
506 */
507 get starringEnabled() {
508 return this._isLoggedIn;
509 }
510
511 /**
512 * @return {boolean} Whether the user has permissions to edit the issues in
513 * the list.
514 */
515 get editingEnabled() {
516 return this._isLoggedIn && (userIsMember(this._currentUser,
517 this.projectName, this._usersProjects) ||
518 this._currentUser.isSiteAdmin);
519 }
520
521 /**
522 * @return {Array<string>} Array of columns to group by.
523 */
524 get groups() {
525 return parseColSpec(this._queryParams.groupby);
526 }
527
528 /**
529 * @return {number} Maximum number of issues to load for this query.
530 */
531 get maxItems() {
532 return Number.parseInt(this._queryParams.num) || DEFAULT_ISSUES_PER_PAGE;
533 }
534
535 /**
536 * @return {number} Number of issues to offset by, based on pagination.
537 */
538 get startIndex() {
539 const num = Number.parseInt(this._queryParams.start) || 0;
540 return Math.max(0, num);
541 }
542
543 /**
544 * Computes the current URL of the page with updated queryParams.
545 *
546 * @param {Object} newParams keys and values to override existing parameters.
547 * @return {string} the new URL.
548 */
549 _urlWithNewParams(newParams) {
550 const baseUrl = `/p/${this.projectName}/issues/list`;
551 return urlWithNewParams(baseUrl, this._queryParams, newParams);
552 }
553
554 /**
555 * Shows the user an alert telling them their action won't work.
556 * @param {string} action Text describing what you're trying to do.
557 */
558 noneSelectedAlert(action) {
559 // TODO(zhangtiff): Replace this with a modal for a more modern feel.
560 alert(`Please select some issues to ${action}.`);
561 }
562
563 /**
564 * Opens the the column selector.
565 */
566 openColumnsDialog() {
567 this.shadowRoot.querySelector('mr-change-columns').open();
568 }
569
570 /**
571 * Opens a modal to add the selected issues to a hotlist.
572 */
573 addToHotlist() {
574 const issues = this.selectedIssues;
575 if (!issues || !issues.length) {
576 this.noneSelectedAlert('add to hotlists');
577 return;
578 }
579 this.shadowRoot.querySelector('mr-update-issue-hotlists-dialog').open();
580 }
581
582 /**
583 * Redirects the user to the bulk edit page for the issues they've selected.
584 */
585 bulkEdit() {
586 const issues = this.selectedIssues;
587 if (!issues || !issues.length) {
588 this.noneSelectedAlert('edit');
589 return;
590 }
591 const params = {
592 ids: issues.map((issue) => issue.localId).join(','),
593 q: this._queryParams && this._queryParams.q,
594 };
595 this.page(`/p/${this.projectName}/issues/bulkedit?${qs.stringify(params)}`);
596 }
597
598 /** Shows user confirmation that their hotlist changes were saved. */
599 _showHotlistSaveSnackbar() {
600 store.dispatch(ui.showSnackbar(ui.snackbarNames.UPDATE_HOTLISTS_SUCCESS,
601 'Hotlists updated.'));
602 }
603
604 /**
605 * Flags the selected issues as spam.
606 * @param {boolean} flagAsSpam If true, flag as spam. If false, unflag
607 * as spam.
608 */
609 async _flagIssues(flagAsSpam = true) {
610 const issues = this.selectedIssues;
611 if (!issues || !issues.length) {
612 return this.noneSelectedAlert(
613 `${flagAsSpam ? 'flag' : 'un-flag'} as spam`);
614 }
615 const refs = issues.map((issue) => ({
616 localId: issue.localId,
617 projectName: issue.projectName,
618 }));
619
620 // TODO(zhangtiff): Refactor this into a shared action creator and
621 // display the error on the frontend.
622 try {
623 await prpcClient.call('monorail.Issues', 'FlagIssues', {
624 issueRefs: refs,
625 flag: flagAsSpam,
626 });
627 this.refresh();
628 } catch (e) {
629 console.error(e);
630 }
631 }
632
633 /**
634 * Syncs this component's selected issues with the child component's selected
635 * issues.
636 */
637 _setSelectedIssues() {
638 const issueListRef = this.shadowRoot.querySelector('mr-issue-list');
639 if (!issueListRef) return;
640
641 this.selectedIssues = issueListRef.selectedIssues;
642 }
643};
644
645
646/**
647 * Recursively traverses all shadow DOMs in an element subtree and returns an
648 * Array containing the updateComplete Promises for all lit-element nodes.
649 * @param {!LitElement} element
650 * @return {!Array<Promise<Boolean>>}
651 */
652function _subtreeUpdateComplete(element) {
653 if (!(element.shadowRoot && element.updateComplete)) {
654 return [];
655 }
656
657 const children = element.shadowRoot.querySelectorAll('*');
658 const childPromises = Array.from(children, (e) => _subtreeUpdateComplete(e));
659 return [element.updateComplete].concat(...childPromises);
660}
661
662customElements.define('mr-list-page', MrListPage);