| // Copyright 2019 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {LitElement, html, css} from 'lit-element'; |
| import page from 'page'; |
| import qs from 'qs'; |
| import {store, connectStore} from 'reducers/base.js'; |
| import * as issueV0 from 'reducers/issueV0.js'; |
| import * as projectV0 from 'reducers/projectV0.js'; |
| import * as userV0 from 'reducers/userV0.js'; |
| import * as sitewide from 'reducers/sitewide.js'; |
| import * as ui from 'reducers/ui.js'; |
| import {prpcClient} from 'prpc-client-instance.js'; |
| import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js'; |
| import {DEFAULT_ISSUE_FIELD_LIST, parseColSpec} from 'shared/issue-fields.js'; |
| import { |
| shouldWaitForDefaultQuery, |
| urlWithNewParams, |
| userIsMember, |
| } from 'shared/helpers.js'; |
| import {SHARED_STYLES} from 'shared/shared-styles.js'; |
| import 'elements/framework/dialogs/mr-change-columns/mr-change-columns.js'; |
| // eslint-disable-next-line max-len |
| import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js'; |
| import 'elements/framework/mr-button-bar/mr-button-bar.js'; |
| import 'elements/framework/mr-dropdown/mr-dropdown.js'; |
| import 'elements/framework/mr-issue-list/mr-issue-list.js'; |
| import '../mr-mode-selector/mr-mode-selector.js'; |
| |
| export const DEFAULT_ISSUES_PER_PAGE = 100; |
| const PARAMS_THAT_TRIGGER_REFRESH = ['sort', 'groupby', 'num', |
| 'start']; |
| const SNACKBAR_LOADING = 'Loading issues...'; |
| |
| /** |
| * `<mr-list-page>` |
| * |
| * Container page for the list view |
| */ |
| export class MrListPage extends connectStore(LitElement) { |
| /** @override */ |
| static get styles() { |
| return [ |
| SHARED_STYLES, |
| css` |
| :host { |
| display: block; |
| box-sizing: border-box; |
| width: 100%; |
| padding: 0.5em 8px; |
| } |
| .container-loading, |
| .container-no-issues { |
| width: 100%; |
| box-sizing: border-box; |
| padding: 0 8px; |
| font-size: var(--chops-main-font-size); |
| } |
| .container-no-issues { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| } |
| .container-no-issues p { |
| margin: 0.5em; |
| } |
| .no-issues-block { |
| display: block; |
| padding: 1em 16px; |
| margin-top: 1em; |
| flex-grow: 1; |
| width: 300px; |
| max-width: 100%; |
| text-align: center; |
| background: var(--chops-primary-accent-bg); |
| border-radius: 8px; |
| border-bottom: var(--chops-normal-border); |
| } |
| .no-issues-block[hidden] { |
| display: none; |
| } |
| .list-controls { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| width: 100%; |
| padding: 0.5em 0; |
| } |
| .right-controls { |
| flex-grow: 0; |
| display: flex; |
| align-items: center; |
| justify-content: flex-end; |
| } |
| .next-link, .prev-link { |
| display: inline-block; |
| margin: 0 8px; |
| } |
| mr-mode-selector { |
| margin-left: 8px; |
| } |
| `, |
| ]; |
| } |
| |
| /** @override */ |
| render() { |
| const selectedRefs = this.selectedIssues.map( |
| ({localId, projectName}) => ({localId, projectName})); |
| |
| return html` |
| ${this._renderControls()} |
| ${this._renderListBody()} |
| <mr-update-issue-hotlists-dialog |
| .issueRefs=${selectedRefs} |
| @saveSuccess=${this._showHotlistSaveSnackbar} |
| ></mr-update-issue-hotlists-dialog> |
| <mr-change-columns |
| .columns=${this.columns} |
| .queryParams=${this._queryParams} |
| ></mr-change-columns> |
| `; |
| } |
| |
| /** |
| * @return {TemplateResult} |
| */ |
| _renderListBody() { |
| if (!this._issueListLoaded) { |
| return html` |
| <div class="container-loading"> |
| Loading... |
| </div> |
| `; |
| } else if (!this.totalIssues) { |
| return html` |
| <div class="container-no-issues"> |
| <p> |
| The search query: |
| </p> |
| <strong>${this._queryParams.q}</strong> |
| <p> |
| did not generate any results. |
| </p> |
| <div class="no-issues-block"> |
| Type a new query in the search box above |
| </div> |
| <a |
| href=${this._urlWithNewParams({can: 2, q: ''})} |
| class="no-issues-block view-all-open" |
| > |
| View all open issues |
| </a> |
| <a |
| href=${this._urlWithNewParams({can: 1})} |
| class="no-issues-block consider-closed" |
| ?hidden=${this._queryParams.can === '1'} |
| > |
| Consider closed issues |
| </a> |
| </div> |
| `; |
| } |
| |
| return html` |
| <mr-issue-list |
| .issues=${this.issues} |
| .projectName=${this.projectName} |
| .queryParams=${this._queryParams} |
| .initialCursor=${this._queryParams.cursor} |
| .currentQuery=${this.currentQuery} |
| .currentCan=${this.currentCan} |
| .columns=${this.columns} |
| .defaultFields=${DEFAULT_ISSUE_FIELD_LIST} |
| .extractFieldValues=${this._extractFieldValues} |
| .groups=${this.groups} |
| .userDisplayName=${this.userDisplayName} |
| ?selectionEnabled=${this.editingEnabled} |
| ?sortingAndGroupingEnabled=${true} |
| ?starringEnabled=${this.starringEnabled} |
| @selectionChange=${this._setSelectedIssues} |
| ></mr-issue-list> |
| `; |
| } |
| |
| /** |
| * @return {TemplateResult} |
| */ |
| _renderControls() { |
| const maxItems = this.maxItems; |
| const startIndex = this.startIndex; |
| const end = Math.min(startIndex + maxItems, this.totalIssues); |
| const hasNext = end < this.totalIssues; |
| const hasPrev = startIndex > 0; |
| |
| return html` |
| <div class="list-controls"> |
| <div> |
| ${this.editingEnabled ? html` |
| <mr-button-bar .items=${this._actions}></mr-button-bar> |
| ` : ''} |
| </div> |
| |
| <div class="right-controls"> |
| ${hasPrev ? html` |
| <a |
| href=${this._urlWithNewParams({start: startIndex - maxItems})} |
| class="prev-link" |
| > |
| ‹ Prev |
| </a> |
| ` : ''} |
| <div class="issue-count" ?hidden=${!this.totalIssues}> |
| ${startIndex + 1} - ${end} of ${this.totalIssuesDisplay} |
| </div> |
| ${hasNext ? html` |
| <a |
| href=${this._urlWithNewParams({start: startIndex + maxItems})} |
| class="next-link" |
| > |
| Next › |
| </a> |
| ` : ''} |
| <mr-mode-selector |
| .projectName=${this.projectName} |
| .queryParams=${this._queryParams} |
| value="list" |
| ></mr-mode-selector> |
| </div> |
| </div> |
| `; |
| } |
| |
| /** @override */ |
| static get properties() { |
| return { |
| issues: {type: Array}, |
| totalIssues: {type: Number}, |
| /** @private {Object} */ |
| _queryParams: {type: Object}, |
| projectName: {type: String}, |
| _fetchingIssueList: {type: Boolean}, |
| _issueListLoaded: {type: Boolean}, |
| selectedIssues: {type: Array}, |
| columns: {type: Array}, |
| userDisplayName: {type: String}, |
| /** |
| * The current search string the user is querying for. |
| */ |
| currentQuery: {type: String}, |
| /** |
| * The current canned query the user is searching for. |
| */ |
| currentCan: {type: String}, |
| /** |
| * A function that takes in an issue and a field name and returns the |
| * value for that field in the issue. This function accepts custom fields, |
| * built in fields, and ad hoc fields computed from label prefixes. |
| */ |
| _extractFieldValues: {type: Object}, |
| _isLoggedIn: {type: Boolean}, |
| _currentUser: {type: Object}, |
| _usersProjects: {type: Object}, |
| _fetchIssueListError: {type: String}, |
| _presentationConfigLoaded: {type: Boolean}, |
| }; |
| }; |
| |
| /** @override */ |
| constructor() { |
| super(); |
| this.issues = []; |
| this._fetchingIssueList = false; |
| this._issueListLoaded = false; |
| this.selectedIssues = []; |
| this._queryParams = {}; |
| this.columns = []; |
| this._usersProjects = new Map(); |
| this._presentationConfigLoaded = false; |
| |
| this._boundRefresh = this.refresh.bind(this); |
| |
| this._actions = [ |
| {icon: 'edit', text: 'Bulk edit', handler: this.bulkEdit.bind(this)}, |
| { |
| icon: 'add', text: 'Add to hotlist', |
| handler: this.addToHotlist.bind(this), |
| }, |
| { |
| icon: 'table_chart', text: 'Change columns', |
| handler: this.openColumnsDialog.bind(this), |
| }, |
| {icon: 'more_vert', text: 'More actions...', items: [ |
| {text: 'Flag as spam', handler: () => this._flagIssues(true)}, |
| {text: 'Un-flag as spam', handler: () => this._flagIssues(false)}, |
| ]}, |
| ]; |
| |
| /** |
| * @param {Issue} _issue |
| * @param {string} _fieldName |
| * @return {Array<string>} |
| */ |
| this._extractFieldValues = (_issue, _fieldName) => []; |
| |
| // Expose page.js for test stubbing. |
| this.page = page; |
| }; |
| |
| /** @override */ |
| connectedCallback() { |
| super.connectedCallback(); |
| |
| window.addEventListener('refreshList', this._boundRefresh); |
| |
| // TODO(zhangtiff): Consider if we can make this page title more useful for |
| // the list view. |
| store.dispatch(sitewide.setPageTitle('Issues')); |
| } |
| |
| /** @override */ |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| |
| window.removeEventListener('refreshList', this._boundRefresh); |
| |
| this._hideIssueLoadingSnackbar(); |
| } |
| |
| /** @override */ |
| updated(changedProperties) { |
| this._measureIssueListLoadTime(changedProperties); |
| |
| if (changedProperties.has('_fetchingIssueList')) { |
| const wasFetching = changedProperties.get('_fetchingIssueList'); |
| const isFetching = this._fetchingIssueList; |
| // Show a snackbar if waiting for issues to load but only when there's |
| // already a different, non-empty issue list loaded. This approach avoids |
| // clearing the issue list for a loading screen. |
| if (isFetching && this.totalIssues > 0) { |
| this._showIssueLoadingSnackbar(); |
| } |
| if (wasFetching && !isFetching) { |
| this._hideIssueLoadingSnackbar(); |
| } |
| } |
| |
| if (changedProperties.has('userDisplayName')) { |
| store.dispatch(issueV0.fetchStarredIssues()); |
| } |
| |
| if (changedProperties.has('_fetchIssueListError') && |
| this._fetchIssueListError) { |
| this._showIssueErrorSnackbar(this._fetchIssueListError); |
| } |
| |
| const shouldRefresh = this._shouldRefresh(changedProperties); |
| if (shouldRefresh) this.refresh(); |
| } |
| |
| /** |
| * Tracks the start and end times of an issues list render and |
| * records an issue list load time. |
| * @param {Map} changedProperties |
| */ |
| async _measureIssueListLoadTime(changedProperties) { |
| if (!changedProperties.has('issues')) { |
| return; |
| } |
| |
| if (!changedProperties.get('issues')) { |
| // Ignore initial initialization from the constructer where |
| // 'issues' is set from undefined to an empty array. |
| return; |
| } |
| |
| const fullAppLoad = ui.navigationCount(store.getState()) == 1; |
| const startMark = fullAppLoad ? undefined : 'start load issue list page'; |
| |
| await Promise.all(_subtreeUpdateComplete(this)); |
| |
| const endMark = 'finish load list of issues'; |
| performance.mark(endMark); |
| |
| const measurementType = fullAppLoad ? 'from outside app' : 'within app'; |
| const measurementName = `load list of issues (${measurementType})`; |
| performance.measure(measurementName, startMark, endMark); |
| |
| const measurement = performance.getEntriesByName( |
| measurementName)[0].duration; |
| window.getTSMonClient().recordIssueListLoadTiming(measurement, fullAppLoad); |
| |
| // Be sure to clear this mark even on full page navigation. |
| performance.clearMarks('start load issue list page'); |
| performance.clearMarks(endMark); |
| performance.clearMeasures(measurementName); |
| } |
| |
| /** |
| * Considers if list-page should fetch ListIssues |
| * @param {Map} changedProperties |
| * @return {boolean} |
| */ |
| _shouldRefresh(changedProperties) { |
| const wait = shouldWaitForDefaultQuery(this._queryParams); |
| if (wait && !this._presentationConfigLoaded) { |
| return false; |
| } else if (wait && this._presentationConfigLoaded && |
| changedProperties.has('_presentationConfigLoaded')) { |
| return true; |
| } else if (changedProperties.has('projectName') || |
| changedProperties.has('currentQuery') || |
| changedProperties.has('currentCan')) { |
| return true; |
| } else if (changedProperties.has('_queryParams')) { |
| const oldParams = changedProperties.get('_queryParams') || {}; |
| |
| const shouldRefresh = PARAMS_THAT_TRIGGER_REFRESH.some((param) => { |
| const oldValue = oldParams[param]; |
| const newValue = this._queryParams[param]; |
| return oldValue !== newValue; |
| }); |
| return shouldRefresh; |
| } |
| return false; |
| } |
| |
| // TODO(crbug.com/monorail/6933): Remove the need for this wrapper. |
| /** Dispatches a Redux action to show an issues loading snackbar. */ |
| _showIssueLoadingSnackbar() { |
| store.dispatch(ui.showSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST, |
| SNACKBAR_LOADING, 0)); |
| } |
| |
| /** Dispatches a Redux action to hide the issue loading snackbar. */ |
| _hideIssueLoadingSnackbar() { |
| store.dispatch(ui.hideSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST)); |
| } |
| |
| /** |
| * Shows a snackbar telling the user their issue loading failed. |
| * @param {string} error The error to display. |
| */ |
| _showIssueErrorSnackbar(error) { |
| store.dispatch(ui.showSnackbar(ui.snackbarNames.FETCH_ISSUE_LIST_ERROR, |
| error)); |
| } |
| |
| /** |
| * Refreshes the list of issues show. |
| */ |
| refresh() { |
| store.dispatch(issueV0.fetchIssueList(this.projectName, { |
| ...this._queryParams, |
| q: this.currentQuery, |
| can: this.currentCan, |
| maxItems: this.maxItems, |
| start: this.startIndex, |
| })); |
| } |
| |
| /** @override */ |
| stateChanged(state) { |
| this.projectName = projectV0.viewedProjectName(state); |
| this._isLoggedIn = userV0.isLoggedIn(state); |
| this._currentUser = userV0.currentUser(state); |
| this._usersProjects = userV0.projectsPerUser(state); |
| |
| this.issues = issueV0.issueList(state) || []; |
| this.totalIssues = issueV0.totalIssues(state) || 0; |
| this._fetchingIssueList = issueV0.requests(state).fetchIssueList.requesting; |
| this._issueListLoaded = issueV0.issueListLoaded(state); |
| |
| const error = issueV0.requests(state).fetchIssueList.error; |
| this._fetchIssueListError = error ? error.message : ''; |
| |
| this.currentQuery = sitewide.currentQuery(state); |
| this.currentCan = sitewide.currentCan(state); |
| this.columns = |
| sitewide.currentColumns(state) || projectV0.defaultColumns(state); |
| |
| this._queryParams = sitewide.queryParams(state); |
| |
| this._extractFieldValues = projectV0.extractFieldValuesFromIssue(state); |
| this._presentationConfigLoaded = |
| projectV0.viewedPresentationConfigLoaded(state); |
| } |
| |
| /** |
| * @return {string} Display text of total issue number. |
| */ |
| get totalIssuesDisplay() { |
| if (this.totalIssues === 1) { |
| return `${this.totalIssues}`; |
| } else if (this.totalIssues === SERVER_LIST_ISSUES_LIMIT) { |
| // Server has hard limit up to 100,000 list results |
| return `100,000+`; |
| } |
| return `${this.totalIssues}`; |
| } |
| |
| /** |
| * @return {boolean} Whether the user is able to star the issues in the list. |
| */ |
| get starringEnabled() { |
| return this._isLoggedIn; |
| } |
| |
| /** |
| * @return {boolean} Whether the user has permissions to edit the issues in |
| * the list. |
| */ |
| get editingEnabled() { |
| return this._isLoggedIn && (userIsMember(this._currentUser, |
| this.projectName, this._usersProjects) || |
| this._currentUser.isSiteAdmin); |
| } |
| |
| /** |
| * @return {Array<string>} Array of columns to group by. |
| */ |
| get groups() { |
| return parseColSpec(this._queryParams.groupby); |
| } |
| |
| /** |
| * @return {number} Maximum number of issues to load for this query. |
| */ |
| get maxItems() { |
| return Number.parseInt(this._queryParams.num) || DEFAULT_ISSUES_PER_PAGE; |
| } |
| |
| /** |
| * @return {number} Number of issues to offset by, based on pagination. |
| */ |
| get startIndex() { |
| const num = Number.parseInt(this._queryParams.start) || 0; |
| return Math.max(0, num); |
| } |
| |
| /** |
| * Computes the current URL of the page with updated queryParams. |
| * |
| * @param {Object} newParams keys and values to override existing parameters. |
| * @return {string} the new URL. |
| */ |
| _urlWithNewParams(newParams) { |
| const baseUrl = `/p/${this.projectName}/issues/list`; |
| return urlWithNewParams(baseUrl, this._queryParams, newParams); |
| } |
| |
| /** |
| * Shows the user an alert telling them their action won't work. |
| * @param {string} action Text describing what you're trying to do. |
| */ |
| noneSelectedAlert(action) { |
| // TODO(zhangtiff): Replace this with a modal for a more modern feel. |
| alert(`Please select some issues to ${action}.`); |
| } |
| |
| /** |
| * Opens the the column selector. |
| */ |
| openColumnsDialog() { |
| this.shadowRoot.querySelector('mr-change-columns').open(); |
| } |
| |
| /** |
| * Opens a modal to add the selected issues to a hotlist. |
| */ |
| addToHotlist() { |
| const issues = this.selectedIssues; |
| if (!issues || !issues.length) { |
| this.noneSelectedAlert('add to hotlists'); |
| return; |
| } |
| this.shadowRoot.querySelector('mr-update-issue-hotlists-dialog').open(); |
| } |
| |
| /** |
| * Redirects the user to the bulk edit page for the issues they've selected. |
| */ |
| bulkEdit() { |
| const issues = this.selectedIssues; |
| if (!issues || !issues.length) { |
| this.noneSelectedAlert('edit'); |
| return; |
| } |
| const params = { |
| ids: issues.map((issue) => issue.localId).join(','), |
| q: this._queryParams && this._queryParams.q, |
| }; |
| this.page(`/p/${this.projectName}/issues/bulkedit?${qs.stringify(params)}`); |
| } |
| |
| /** Shows user confirmation that their hotlist changes were saved. */ |
| _showHotlistSaveSnackbar() { |
| store.dispatch(ui.showSnackbar(ui.snackbarNames.UPDATE_HOTLISTS_SUCCESS, |
| 'Hotlists updated.')); |
| } |
| |
| /** |
| * Flags the selected issues as spam. |
| * @param {boolean} flagAsSpam If true, flag as spam. If false, unflag |
| * as spam. |
| */ |
| async _flagIssues(flagAsSpam = true) { |
| const issues = this.selectedIssues; |
| if (!issues || !issues.length) { |
| return this.noneSelectedAlert( |
| `${flagAsSpam ? 'flag' : 'un-flag'} as spam`); |
| } |
| const refs = issues.map((issue) => ({ |
| localId: issue.localId, |
| projectName: issue.projectName, |
| })); |
| |
| // TODO(zhangtiff): Refactor this into a shared action creator and |
| // display the error on the frontend. |
| try { |
| await prpcClient.call('monorail.Issues', 'FlagIssues', { |
| issueRefs: refs, |
| flag: flagAsSpam, |
| }); |
| this.refresh(); |
| } catch (e) { |
| console.error(e); |
| } |
| } |
| |
| /** |
| * Syncs this component's selected issues with the child component's selected |
| * issues. |
| */ |
| _setSelectedIssues() { |
| const issueListRef = this.shadowRoot.querySelector('mr-issue-list'); |
| if (!issueListRef) return; |
| |
| this.selectedIssues = issueListRef.selectedIssues; |
| } |
| }; |
| |
| |
| /** |
| * Recursively traverses all shadow DOMs in an element subtree and returns an |
| * Array containing the updateComplete Promises for all lit-element nodes. |
| * @param {!LitElement} element |
| * @return {!Array<Promise<Boolean>>} |
| */ |
| function _subtreeUpdateComplete(element) { |
| if (!(element.shadowRoot && element.updateComplete)) { |
| return []; |
| } |
| |
| const children = element.shadowRoot.querySelectorAll('*'); |
| const childPromises = Array.from(children, (e) => _subtreeUpdateComplete(e)); |
| return [element.updateComplete].concat(...childPromises); |
| } |
| |
| customElements.define('mr-list-page', MrListPage); |