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"
+            >
+              &lsaquo; 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 &rsaquo;
+            </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);
+    });
+  });
+});