Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-list/mr-list-page/mr-list-page.js b/static_src/elements/issue-list/mr-list-page/mr-list-page.js
new file mode 100644
index 0000000..809c3fc
--- /dev/null
+++ b/static_src/elements/issue-list/mr-list-page/mr-list-page.js
@@ -0,0 +1,662 @@
+// 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);
diff --git a/static_src/elements/issue-list/mr-list-page/mr-list-page.test.js b/static_src/elements/issue-list/mr-list-page/mr-list-page.test.js
new file mode 100644
index 0000000..0f1d4ac
--- /dev/null
+++ b/static_src/elements/issue-list/mr-list-page/mr-list-page.test.js
@@ -0,0 +1,615 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrListPage, DEFAULT_ISSUES_PER_PAGE} from './mr-list-page.js';
+import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js';
+import {store, resetState} from 'reducers/base.js';
+
+let element;
+
+describe('mr-list-page', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+ element = document.createElement('mr-list-page');
+ document.body.appendChild(element);
+ sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrListPage);
+ });
+
+ it('shows loading page when issues not loaded yet', async () => {
+ element._issueListLoaded = false;
+
+ await element.updateComplete;
+
+ const loading = element.shadowRoot.querySelector('.container-loading');
+ const noIssues = element.shadowRoot.querySelector('.container-no-issues');
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+
+ assert.equal(loading.textContent.trim(), 'Loading...');
+ assert.isNull(noIssues);
+ assert.isNull(issueList);
+ });
+
+ it('does not clear existing issue list when loading new issues', async () => {
+ element._fetchingIssueList = true;
+ element._issueListLoaded = true;
+
+ element.totalIssues = 1;
+ element.issues = [{localId: 1, projectName: 'chromium'}];
+
+ await element.updateComplete;
+
+ const loading = element.shadowRoot.querySelector('.container-loading');
+ const noIssues = element.shadowRoot.querySelector('.container-no-issues');
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+
+ assert.isNull(loading);
+ assert.isNull(noIssues);
+ assert.isNotNull(issueList);
+ // TODO(crbug.com/monorail/6560): We intend for the snackbar to be shown,
+ // but it is hidden because the store thinks we have 0 total issues.
+ });
+
+ it('shows list when done loading', async () => {
+ element._fetchingIssueList = false;
+ element._issueListLoaded = true;
+
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const loading = element.shadowRoot.querySelector('.container-loading');
+ const noIssues = element.shadowRoot.querySelector('.container-no-issues');
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+
+ assert.isNull(loading);
+ assert.isNull(noIssues);
+ assert.isNotNull(issueList);
+ });
+
+ describe('issue loading snackbar', () => {
+ beforeEach(() => {
+ sinon.spy(store, 'dispatch');
+ });
+
+ afterEach(() => {
+ store.dispatch.restore();
+ });
+
+ it('shows snackbar when loading new list of issues', async () => {
+ sinon.stub(element, 'stateChanged');
+ sinon.stub(element, '_showIssueLoadingSnackbar');
+
+ element._fetchingIssueList = true;
+ element.totalIssues = 1;
+ element.issues = [{localId: 1, projectName: 'chromium'}];
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element._showIssueLoadingSnackbar);
+ });
+
+ it('hides snackbar when issues are done loading', async () => {
+ element._fetchingIssueList = true;
+ element.totalIssues = 1;
+ element.issues = [{localId: 1, projectName: 'chromium'}];
+
+ await element.updateComplete;
+
+ sinon.assert.neverCalledWith(store.dispatch,
+ {type: 'HIDE_SNACKBAR', id: 'FETCH_ISSUE_LIST'});
+
+ element._fetchingIssueList = false;
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(store.dispatch,
+ {type: 'HIDE_SNACKBAR', id: 'FETCH_ISSUE_LIST'});
+ });
+
+ it('hides snackbar when <mr-list-page> disconnects', async () => {
+ document.body.removeChild(element);
+
+ sinon.assert.calledWith(store.dispatch,
+ {type: 'HIDE_SNACKBAR', id: 'FETCH_ISSUE_LIST'});
+
+ document.body.appendChild(element);
+ });
+
+ it('shows snackbar on issue loading error', async () => {
+ sinon.stub(element, 'stateChanged');
+ sinon.stub(element, '_showIssueErrorSnackbar');
+
+ element._fetchIssueListError = 'Something went wrong';
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element._showIssueErrorSnackbar,
+ 'Something went wrong');
+ });
+ });
+
+ it('shows no issues when no search results', async () => {
+ element._fetchingIssueList = false;
+ element._issueListLoaded = true;
+
+ element.totalIssues = 0;
+ element._queryParams = {q: 'owner:me'};
+
+ await element.updateComplete;
+
+ const loading = element.shadowRoot.querySelector('.container-loading');
+ const noIssues = element.shadowRoot.querySelector('.container-no-issues');
+ const issueList = element.shadowRoot.querySelector('mr-issue-list');
+
+ assert.isNull(loading);
+ assert.isNotNull(noIssues);
+ assert.isNull(issueList);
+
+ assert.equal(noIssues.querySelector('strong').textContent.trim(),
+ 'owner:me');
+ });
+
+ it('offers consider closed issues when no open results', async () => {
+ element._fetchingIssueList = false;
+ element._issueListLoaded = true;
+
+ element.totalIssues = 0;
+ element._queryParams = {q: 'owner:me', can: '2'};
+
+ await element.updateComplete;
+
+ const considerClosed = element.shadowRoot.querySelector('.consider-closed');
+
+ assert.isFalse(considerClosed.hidden);
+
+ element._queryParams = {q: 'owner:me', can: '1'};
+ element._fetchingIssueList = false;
+ element._issueListLoaded = true;
+
+ await element.updateComplete;
+
+ assert.isTrue(considerClosed.hidden);
+ });
+
+ it('refreshes when _queryParams.sort changes', async () => {
+ sinon.stub(element, 'refresh');
+
+ element._queryParams = {q: ''};
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 1);
+
+ element._queryParams = {q: '', colspec: 'Summary+ID'};
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 1);
+
+ element._queryParams = {q: '', sort: '-Summary'};
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 2);
+
+ element.refresh.restore();
+ });
+
+ it('refreshes when currentQuery changes', async () => {
+ sinon.stub(element, 'refresh');
+
+ element._queryParams = {q: ''};
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 1);
+
+ element.currentQuery = 'some query term';
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 2);
+
+ element.refresh.restore();
+ });
+
+ it('does not refresh when presentation config not fetched', async () => {
+ sinon.stub(element, 'refresh');
+
+ element._presentationConfigLoaded = false;
+ element.currentQuery = 'some query term';
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 0);
+
+ element.refresh.restore();
+ });
+
+ it('refreshes if presentation config fetch finishes last', async () => {
+ sinon.stub(element, 'refresh');
+
+ element._presentationConfigLoaded = false;
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 0);
+
+ element._presentationConfigLoaded = true;
+ element.currentQuery = 'some query term';
+
+ await element.updateComplete;
+ sinon.assert.callCount(element.refresh, 1);
+
+ element.refresh.restore();
+ });
+
+ it('startIndex parses _queryParams for value', () => {
+ // Default value.
+ element._queryParams = {};
+ assert.equal(element.startIndex, 0);
+
+ // Int.
+ element._queryParams = {start: 2};
+ assert.equal(element.startIndex, 2);
+
+ // String.
+ element._queryParams = {start: '5'};
+ assert.equal(element.startIndex, 5);
+
+ // Negative value.
+ element._queryParams = {start: -5};
+ assert.equal(element.startIndex, 0);
+
+ // NaN
+ element._queryParams = {start: 'lol'};
+ assert.equal(element.startIndex, 0);
+ });
+
+ it('maxItems parses _queryParams for value', () => {
+ // Default value.
+ element._queryParams = {};
+ assert.equal(element.maxItems, DEFAULT_ISSUES_PER_PAGE);
+
+ // Int.
+ element._queryParams = {num: 50};
+ assert.equal(element.maxItems, 50);
+
+ // String.
+ element._queryParams = {num: '33'};
+ assert.equal(element.maxItems, 33);
+
+ // NaN
+ element._queryParams = {num: 'lol'};
+ assert.equal(element.maxItems, DEFAULT_ISSUES_PER_PAGE);
+ });
+
+ it('parses groupby parameter correctly', () => {
+ element._queryParams = {groupby: 'Priority+Status'};
+
+ assert.deepEqual(element.groups,
+ ['Priority', 'Status']);
+ });
+
+ it('groupby parsing preserves dashed parameters', () => {
+ element._queryParams = {groupby: 'Priority+Custom-Status'};
+
+ assert.deepEqual(element.groups,
+ ['Priority', 'Custom-Status']);
+ });
+
+ describe('pagination', () => {
+ beforeEach(() => {
+ // Stop Redux from overriding values being tested.
+ sinon.stub(element, 'stateChanged');
+ });
+
+ it('issue count hidden when no issues', async () => {
+ element._queryParams = {num: 10, start: 0};
+ element.totalIssues = 0;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.isTrue(count.hidden);
+ });
+
+ it('issue count renders on first page', async () => {
+ element._queryParams = {num: 10, start: 0};
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.equal(count.textContent.trim(), '1 - 10 of 100');
+ });
+
+ it('issue count renders on middle page', async () => {
+ element._queryParams = {num: 10, start: 50};
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.equal(count.textContent.trim(), '51 - 60 of 100');
+ });
+
+ it('issue count renders on last page', async () => {
+ element._queryParams = {num: 10, start: 95};
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.equal(count.textContent.trim(), '96 - 100 of 100');
+ });
+
+ it('issue count renders on single page', async () => {
+ element._queryParams = {num: 100, start: 0};
+ element.totalIssues = 33;
+
+ await element.updateComplete;
+
+ const count = element.shadowRoot.querySelector('.issue-count');
+
+ assert.equal(count.textContent.trim(), '1 - 33 of 33');
+ });
+
+ it('total issue count shows backend limit of 100,000', () => {
+ element.totalIssues = SERVER_LIST_ISSUES_LIMIT;
+ assert.equal(element.totalIssuesDisplay, '100,000+');
+ });
+
+ it('next and prev hidden on single page', async () => {
+ element._queryParams = {num: 500, start: 0};
+ element.totalIssues = 10;
+
+ await element.updateComplete;
+
+ const next = element.shadowRoot.querySelector('.next-link');
+ const prev = element.shadowRoot.querySelector('.prev-link');
+
+ assert.isNull(next);
+ assert.isNull(prev);
+ });
+
+ it('prev hidden on first page', async () => {
+ element._queryParams = {num: 10, start: 0};
+ element.totalIssues = 30;
+
+ await element.updateComplete;
+
+ const next = element.shadowRoot.querySelector('.next-link');
+ const prev = element.shadowRoot.querySelector('.prev-link');
+
+ assert.isNotNull(next);
+ assert.isNull(prev);
+ });
+
+ it('next hidden on last page', async () => {
+ element._queryParams = {num: 10, start: 9};
+ element.totalIssues = 5;
+
+ await element.updateComplete;
+
+ const next = element.shadowRoot.querySelector('.next-link');
+ const prev = element.shadowRoot.querySelector('.prev-link');
+
+ assert.isNull(next);
+ assert.isNotNull(prev);
+ });
+
+ it('next and prev shown on middle page', async () => {
+ element._queryParams = {num: 10, start: 50};
+ element.totalIssues = 100;
+
+ await element.updateComplete;
+
+ const next = element.shadowRoot.querySelector('.next-link');
+ const prev = element.shadowRoot.querySelector('.prev-link');
+
+ assert.isNotNull(next);
+ assert.isNotNull(prev);
+ });
+ });
+
+ describe('edit actions', () => {
+ beforeEach(() => {
+ sinon.stub(window, 'alert');
+
+ // Give the test user edit privileges.
+ element._isLoggedIn = true;
+ element._currentUser = {isSiteAdmin: true};
+ });
+
+ afterEach(() => {
+ window.alert.restore();
+ });
+
+ it('edit actions hidden when user is logged out', async () => {
+ element._isLoggedIn = false;
+
+ await element.updateComplete;
+
+ assert.isNull(element.shadowRoot.querySelector('mr-button-bar'));
+ });
+
+ it('edit actions hidden when user is not a project member', async () => {
+ element._isLoggedIn = true;
+ element._currentUser = {displayName: 'regular@user.com'};
+
+ await element.updateComplete;
+
+ assert.isNull(element.shadowRoot.querySelector('mr-button-bar'));
+ });
+
+ it('edit actions shown when user is a project member', async () => {
+ element.projectName = 'chromium';
+ element._isLoggedIn = true;
+ element._currentUser = {isSiteAdmin: false, userId: '123'};
+ element._usersProjects = new Map([['123', {ownerOf: ['chromium']}]]);
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.shadowRoot.querySelector('mr-button-bar'));
+
+ element.projectName = 'nonmember-project';
+ await element.updateComplete;
+
+ assert.isNull(element.shadowRoot.querySelector('mr-button-bar'));
+ });
+
+ it('edit actions shown when user is a site admin', async () => {
+ element._isLoggedIn = true;
+ element._currentUser = {isSiteAdmin: true};
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.shadowRoot.querySelector('mr-button-bar'));
+ });
+
+ it('bulk edit stops when no issues selected', () => {
+ element.selectedIssues = [];
+ element.projectName = 'test';
+
+ element.bulkEdit();
+
+ sinon.assert.calledWith(window.alert,
+ 'Please select some issues to edit.');
+ });
+
+ it('bulk edit redirects to bulk edit page', () => {
+ element.page = sinon.stub();
+ element.selectedIssues = [
+ {localId: 1},
+ {localId: 2},
+ ];
+ element.projectName = 'test';
+
+ element.bulkEdit();
+
+ sinon.assert.calledWith(element.page,
+ '/p/test/issues/bulkedit?ids=1%2C2');
+ });
+
+ it('flag issue as spam stops when no issues selected', () => {
+ element.selectedIssues = [];
+
+ element._flagIssues(true);
+
+ sinon.assert.calledWith(window.alert,
+ 'Please select some issues to flag as spam.');
+ });
+
+ it('un-flag issue as spam stops when no issues selected', () => {
+ element.selectedIssues = [];
+
+ element._flagIssues(false);
+
+ sinon.assert.calledWith(window.alert,
+ 'Please select some issues to un-flag as spam.');
+ });
+
+ it('flagging issues as spam sends pRPC request', async () => {
+ element.page = sinon.stub();
+ element.selectedIssues = [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ];
+
+ await element._flagIssues(true);
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'FlagIssues', {
+ issueRefs: [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ],
+ flag: true,
+ });
+ });
+
+ it('un-flagging issues as spam sends pRPC request', async () => {
+ element.page = sinon.stub();
+ element.selectedIssues = [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ];
+
+ await element._flagIssues(false);
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'FlagIssues', {
+ issueRefs: [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ],
+ flag: false,
+ });
+ });
+
+ it('clicking change columns opens dialog', async () => {
+ await element.updateComplete;
+ const dialog = element.shadowRoot.querySelector('mr-change-columns');
+ sinon.stub(dialog, 'open');
+
+ element.openColumnsDialog();
+
+ sinon.assert.calledOnce(dialog.open);
+ });
+
+ it('add to hotlist stops when no issues selected', () => {
+ element.selectedIssues = [];
+ element.projectName = 'test';
+
+ element.addToHotlist();
+
+ sinon.assert.calledWith(window.alert,
+ 'Please select some issues to add to hotlists.');
+ });
+
+ it('add to hotlist dialog opens', async () => {
+ element.selectedIssues = [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ];
+ element.projectName = 'test';
+
+ await element.updateComplete;
+
+ const dialog = element.shadowRoot.querySelector(
+ 'mr-update-issue-hotlists-dialog');
+
+ sinon.stub(dialog, 'open');
+
+ element.addToHotlist();
+
+ sinon.assert.calledOnce(dialog.open);
+ });
+
+ it('hotlist update triggers snackbar', async () => {
+ element.selectedIssues = [
+ {localId: 1, projectName: 'test'},
+ {localId: 2, projectName: 'test'},
+ ];
+ element.projectName = 'test';
+ sinon.stub(element, '_showHotlistSaveSnackbar');
+
+ await element.updateComplete;
+
+ const dialog = element.shadowRoot.querySelector(
+ 'mr-update-issue-hotlists-dialog');
+
+ element.addToHotlist();
+ dialog.dispatchEvent(new Event('saveSuccess'));
+
+ sinon.assert.calledOnce(element._showHotlistSaveSnackbar);
+ });
+ });
+});