Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-list/mr-chart-page/mr-chart-page.js b/static_src/elements/issue-list/mr-chart-page/mr-chart-page.js
new file mode 100644
index 0000000..06ff7a4
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart-page/mr-chart-page.js
@@ -0,0 +1,100 @@
+// 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 {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import '../mr-mode-selector/mr-mode-selector.js';
+import '../mr-chart/mr-chart.js';
+
+/**
+ * <mr-chart-page>
+ *
+ * Chart page view containing mr-mode-selector and mr-chart.
+ * @extends {LitElement}
+ */
+export class MrChartPage extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        box-sizing: border-box;
+        width: 100%;
+        padding: 0.5em 8px;
+      }
+      h2 {
+        font-size: 1.2em;
+        margin: 0 0 0.5em;
+      }
+      .list-controls {
+        display: flex;
+        align-items: center;
+        justify-content: flex-end;
+        width: 100%;
+        padding: 0.5em 0;
+        height: 32px;
+      }
+      .help {
+        padding: 1em;
+        background-color: rgb(227, 242, 253);
+        width: 44em;
+        font-size: 92%;
+        margin: 5px;
+        padding: 6px;
+        border-radius: 6px;
+      }
+      .monospace {
+        font-family: monospace;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <div class="list-controls">
+        <mr-mode-selector
+          .projectName=${this._projectName}
+          .queryParams=${this._queryParams}
+          .value=${'chart'}
+        ></mr-mode-selector>
+      </div>
+      <mr-chart
+        .projectName=${this._projectName}
+        .queryParams=${this._queryParams}
+      ></mr-chart>
+
+      <div>
+        <div class="help">
+          <h2>Supported query parameters:</h2>
+          <span class="monospace">
+            cc, component, hotlist, label, owner, reporter, status
+          </span>
+          <br /><br />
+          <a href="https://bugs.chromium.org/p/monorail/issues/entry?labels=Feature-Charts">
+            Please file feedback here.
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      _projectName: {type: String},
+      /** @private {Object} */
+      _queryParams: {type: Object},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._projectName = projectV0.viewedProjectName(state);
+    this._queryParams = sitewide.queryParams(state);
+  }
+};
+customElements.define('mr-chart-page', MrChartPage);
diff --git a/static_src/elements/issue-list/mr-chart/chops-chart.js b/static_src/elements/issue-list/mr-chart/chops-chart.js
new file mode 100644
index 0000000..a74255a
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/chops-chart.js
@@ -0,0 +1,94 @@
+// 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';
+
+/**
+ * `<chops-chart>`
+ *
+ * Web components wrapper around Chart.js.
+ *
+ */
+export class ChopsChart extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <canvas></canvas>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      type: {type: String},
+      data: {type: Object},
+      options: {type: Object},
+      _chart: {type: Object},
+      _chartConstructor: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.type = 'line';
+    this.data = {};
+    this.options = {};
+  }
+
+  /**
+   * Dynamically chartJs to reduce single EZT bundle size
+   * Move to static import once EZT is deprecated
+   */
+  async connectedCallback() {
+    super.connectedCallback();
+    /* eslint-disable max-len */
+    const {default: Chart} = await import(
+        /* webpackChunkName: "chartjs" */ 'chart.js/dist/Chart.bundle.min.js');
+    this._chartConstructor = Chart;
+  }
+
+  /**
+   * Refetch and rerender chart after property changes
+   * @override
+   * @param {Map} changedProperties
+   */
+  updated(changedProperties) {
+    // Make sure chartJS has loaded before attempting to create a chart
+    if (this._chartConstructor) {
+      if (!this._chart) {
+        const {type, data, options} = this;
+        const ctx = this.shadowRoot.querySelector('canvas').getContext('2d');
+        this._chart = new this._chartConstructor(ctx, {type, data, options});
+      } else if (
+        changedProperties.has('type') ||
+        changedProperties.has('data') ||
+        changedProperties.has('options')) {
+        this._updateChart();
+      }
+    }
+  }
+
+  /**
+   * Sets chartJs options and calls update
+   */
+  _updateChart() {
+    this._chart.type = this.type;
+    this._chart.data = this.data;
+    this._chart.options = this.options;
+
+    this._chart.update();
+  }
+}
+
+customElements.define('chops-chart', ChopsChart);
diff --git a/static_src/elements/issue-list/mr-chart/chops-chart.test.js b/static_src/elements/issue-list/mr-chart/chops-chart.test.js
new file mode 100644
index 0000000..bf05012
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/chops-chart.test.js
@@ -0,0 +1,24 @@
+// 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 {assert} from 'chai';
+import {ChopsChart} from './chops-chart.js';
+
+
+let element;
+
+describe('chops-chart', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-chart');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsChart);
+  });
+});
diff --git a/static_src/elements/issue-list/mr-chart/mr-chart.js b/static_src/elements/issue-list/mr-chart/mr-chart.js
new file mode 100644
index 0000000..a4c4189
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/mr-chart.js
@@ -0,0 +1,1041 @@
+// 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 qs from 'qs';
+import page from 'page';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {linearRegression} from 'shared/math.js';
+import './chops-chart.js';
+import {urlWithNewParams, createObjectComparisonFunc} from 'shared/helpers.js';
+
+const DEFAULT_NUM_DAYS = 90;
+const SECONDS_IN_DAY = 24 * 60 * 60;
+const MAX_QUERY_SIZE = 90;
+const MAX_DISPLAY_LINES = 10;
+const predRangeType = Object.freeze({
+  NEXT_MONTH: 0,
+  NEXT_QUARTER: 1,
+  NEXT_50: 2,
+  HIDE: 3,
+});
+const CHART_OPTIONS = {
+  animation: false,
+  responsive: true,
+  title: {
+    display: true,
+    text: 'Issues over time',
+  },
+  tooltips: {
+    mode: 'x',
+    intersect: false,
+  },
+  hover: {
+    mode: 'x',
+    intersect: false,
+  },
+  legend: {
+    display: true,
+    labels: {
+      boxWidth: 15,
+    },
+  },
+  scales: {
+    xAxes: [{
+      display: true,
+      type: 'time',
+      time: {parser: 'MM/DD/YYYY', tooltipFormat: 'll'},
+      scaleLabel: {
+        display: true,
+        labelString: 'Day',
+      },
+    }],
+    yAxes: [{
+      display: true,
+      ticks: {
+        beginAtZero: true,
+      },
+      scaleLabel: {
+        display: true,
+        labelString: 'Value',
+      },
+    }],
+  },
+};
+const COLOR_CHOICES = ['#00838F', '#B71C1C', '#2E7D32', '#00659C',
+  '#5D4037', '#558B2F', '#FF6F00', '#6A1B9A', '#880E4F', '#827717'];
+const BG_COLOR_CHOICES = ['#B2EBF2', '#EF9A9A', '#C8E6C9', '#B2DFDB',
+  '#D7CCC8', '#DCEDC8', '#FFECB3', '#E1BEE7', '#F8BBD0', '#E6EE9C'];
+
+/**
+ * Set of serialized state this element should update for.
+ * mr-app lowercases all query parameters before putting into store.
+ * @type {Set<string>}
+ */
+export const subscribedQuery = new Set([
+  'start-date',
+  'end-date',
+  'groupby',
+  'labelprefix',
+  'q',
+  'can',
+]);
+
+const queryParamsHaveChanged = createObjectComparisonFunc(subscribedQuery);
+
+/**
+ * Mapping between query param's groupby value and chart application data.
+ * @type {Object}
+ */
+const groupByMapping = {
+  'open': {display: 'Is open', value: 'open'},
+  'owner': {display: 'Owner', value: 'owner'},
+  'comonent': {display: 'Component', value: 'component'},
+  'status': {display: 'Status', value: 'status'},
+};
+
+/**
+ * `<mr-chart>`
+ *
+ * Component rendering the chart view
+ *
+ */
+export default class MrChart extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        max-width: 800px;
+        margin: 0 auto;
+      }
+      chops-chart {
+        max-width: 100%;
+      }
+      div#options {
+        max-width: 720px;
+        margin: 2em auto;
+        text-align: center;
+      }
+      div#options #unsupported-fields {
+        font-weight: bold;
+        color: orange;
+      }
+      div.align {
+        display: flex;
+      }
+      div.align #frequency, div.align #groupBy {
+        display: inline-block;
+        width: 40%;
+      }
+      div.align #frequency #two-toggle {
+        font-size: 95%;
+        text-align: center;
+        margin-bottom: 5px;
+      }
+      div.align #time, div.align #prediction {
+        display: inline-block;
+        width: 60%;
+      }
+      #dropdown {
+        height: 50%;
+      }
+      div.section {
+        display: inline-block;
+        text-align: center;
+      }
+      div.section.input {
+        padding: 4px 10px;
+      }
+      .menu {
+        min-width: 50%;
+        text-align: left;
+        font-size: 12px;
+        box-sizing: border-box;
+        text-decoration: none;
+        white-space: nowrap;
+        padding: 0.25em 8px;
+        transition: 0.2s background ease-in-out;
+        cursor: pointer;
+        color: var(--chops-link-color);
+      }
+      .menu:hover {
+        background: hsl(0, 0%, 90%);
+      }
+      .choice.transparent {
+        background: var(--chops-white);
+        border-color: var(--chops-choice-color);
+        border-radius: 4px;
+      }
+      .choice.shown {
+        background: var(--chops-active-choice-bg);
+      }
+      .choice {
+        padding: 4px 10px;
+        background: var(--chops-choice-bg);
+        color: var(--chops-choice-color);
+        text-decoration: none;
+        display: inline-block;
+      }
+      .choice.checked {
+        background: var(--chops-active-choice-bg);
+      }
+      p .warning-message {
+        display: none;
+        font-size: 1.25em;
+        padding: 0.25em;
+        background-color: var(--chops-orange-50);
+      }
+      progress {
+        background-color: var(--chops-white);
+        border: 1px solid var(--chops-gray-500);
+        margin: 0 0 1em;
+        width: 100%;
+        visibility: visible;
+      }
+      ::-webkit-progress-bar {
+        background-color: var(--chops-white);
+      }
+      progress::-webkit-progress-value {
+        transition: width 1s;
+        background-color: #00838F;
+      }
+    `;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('queryParams')) {
+      this._setPropsFromQueryParams();
+      this._fetchData();
+    }
+  }
+
+  /** @override */
+  render() {
+    const doneLoading = this.progress === 1;
+    return html`
+      <chops-chart
+        type="line"
+        .options=${CHART_OPTIONS}
+        .data=${this._chartData(this.indices, this.values)}
+      ></chops-chart>
+      <div id="options">
+        <p id="unsupported-fields">
+          ${this.unsupportedFields.length ? `
+            Unsupported fields: ${this.unsupportedFields.join(', ')}`: ''}
+        </p>
+        <progress
+          value=${this.progress}
+          ?hidden=${doneLoading}
+        >Loading chart...</progress>
+        <p class="warning-message" ?hidden=${!this.searchLimitReached}>
+          Note: Some results are not being counted.
+          Please narrow your query.
+        </p>
+        <p class="warning-message" ?hidden=${!this.maxQuerySizeReached}>
+          Your query is too long.
+          Showing ${MAX_QUERY_SIZE} weeks from end date.
+        </p>
+        <p class="warning-message" ?hidden=${!this.dateRangeNotLegal}>
+          Your requested date range does not exist.
+          Showing ${MAX_QUERY_SIZE} days from end date.
+        </p>
+        <p class="warning-message" ?hidden=${!this.cannedQueryOpen}>
+          Your query scope prevents closed issues from showing.
+        </p>
+        <div class="align">
+          <div id="frequency">
+            <label for="two-toggle">Choose date range:</label>
+            <div id="two-toggle">
+              <chops-button @click="${this._setDateRange.bind(this, 180)}"
+                class="${this.dateRange === 180 ? 'choice checked': 'choice'}">
+                180 Days
+              </chops-button>
+              <chops-button @click="${this._setDateRange.bind(this, 90)}"
+                class="${this.dateRange === 90 ? 'choice checked': 'choice'}">
+                90 Days
+              </chops-button>
+              <chops-button @click="${this._setDateRange.bind(this, 30)}"
+                class="${this.dateRange === 30 ? 'choice checked': 'choice'}">
+                30 Days
+              </chops-button>
+            </div>
+          </div>
+          <div id="time">
+            <label for="start-date">Choose start and end date:</label>
+            <br />
+            <input
+              type="date"
+              id="start-date"
+              name="start-date"
+              .value=${this.startDate && this.startDate.toISOString().substr(0, 10)}
+              ?disabled=${!doneLoading}
+              @change=${(e) => this.startDate = MrChart.dateStringToDate(e.target.value)}
+            />
+            <input
+              type="date"
+              id="end-date"
+              name="end-date"
+              .value=${this.endDate && this.endDate.toISOString().substr(0, 10)}
+              ?disabled=${!doneLoading}
+              @change=${(e) => this.endDate = MrChart.dateStringToDate(e.target.value)}
+            />
+            <chops-button @click="${this._onDateChanged}" class=choice>
+              Apply
+            </chops-button>
+          </div>
+        </div>
+        <div class="align">
+          <div id="prediction">
+          <label for="two-toggle">Choose prediction range:</label>
+          <div id="two-toggle">
+            ${this._renderPredictChoice('Future Month', predRangeType.NEXT_MONTH)}
+            ${this._renderPredictChoice('Future Quarter', predRangeType.NEXT_QUARTER)}
+            ${this._renderPredictChoice('Future 50%', predRangeType.NEXT_50)}
+            ${this._renderPredictChoice('Hide', predRangeType.HIDE)}
+          </div>
+        </div>
+          <div id="groupBy">
+            <label for="dropdown">Choose group by:</label>
+            <mr-dropdown
+              id="dropdown"
+              ?disabled=${!doneLoading}
+              .text=${this.groupBy.display}
+            >
+              ${this.dropdownHTML}
+            </mr-dropdown>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Renders a single prediction button.
+   * @param {string} choiceName The text displayed on the button.
+   * @param {number} rangeType An enum-like number specifying which range
+   *   to use.
+   * @return {TemplateResult}
+   */
+  _renderPredictChoice(choiceName, rangeType) {
+    const changePrediction = (_e) => {
+      this.predRange = rangeType;
+      this._fetchData();
+    };
+    return html`
+      <chops-button
+        @click=${changePrediction}
+        class="${this.predRange === rangeType ? 'checked': ''} choice">
+        ${choiceName}
+      </chops-button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      progress: {type: Number},
+      projectName: {type: String},
+      hotlistId: {type: Number},
+      indices: {type: Array},
+      values: {type: Array},
+      unsupportedFields: {type: Array},
+      dateRangeNotLegal: {type: Boolean},
+      dateRange: {type: Number},
+      frequency: {type: Number},
+      queryParams: {
+        type: Object,
+        hasChanged: queryParamsHaveChanged,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.progress = 0.05;
+    this.values = [];
+    this.indices = [];
+    this.unsupportedFields = [];
+    this.predRange = predRangeType.HIDE;
+    this._page = page;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    if (!this.projectName && !this.hotlistId) {
+      throw new Error('Attribute `projectName` or `hotlistId` required.');
+    }
+    this._setPropsFromQueryParams();
+    this._constructDropdownMenu();
+  }
+
+  /**
+   * Initialize queryParams and set properties from the queryParams.
+   * Since this page exists in both the SPA and ezt they initialize mr-chart
+   * differently, ie in ezt, this.queryParams will be undefined during
+   * connectedCallback. Until ezt is deleted, initialize props here.
+   */
+  _setPropsFromQueryParams() {
+    if (!this.queryParams) {
+      const params = qs.parse(document.location.search.substring(1));
+      // ezt pages used querystring as source of truth
+      // and 'labelPrefix'in query param, but SPA uses
+      // redux store's sitewide.queryParams as source of truth
+      // and lowercases all keys in sitewide.queryParams
+      if (params.hasOwnProperty('labelPrefix')) {
+        const labelPrefixValue = params['labelPrefix'];
+        params['labelprefix'] = labelPrefixValue;
+        delete params['labelPrefix'];
+      }
+      this.queryParams = params;
+    }
+    this.endDate = MrChart.getEndDate(this.queryParams['end-date']);
+    this.startDate = MrChart.getStartDate(
+        this.queryParams['start-date'],
+        this.endDate, DEFAULT_NUM_DAYS);
+    this.groupBy = MrChart.getGroupByFromQuery(this.queryParams);
+  }
+
+  /**
+   * Set dropdown options menu in HTML.
+   */
+  async _constructDropdownMenu() {
+    const response = await this._getLabelPrefixes();
+    let dropdownOptions = ['None', 'Component', 'Is open', 'Status', 'Owner'];
+    dropdownOptions = dropdownOptions.concat(response);
+    const dropdownHTML = dropdownOptions.map((str) => html`
+      <option class='menu' @click=${this._setGroupBy}>
+        ${str}</option>`);
+    this.dropdownHTML = html`${dropdownHTML}`;
+  }
+
+  /**
+   * Call global page.js to change frontend route based on new parameters
+   * @param {Object<string, string>} newParams
+   */
+  _changeUrlParams(newParams) {
+    const newUrl = urlWithNewParams(`/p/${this.projectName}/issues/list`,
+        this.queryParams, newParams);
+    this._page(newUrl);
+  }
+
+  /**
+   * Set start date and end date and trigger url action
+   */
+  _onDateChanged() {
+    const newParams = {
+      'start-date': this.startDate.toISOString().substr(0, 10),
+      'end-date': this.endDate.toISOString().substr(0, 10),
+    };
+    this._changeUrlParams(newParams);
+  }
+
+  /**
+   * Fetch data required to render chart
+   * @fires Event#allDataLoaded
+   */
+  async _fetchData() {
+    this.dateRange = Math.ceil(
+        (this.endDate - this.startDate) / (1000 * SECONDS_IN_DAY));
+
+    // Coordinate different params and flags, protect against illegal queries
+    // Case for start date greater than end date.
+    if (this.dateRange <= 0) {
+      this.frequency = 7;
+      this.dateRangeNotLegal = true;
+      this.maxQuerySizeReached = false;
+      this.dateRange = MAX_QUERY_SIZE;
+    } else {
+      this.dateRangeNotLegal = false;
+      if (this.dateRange >= MAX_QUERY_SIZE * 7) {
+        // Case for date range too long, requires >= MAX_QUERY_SIZE queries.
+        this.frequency = 7;
+        this.maxQuerySizeReached = true;
+        this.dateRange = MAX_QUERY_SIZE * 7;
+      } else {
+        this.maxQuerySizeReached = false;
+        if (this.dateRange < MAX_QUERY_SIZE) {
+          // Case for small date range, displayed in daily frequency.
+          this.frequency = 1;
+        } else {
+          // Case for medium date range, displayed in weekly frequency.
+          this.frequency = 7;
+        }
+      }
+    }
+    // Set canned query flag.
+    this.cannedQueryOpen = (this.queryParams.can === '2' &&
+      this.groupBy.value === 'open');
+
+    // Reset chart variables except indices.
+    this.progress = 0.05;
+
+    let numTimestampsLoaded = 0;
+    const timestampsChronological = MrChart.makeTimestamps(this.endDate,
+        this.frequency, this.dateRange);
+    const tsToIndexMap = new Map(timestampsChronological.map((ts, idx) => (
+      [ts, idx]
+    )));
+    this.indices = MrChart.makeIndices(timestampsChronological);
+    const timestamps = MrChart.sortInBisectOrder(timestampsChronological);
+    this.values = new Array(timestamps.length).fill(undefined);
+
+    const fetchPromises = timestamps.map(async (ts) => {
+      const data = await this._fetchDataAtTimestamp(ts);
+      const index = tsToIndexMap.get(ts);
+      this.values[index] = data.issues;
+      numTimestampsLoaded += 1;
+      const progressValue = numTimestampsLoaded / timestamps.length;
+      this.progress = progressValue;
+
+      return data;
+    });
+
+    const chartData = await Promise.all(fetchPromises);
+
+    // This is purely for testing purposes
+    this.dispatchEvent(new Event('allDataLoaded'));
+
+    // Check if the query includes any field values that are not supported.
+    const flatUnsupportedFields = chartData.reduce((acc, datum) => {
+      if (datum.unsupportedField) {
+        acc = acc.concat(datum.unsupportedField);
+      }
+      return acc;
+    }, []);
+    this.unsupportedFields = Array.from(new Set(flatUnsupportedFields));
+
+    this.searchLimitReached = chartData.some((d) => d.searchLimitReached);
+  }
+
+  /**
+   * fetch data at timestamp
+   * @param {number} timestamp
+   * @return {{date: number, issues: Array<Map.<string, number>>,
+   *   unsupportedField: string, searchLimitReached: string}}
+   */
+  async _fetchDataAtTimestamp(timestamp) {
+    const query = this.queryParams.q;
+    const cannedQuery = this.queryParams.can;
+    const message = {
+      timestamp: timestamp,
+      projectName: this.projectName,
+      query: query,
+      cannedQuery: cannedQuery,
+      hotlistId: this.hotlistId,
+      groupBy: undefined,
+    };
+    if (this.groupBy.value !== '') {
+      message['groupBy'] = this.groupBy.value;
+      if (this.groupBy.value === 'label') {
+        message['labelPrefix'] = this.groupBy.labelPrefix;
+      }
+    }
+    const response = await prpcClient.call('monorail.Issues',
+        'IssueSnapshot', message);
+
+    let issues;
+    if (response.snapshotCount) {
+      issues = response.snapshotCount.reduce((map, curr) => {
+        if (curr.dimension !== undefined) {
+          if (this.groupBy.value === '') {
+            map.set('Issue Count', curr.count);
+          } else {
+            map.set(curr.dimension, curr.count);
+          }
+        }
+        return map;
+      }, new Map());
+    } else {
+      issues = new Map();
+    }
+    return {
+      date: timestamp * 1000,
+      issues: issues,
+      unsupportedField: response.unsupportedField,
+      searchLimitReached: response.searchLimitReached,
+    };
+  }
+
+  /**
+   * Get prefixes from the set of labels.
+   */
+  async _getLabelPrefixes() {
+    // If no project (i.e. viewing a hotlist), return empty list.
+    if (!this.projectName) {
+      return [];
+    }
+
+    const projectRequestMessage = {
+      project_name: this.projectName};
+    const labelsResponse = await prpcClient.call(
+        'monorail.Projects', 'GetLabelOptions', projectRequestMessage);
+    const labelPrefixes = new Set();
+    for (let i = 0; i < labelsResponse.labelOptions.length; i++) {
+      const label = labelsResponse.labelOptions[i].label;
+      if (label.includes('-')) {
+        labelPrefixes.add(label.split('-')[0]);
+      }
+    }
+    return Array.from(labelPrefixes);
+  }
+
+  /**
+   * construct chart data
+   * @param {Array} indices
+   * @param {Array} values
+   * @return {Object} chart data and options
+   */
+  _chartData(indices, values) {
+    // Generate a map of each data line {dimension:string, value:array}
+    const mapValues = new Map();
+    for (let i = 0; i < values.length; i++) {
+      if (values[i] !== undefined) {
+        values[i].forEach((value, key, map) => mapValues.set(key, []));
+      }
+    }
+    // Count the number of 0 or undefined data points.
+    let count = 0;
+    for (let i = 0; i < values.length; i++) {
+      if (values[i] !== undefined) {
+        if (values[i].size === 0) {
+          count++;
+        }
+        // Set none-existing data points 0.
+        mapValues.forEach((value, key, map) => {
+          mapValues.set(key, value.concat([values[i].get(key) || 0]));
+        });
+      } else {
+        count++;
+      }
+    }
+    // Legend display set back to default.
+    CHART_OPTIONS.legend.display = true;
+    // Check if any positive valued data exist, if not, draw an array of zeros.
+    if (count === values.length) {
+      return {
+        type: 'line',
+        labels: indices,
+        datasets: [{
+          label: this.groupBy.labelPrefix,
+          data: Array(indices.length).fill(0),
+          backgroundColor: COLOR_CHOICES[0],
+          borderColor: COLOR_CHOICES[0],
+          showLine: true,
+          fill: false,
+        }],
+      };
+    }
+    // Convert map to a dataset of lines.
+    let arrayValues = [];
+    mapValues.forEach((value, key, map) => {
+      arrayValues.push({
+        label: key,
+        data: value,
+        backgroundColor: COLOR_CHOICES[arrayValues.length %
+          COLOR_CHOICES.length],
+        borderColor: COLOR_CHOICES[arrayValues.length % COLOR_CHOICES.length],
+        showLine: true,
+        fill: false,
+      });
+    });
+    arrayValues = MrChart.getSortedLines(arrayValues, MAX_DISPLAY_LINES);
+    if (this.predRange === predRangeType.HIDE) {
+      return {
+        type: 'line',
+        labels: indices,
+        datasets: arrayValues,
+      };
+    }
+
+    let predictedValues = [];
+    let originalData;
+    let predictedData;
+    let maxData;
+    let minData;
+    let currColor;
+    let currBGColor;
+    // Check if displayed values > MAX_DISPLAY_LINES, hide legend.
+    if (arrayValues.length * 4 > MAX_DISPLAY_LINES) {
+      CHART_OPTIONS.legend.display = false;
+    } else {
+      CHART_OPTIONS.legend.display = true;
+    }
+    for (let i = 0; i < arrayValues.length; i++) {
+      [originalData, predictedData, maxData, minData] =
+        MrChart.getAllData(indices, arrayValues[i]['data'], this.dateRange,
+            this.predRange, this.frequency, this.endDate);
+      currColor = COLOR_CHOICES[i % COLOR_CHOICES.length];
+      currBGColor = BG_COLOR_CHOICES[i % COLOR_CHOICES.length];
+      predictedValues = predictedValues.concat([{
+        label: arrayValues[i]['label'],
+        backgroundColor: currColor,
+        borderColor: currColor,
+        data: originalData,
+        showLine: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' prediction'),
+        backgroundColor: currColor,
+        borderColor: currColor,
+        borderDash: [5, 5],
+        data: predictedData,
+        pointRadius: 0,
+        showLine: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' lower error'),
+        backgroundColor: currBGColor,
+        borderColor: currBGColor,
+        borderDash: [5, 5],
+        data: minData,
+        pointRadius: 0,
+        showLine: true,
+        hidden: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' upper error'),
+        backgroundColor: currBGColor,
+        borderColor: currBGColor,
+        borderDash: [5, 5],
+        data: maxData,
+        pointRadius: 0,
+        showLine: true,
+        hidden: true,
+        fill: '-1',
+      }]);
+    }
+    return {
+      type: 'scatter',
+      datasets: predictedValues,
+    };
+  }
+
+  /**
+   * Change group by based on dropdown menu selection.
+   * @param {Event} e
+   */
+  _setGroupBy(e) {
+    switch (e.target.text) {
+      case 'None':
+        this.groupBy = {value: undefined};
+        break;
+      case 'Is open':
+        this.groupBy = {value: 'open'};
+        break;
+      case 'Owner':
+      case 'Component':
+      case 'Status':
+        this.groupBy = {value: e.target.text.toLowerCase()};
+        break;
+      default:
+        this.groupBy = {value: 'label', labelPrefix: e.target.text};
+    }
+    this.groupBy['display'] = e.target.text;
+    this.shadowRoot.querySelector('#dropdown').text = e.target.text;
+    this.shadowRoot.querySelector('#dropdown').close();
+
+    const newParams = {
+      'groupby': this.groupBy.value,
+      'labelprefix': this.groupBy.labelPrefix,
+    };
+
+    this._changeUrlParams(newParams);
+  }
+
+  /**
+   * Change date range and frequency based on button clicked.
+   * @param {number} dateRange Number of days in date range
+   */
+  _setDateRange(dateRange) {
+    if (this.dateRange !== dateRange) {
+      this.startDate = new Date(
+          this.endDate.getTime() - 1000 * SECONDS_IN_DAY * dateRange);
+      this._onDateChanged();
+      window.getTSMonClient().recordDateRangeChange(dateRange);
+    }
+  }
+
+  /**
+   * Move first, last, and median to the beginning of the array, recursively.
+   * @param  {Array} timestamps
+   * @return {Array}
+   */
+  static sortInBisectOrder(timestamps) {
+    const arr = [];
+    if (timestamps.length === 0) {
+      return arr;
+    } else if (timestamps.length <= 2) {
+      return timestamps;
+    } else {
+      const beginTs = timestamps.shift();
+      const endTs = timestamps.pop();
+      const medianTs = timestamps.splice(timestamps.length / 2, 1)[0];
+      return [beginTs, endTs, medianTs].concat(
+          MrChart.sortInBisectOrder(timestamps));
+    }
+  }
+
+  /**
+   * Populate array of timestamps we want to fetch.
+   * @param {Date} endDate
+   * @param {number} frequency
+   * @param {number} numDays
+   * @return {Array}
+   */
+  static makeTimestamps(endDate, frequency, numDays=DEFAULT_NUM_DAYS) {
+    if (!endDate) {
+      throw new Error('endDate required');
+    }
+    const endTimeSeconds = Math.round(endDate.getTime() / 1000);
+    const timestampsChronological = [];
+    for (let i = 0; i < numDays; i += frequency) {
+      timestampsChronological.unshift(endTimeSeconds - (SECONDS_IN_DAY * i));
+    }
+    return timestampsChronological;
+  }
+
+  /**
+   * Convert a string '2018-11-03' to a Date object.
+   * @param  {string} dateString
+   * @return {Date}
+   */
+  static dateStringToDate(dateString) {
+    if (!dateString) {
+      return null;
+    }
+    const splitDate = dateString.split('-');
+    const year = Number.parseInt(splitDate[0]);
+    // Month is 0-indexed, so subtract one.
+    const month = Number.parseInt(splitDate[1]) - 1;
+    const day = Number.parseInt(splitDate[2]);
+    return new Date(Date.UTC(year, month, day, 23, 59, 59));
+  }
+
+  /**
+   * Returns a Date parsed from string input, defaults to current date.
+   * @param {string} input
+   * @return {Date}
+   */
+  static getEndDate(input) {
+    if (input) {
+      const date = MrChart.dateStringToDate(input);
+      if (date) {
+        return date;
+      }
+    }
+    const today = new Date();
+    today.setHours(23);
+    today.setMinutes(59);
+    today.setSeconds(59);
+    return today;
+  }
+
+  /**
+   * Return a Date parsed from string input
+   * defaults to diff days befores endDate
+   * @param {string} input
+   * @param {Date} endDate
+   * @param {number} diff
+   * @return {Date}
+   */
+  static getStartDate(input, endDate, diff) {
+    if (input) {
+      const date = MrChart.dateStringToDate(input);
+      if (date) {
+        return date;
+      }
+    }
+    return new Date(endDate.getTime() - 1000 * SECONDS_IN_DAY * diff);
+  }
+
+  /**
+   * Make indices
+   * @param {Array} timestamps
+   * @return {Array}
+   */
+  static makeIndices(timestamps) {
+    const dateFormat = {year: 'numeric', month: 'numeric', day: 'numeric'};
+    return timestamps.map((ts) => (
+      (new Date(ts * 1000)).toLocaleDateString('en-US', dateFormat)
+    ));
+  }
+
+  /**
+   * Generate predicted future data based on previous data.
+   * @param {Array} values
+   * @param {number} dateRange
+   * @param {number} interval
+   * @param {number} frequency
+   * @param {Date} inputEndDate
+   * @return {Array}
+   */
+  static getPredictedData(
+      values, dateRange, interval, frequency, inputEndDate) {
+    // TODO(weihanl): changes to support frequencies other than 1 and 7.
+    let n;
+    let endDateRange;
+    if (frequency === 1) {
+      // Display in daily.
+      n = values.length;
+      endDateRange = interval;
+    } else {
+      // Display in weekly.
+      n = Math.floor((DEFAULT_NUM_DAYS + 1) / 7);
+      endDateRange = interval * 7 - 1;
+    }
+    const [slope, intercept] = linearRegression(values, n);
+    const endDate = new Date(inputEndDate.getTime() +
+        1000 * SECONDS_IN_DAY * (1 + endDateRange));
+    const timestampsChronological = MrChart.makeTimestamps(
+        endDate, frequency, endDateRange);
+    const predictedIndices = MrChart.makeIndices(timestampsChronological);
+
+    // Obtain future data and past data on the generated line.
+    const predictedValues = [];
+    const generatedValues = [];
+    for (let i = 0; i < interval; i++) {
+      predictedValues.push(Math.round(100*((i + n) * slope + intercept)) / 100);
+    }
+    for (let i = 0; i < n; i++) {
+      generatedValues.push(Math.round(100*(i * slope + intercept)) / 100);
+    }
+    return [predictedIndices, predictedValues, generatedValues];
+  }
+
+  /**
+   * Generate error range lines using +/- standard error
+   * on intercept to original line.
+   * @param {Array} generatedValues
+   * @param {Array} values
+   * @param {Array} predictedValues
+   * @return {Array}
+   */
+  static getErrorData(generatedValues, values, predictedValues) {
+    const diffs = [];
+    for (let i = 0; i < generatedValues.length; i++) {
+      diffs.push(values[values.length - generatedValues.length + i] -
+          generatedValues[i]);
+    }
+    const sqDiffs = diffs.map((v) => v * v);
+    const stdDev = sqDiffs.reduce((sum, v) => sum + v) / values.length;
+    const maxValues = predictedValues.map(
+        (x) => Math.round(100 * (x + stdDev)) / 100);
+    const minValues = predictedValues.map(
+        (x) => Math.round(100 * (x - stdDev)) / 100);
+    return [maxValues, minValues];
+  }
+
+  /**
+   * Format all data using scattered dot representation for a single chart line.
+   * @param {Array} indices
+   * @param {Array} values
+   * @param {humber} dateRange
+   * @param {number} predRange
+   * @param {number} frequency
+   * @param {Date} endDate
+   * @return {Array}
+   */
+  static getAllData(indices, values, dateRange, predRange, frequency, endDate) {
+    // Set the number of data points that needs to be generated based on
+    // future time range and frequency.
+    let interval;
+    switch (predRange) {
+      case predRangeType.NEXT_MONTH:
+        interval = frequency === 1 ? 30 : 4;
+        break;
+      case predRangeType.NEXT_QUARTER:
+        interval = frequency === 1 ? 90 : 13;
+        break;
+      case predRangeType.NEXT_50:
+        interval = Math.floor((dateRange + 1) / (frequency * 2));
+        break;
+    }
+
+    const [predictedIndices, predictedValues, generatedValues] =
+      MrChart.getPredictedData(values, dateRange, interval, frequency, endDate);
+    const [maxValues, minValues] =
+      MrChart.getErrorData(generatedValues, values, predictedValues);
+    const n = generatedValues.length;
+
+    // Format data into an array of {x:"MM/DD/YYYY", y:1.00} to draw chart.
+    const originalData = [];
+    const predictedData = [];
+    const maxData = [{
+      x: indices[values.length - 1],
+      y: generatedValues[n - 1],
+    }];
+    const minData = [{
+      x: indices[values.length - 1],
+      y: generatedValues[n - 1],
+    }];
+    for (let i = 0; i < values.length; i++) {
+      originalData.push({x: indices[i], y: values[i]});
+    }
+    for (let i = 0; i < n; i++) {
+      predictedData.push({x: indices[values.length - n + i],
+        y: Math.max(Math.round(100 * generatedValues[i]) / 100, 0)});
+    }
+    for (let i = 0; i < predictedValues.length; i++) {
+      predictedData.push({
+        x: predictedIndices[i],
+        y: Math.max(predictedValues[i], 0),
+      });
+      maxData.push({x: predictedIndices[i], y: Math.max(maxValues[i], 0)});
+      minData.push({x: predictedIndices[i], y: Math.max(minValues[i], 0)});
+    }
+    return [originalData, predictedData, maxData, minData];
+  }
+
+  /**
+   * Sort lines by data in reversed chronological order and
+   * return top n lines with most issues.
+   * @param {Array} arrayValues
+   * @param {number} index
+   * @return {Array}
+   */
+  static getSortedLines(arrayValues, index) {
+    if (index >= arrayValues.length) {
+      return arrayValues;
+    }
+    // Convert data by reversing and starting from last digit and sort
+    // according to the resulting value. e.g. [4,2,0] => 24, [0,4,3] => 340
+    const sortedValues = arrayValues.slice().sort((arrX, arrY) => {
+      const intX = parseInt(
+          arrX.data.map((i) => i.toString()).reverse().join(''));
+      const intY = parseInt(
+          arrY.data.map((i) => i.toString()).reverse().join(''));
+      return intY - intX;
+    });
+    return sortedValues.slice(0, index);
+  }
+
+  /**
+   * Parses queryParams for groupBy property
+   * @param {Object<string, string>} queryParams
+   * @return {Object<string, string>}
+   */
+  static getGroupByFromQuery(queryParams) {
+    const defaultValue = {display: 'None', value: ''};
+
+    const labelMapping = {
+      'label': {
+        display: queryParams.labelprefix,
+        value: 'label',
+        labelPrefix: queryParams.labelprefix,
+      },
+    };
+
+    return groupByMapping[queryParams.groupby] ||
+        labelMapping[queryParams.groupby] ||
+        defaultValue;
+  }
+}
+
+customElements.define('mr-chart', MrChart);
diff --git a/static_src/elements/issue-list/mr-chart/mr-chart.test.js b/static_src/elements/issue-list/mr-chart/mr-chart.test.js
new file mode 100644
index 0000000..8c079fd
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/mr-chart.test.js
@@ -0,0 +1,524 @@
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import MrChart, {
+  subscribedQuery,
+} from 'elements/issue-list/mr-chart/mr-chart.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let dataLoadedPromise;
+
+const beforeEachElement = () => {
+  if (element && document.body.contains(element)) {
+    // Avoid setting up multiple versions of the same element.
+    document.body.removeChild(element);
+    element = null;
+  }
+  const el = document.createElement('mr-chart');
+  el.setAttribute('projectName', 'rutabaga');
+  dataLoadedPromise = new Promise((resolve) => {
+    el.addEventListener('allDataLoaded', resolve);
+  });
+
+  document.body.appendChild(el);
+  return el;
+};
+
+describe('mr-chart', () => {
+  beforeEach(() => {
+    window.CS_env = {
+      token: 'rutabaga-token',
+      tokenExpiresSec: 0,
+      app_version: 'rutabaga-version',
+    };
+    sinon.stub(prpcClient, 'call').callsFake(async () => {
+      return {
+        snapshotCount: [{count: 8}],
+        unsupportedField: [],
+        searchLimitReached: false,
+      };
+    });
+
+    element = beforeEachElement();
+  });
+
+  afterEach(async () => {
+    // _fetchData is always called when the element is connected, so we have to
+    // wait until all data has been loaded.
+    // Otherwise prpcClient.call will be restored and we will make actual XHR
+    // calls.
+    await dataLoadedPromise;
+
+    document.body.removeChild(element);
+
+    prpcClient.call.restore();
+  });
+
+  describe('initializes', () => {
+    it('renders', () => {
+      assert.instanceOf(element, MrChart);
+    });
+
+    it('sets this.projectname', () => {
+      assert.equal(element.projectName, 'rutabaga');
+    });
+  });
+
+  describe('data loading', () => {
+    beforeEach(() => {
+      // Stub MrChart.makeTimestamps to return 6, not 30 data points.
+      const originalMakeTimestamps = MrChart.makeTimestamps;
+      sinon.stub(MrChart, 'makeTimestamps').callsFake((endDate) => {
+        return originalMakeTimestamps(endDate, 1, 6);
+      });
+      sinon.stub(MrChart, 'getEndDate').callsFake(() => {
+        return new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+      });
+
+      // Re-instantiate element after stubs.
+      element = beforeEachElement();
+    });
+
+    afterEach(() => {
+      MrChart.makeTimestamps.restore();
+      MrChart.getEndDate.restore();
+    });
+
+    it('makes a series of XHR calls', async () => {
+      await dataLoadedPromise;
+      for (let i = 0; i < 6; i++) {
+        assert.deepEqual(element.values[i], new Map());
+      }
+    });
+
+    it('sets indices and correctly re-orders values', async () => {
+      await dataLoadedPromise;
+
+      const timestampMap = new Map([
+        [1540857599, 0], [1540943999, 1], [1541030399, 2], [1541116799, 3],
+        [1541203199, 4], [1541289599, 5],
+      ]);
+      sinon.stub(MrChart.prototype, '_fetchDataAtTimestamp').callsFake(
+          async (ts) => ({issues: {'Issue Count': timestampMap.get(ts)}}));
+
+      element.endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+      await element._fetchData();
+
+      assert.deepEqual(element.indices, [
+        '10/29/2018', '10/30/2018', '10/31/2018',
+        '11/1/2018', '11/2/2018', '11/3/2018',
+      ]);
+      for (let i = 0; i < 6; i++) {
+        assert.deepEqual(element.values[i], {'Issue Count': i});
+      }
+      MrChart.prototype._fetchDataAtTimestamp.restore();
+    });
+
+    it('if issue count is null, defaults to 0', async () => {
+      prpcClient.call.restore();
+      sinon.stub(prpcClient, 'call').callsFake(async () => {
+        return {snapshotCount: [{}]};
+      });
+      MrChart.makeTimestamps.restore();
+      sinon.stub(MrChart, 'makeTimestamps').callsFake((endDate) => {
+        return [1234567, 2345678, 3456789];
+      });
+
+      await element._fetchData(new Date());
+      assert.deepEqual(element.values[0], new Map());
+    });
+
+    it('Retrieve data under groupby feature', async () => {
+      const data = new Map([['Type-1', 0], ['Type-2', 1]]);
+      sinon.stub(MrChart.prototype, '_fetchDataAtTimestamp').callsFake(
+          () => ({issues: data}));
+
+      element = beforeEachElement();
+
+      await element._fetchData(new Date());
+      for (let i = 0; i < 3; i++) {
+        assert.deepEqual(element.values[i], data);
+      }
+      MrChart.prototype._fetchDataAtTimestamp.restore();
+    });
+
+    it('_fetchDataAtTimestamp has no default query or can', async () => {
+      await element._fetchData();
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Issues',
+          'IssueSnapshot',
+          {
+            cannedQuery: undefined,
+            groupBy: undefined,
+            hotlistId: undefined,
+            query: undefined,
+            projectName: 'rutabaga',
+            timestamp: 1540857599,
+          });
+    });
+  });
+
+  describe('start date change detection', () => {
+    it('illegal query: start-date is greater than end-date', async () => {
+      await element.updateComplete;
+
+      element.startDate = new Date('2199-11-06');
+      element._fetchData();
+
+      assert.equal(element.dateRange, 90);
+      assert.equal(element.frequency, 7);
+      assert.equal(element.dateRangeNotLegal, true);
+    });
+
+    it('illegal query: end_date - start_date requires more than 90 queries',
+        async () => {
+          await element.updateComplete;
+
+          element.startDate = new Date('2016-10-03');
+          element._fetchData();
+
+          assert.equal(element.dateRange, 90 * 7);
+          assert.equal(element.frequency, 7);
+          assert.equal(element.maxQuerySizeReached, true);
+        });
+  });
+
+  describe('date change behavior', () => {
+    it('pushes to history API via pageJS', async () => {
+      sinon.stub(element, '_page');
+      sinon.spy(element, '_setDateRange');
+      sinon.spy(element, '_onDateChanged');
+      sinon.spy(element, '_changeUrlParams');
+
+      await element.updateComplete;
+
+      const thirtyButton = element.shadowRoot
+          .querySelector('#two-toggle').children[2];
+      thirtyButton.click();
+
+      sinon.assert.calledOnce(element._setDateRange);
+      sinon.assert.calledOnce(element._onDateChanged);
+      sinon.assert.calledOnce(element._changeUrlParams);
+      sinon.assert.calledOnce(element._page);
+
+      element._page.restore();
+      element._setDateRange.restore();
+      element._onDateChanged.restore();
+      element._changeUrlParams.restore();
+    });
+  });
+
+  describe('progress bar', () => {
+    it('visible based on loading progress', async () => {
+      // Check for visible progress bar and hidden input after initial render
+      await element.updateComplete;
+      const progressBar = element.shadowRoot.querySelector('progress');
+      const endDateInput = element.shadowRoot.querySelector('#end-date');
+      assert.isFalse(progressBar.hasAttribute('hidden'));
+      assert.isTrue(endDateInput.disabled);
+
+      // Check for hidden progress bar and enabled input after fetch and render
+      await dataLoadedPromise;
+      await element.updateComplete;
+      assert.isTrue(progressBar.hasAttribute('hidden'));
+      assert.isFalse(endDateInput.disabled);
+
+      // Trigger another data fetch and render, but prior to fetch complete
+      // Check progress bar is visible again
+      element.queryParams['start-date'] = '2012-01-01';
+      await element.requestUpdate('queryParams');
+      await element.updateComplete;
+      assert.isFalse(progressBar.hasAttribute('hidden'));
+
+      await dataLoadedPromise;
+      await element.updateComplete;
+      assert.isTrue(progressBar.hasAttribute('hidden'));
+    });
+  });
+
+  describe('static methods', () => {
+    describe('sortInBisectOrder', () => {
+      it('orders first, last, median recursively', () => {
+        assert.deepEqual(MrChart.sortInBisectOrder([]), []);
+        assert.deepEqual(MrChart.sortInBisectOrder([9]), [9]);
+        assert.deepEqual(MrChart.sortInBisectOrder([8, 9]), [8, 9]);
+        assert.deepEqual(MrChart.sortInBisectOrder([7, 8, 9]), [7, 9, 8]);
+        assert.deepEqual(
+            MrChart.sortInBisectOrder([1, 2, 3, 4, 5]), [1, 5, 3, 2, 4]);
+      });
+    });
+
+    describe('makeTimestamps', () => {
+      it('throws an error if endDate not passed', () => {
+        assert.throws(() => {
+          MrChart.makeTimestamps();
+        }, 'endDate required');
+      });
+      it('returns an array of in seconds', () => {
+        const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        const secondsInDay = 24 * 60 * 60;
+
+        assert.deepEqual(MrChart.makeTimestamps(endDate, 1, 6), [
+          1541289599 - (secondsInDay * 5), 1541289599 - (secondsInDay * 4),
+          1541289599 - (secondsInDay * 3), 1541289599 - (secondsInDay * 2),
+          1541289599 - (secondsInDay * 1), 1541289599 - (secondsInDay * 0),
+        ]);
+      });
+      it('tests frequency greater than 1', () => {
+        const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        const secondsInDay = 24 * 60 * 60;
+
+        assert.deepEqual(MrChart.makeTimestamps(endDate, 2, 6), [
+          1541289599 - (secondsInDay * 4),
+          1541289599 - (secondsInDay * 2),
+          1541289599 - (secondsInDay * 0),
+        ]);
+      });
+      it('tests frequency greater than 1', () => {
+        const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        const secondsInDay = 24 * 60 * 60;
+
+        assert.deepEqual(MrChart.makeTimestamps(endDate, 2, 7), [
+          1541289599 - (secondsInDay * 6),
+          1541289599 - (secondsInDay * 4),
+          1541289599 - (secondsInDay * 2),
+          1541289599 - (secondsInDay * 0),
+        ]);
+      });
+    });
+
+    describe('dateStringToDate', () => {
+      it('returns null if no input', () => {
+        assert.isNull(MrChart.dateStringToDate());
+      });
+
+      it('returns a new Date at EOD UTC', () => {
+        const actualDate = MrChart.dateStringToDate('2018-11-03');
+        const expectedDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        assert.equal(expectedDate.getTime(), 1541289599000, 'Sanity check.');
+
+        assert.equal(actualDate.getTime(), expectedDate.getTime());
+      });
+    });
+
+    describe('getEndDate', () => {
+      let clock;
+
+      beforeEach(() => {
+        clock = sinon.useFakeTimers(10000);
+      });
+
+      afterEach(() => {
+        clock.restore();
+      });
+
+      it('returns parsed input date', () => {
+        const input = '2018-11-03';
+
+        const expectedDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        // Time sanity check.
+        assert.equal(Math.round(expectedDate.getTime() / 1e3), 1541289599);
+
+        const actual = MrChart.getEndDate(input);
+        assert.equal(actual.getTime(), expectedDate.getTime());
+      });
+
+      it('returns EOD of current date by default', () => {
+        const expectedDate = new Date();
+        expectedDate.setHours(23);
+        expectedDate.setMinutes(59);
+        expectedDate.setSeconds(59);
+
+        assert.equal(MrChart.getEndDate().getTime(),
+            expectedDate.getTime());
+      });
+    });
+
+    describe('getStartDate', () => {
+      let clock;
+
+      beforeEach(() => {
+        clock = sinon.useFakeTimers(10000);
+      });
+
+      afterEach(() => {
+        clock.restore();
+      });
+
+      it('returns parsed input date', () => {
+        const input = '2018-07-03';
+
+        const expectedDate = new Date(Date.UTC(2018, 6, 3, 23, 59, 59));
+        // Time sanity check.
+        assert.equal(Math.round(expectedDate.getTime() / 1e3), 1530662399);
+
+        const actual = MrChart.getStartDate(input);
+        assert.equal(actual.getTime(), expectedDate.getTime());
+      });
+
+      it('returns EOD of current date by default', () => {
+        const today = new Date();
+        today.setHours(23);
+        today.setMinutes(59);
+        today.setSeconds(59);
+
+        const secondsInDay = 24 * 60 * 60;
+        const expectedDate = new Date(today.getTime() -
+            1000 * 90 * secondsInDay);
+        assert.equal(MrChart.getStartDate(undefined, today, 90).getTime(),
+            expectedDate.getTime());
+      });
+    });
+
+    describe('makeIndices', () => {
+      it('returns dates in mm/dd/yyy format', () => {
+        const timestamps = [
+          1540857599, 1540943999, 1541030399,
+          1541116799, 1541203199, 1541289599,
+        ];
+        assert.deepEqual(MrChart.makeIndices(timestamps), [
+          '10/29/2018', '10/30/2018', '10/31/2018',
+          '11/1/2018', '11/2/2018', '11/3/2018',
+        ]);
+      });
+    });
+
+    describe('getPredictedData', () => {
+      it('get predicted data shown in daily', () => {
+        const values = [0, 1, 2, 3, 4, 5, 6];
+        const result = MrChart.getPredictedData(
+            values, values.length, 3, 1, new Date('10-02-2017'));
+        assert.deepEqual(result[0], ['10/4/2017', '10/5/2017', '10/6/2017']);
+        assert.deepEqual(result[1], [7, 8, 9]);
+        assert.deepEqual(result[2], [0, 1, 2, 3, 4, 5, 6]);
+      });
+
+      it('get predicted data shown in weekly', () => {
+        const values = [0, 7, 14, 21, 28, 35, 42, 49, 56, 63, 70, 77, 84];
+        const result = MrChart.getPredictedData(
+            values, 91, 13, 7, new Date('10-02-2017'));
+        assert.deepEqual(result[1], values.map((x) => x+91));
+        assert.deepEqual(result[2], values);
+      });
+    });
+
+    describe('getErrorData', () => {
+      it('get error data with perfect regression', () => {
+        const values = [0, 1, 2, 3, 4, 5, 6];
+        const result = MrChart.getErrorData(values, values, [7, 8, 9]);
+        assert.deepEqual(result[0], [7, 8, 9]);
+        assert.deepEqual(result[1], [7, 8, 9]);
+      });
+
+      it('get error data with nonperfect regression', () => {
+        const values = [0, 1, 3, 4, 6, 6, 7];
+        const result = MrChart.getPredictedData(
+            values, values.length, 3, 1, new Date('10-02-2017'));
+        const error = MrChart.getErrorData(result[2], values, result[1]);
+        assert.isTrue(error[0][0] > result[1][0]);
+        assert.isTrue(error[1][0] < result[1][0]);
+      });
+    });
+
+    describe('getSortedLines', () => {
+      it('return all lines for less than n lines', () => {
+        const arrayValues = [
+          {label: 'line1', data: [0, 0, 1]},
+          {label: 'line2', data: [0, 1, 2]},
+          {label: 'line3', data: [0, 1, 0]},
+          {label: 'line4', data: [4, 0, 3]},
+        ];
+        const expectedValues = [
+          {label: 'line1', data: [0, 0, 1]},
+          {label: 'line2', data: [0, 1, 2]},
+          {label: 'line3', data: [0, 1, 0]},
+          {label: 'line4', data: [4, 0, 3]},
+        ];
+        const actualValues = MrChart.getSortedLines(arrayValues, 4);
+        for (let i = 0; i < 4; i++) {
+          assert.deepEqual(expectedValues[i], actualValues[i]);
+        }
+      });
+
+      it('return top n lines in sorted order for more than n lines',
+          () => {
+            const arrayValues = [
+              {label: 'line1', data: [0, 0, 1]},
+              {label: 'line2', data: [0, 1, 2]},
+              {label: 'line3', data: [0, 4, 0]},
+              {label: 'line4', data: [4, 0, 3]},
+              {label: 'line5', data: [0, 2, 3]},
+            ];
+            const expectedValues = [
+              {label: 'line5', data: [0, 2, 3]},
+              {label: 'line4', data: [4, 0, 3]},
+              {label: 'line2', data: [0, 1, 2]},
+            ];
+            const actualValues = MrChart.getSortedLines(arrayValues, 3);
+            for (let i = 0; i < 3; i++) {
+              assert.deepEqual(expectedValues[i], actualValues[i]);
+            }
+          });
+    });
+
+    describe('getGroupByFromQuery', () => {
+      it('get group by label object from URL', () => {
+        const input = {'groupby': 'label', 'labelprefix': 'Type'};
+
+        const expectedGroupBy = {
+          value: 'label',
+          labelPrefix: 'Type',
+          display: 'Type',
+        };
+        assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+      });
+
+      it('get group by is open object from URL', () => {
+        const input = {'groupby': 'open'};
+
+        const expectedGroupBy = {value: 'open', display: 'Is open'};
+        assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+      });
+
+      it('get group by none object from URL', () => {
+        const input = {'groupby': ''};
+
+        const expectedGroupBy = {value: '', display: 'None'};
+        assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+      });
+
+      it('only returns valid groupBy values', () => {
+        const invalidKeys = ['pri', 'reporter', 'stars'];
+
+        const queryParams = {groupBy: ''};
+
+        invalidKeys.forEach((key) => {
+          queryParams.groupBy = key;
+          const expected = {value: '', display: 'None'};
+          const result = MrChart.getGroupByFromQuery(queryParams);
+          assert.deepEqual(result, expected);
+        });
+      });
+    });
+  });
+
+  describe('subscribedQuery', () => {
+    it('includes start and end date', () => {
+      assert.isTrue(subscribedQuery.has('start-date'));
+      assert.isTrue(subscribedQuery.has('start-date'));
+    });
+
+    it('includes groupby and labelprefix', () => {
+      assert.isTrue(subscribedQuery.has('groupby'));
+      assert.isTrue(subscribedQuery.has('labelprefix'));
+    });
+
+    it('includes q and can', () => {
+      assert.isTrue(subscribedQuery.has('q'));
+      assert.isTrue(subscribedQuery.has('can'));
+    });
+  });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/extract-grid-data.js b/static_src/elements/issue-list/mr-grid-page/extract-grid-data.js
new file mode 100644
index 0000000..ebfa510
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/extract-grid-data.js
@@ -0,0 +1,203 @@
+// 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 {EMPTY_FIELD_VALUE, fieldTypes} from 'shared/issue-fields.js';
+import 'shared/typedef.js';
+
+
+const DEFAULT_HEADER_VALUE = 'All';
+
+// Sort headings functions
+// TODO(zhangtiff): Find some way to restructure this code to allow
+// sorting functions to sort with raw types instead of stringified values.
+
+/**
+ * Used as an optional 'compareFunction' for Array.sort().
+ * @param {string} strA
+ * @param {string} strB
+ * @return {number}
+ */
+function intStrComparator(strA, strB) {
+  return parseInt(strA) - parseInt(strB);
+}
+
+/**
+ * Used as an optional 'compareFunction' for Array.sort()
+ * @param {string} issueRefStrA
+ * @param {string} issueRefStrB
+ * @return {number}
+ */
+function issueRefComparator(issueRefStrA, issueRefStrB) {
+  const issueRefA = issueRefStrA.split(':');
+  const issueRefB = issueRefStrB.split(':');
+  if (issueRefA[0] != issueRefB[0]) {
+    return issueRefStrA.localeCompare(issueRefStrB);
+  } else {
+    return parseInt(issueRefA[1]) - parseInt(issueRefB[1]);
+  }
+}
+
+/**
+ * Returns a comparator for strings representing statuses using the ordering
+ * provided in statusDefs.
+ * Any status not found in statusDefs will be sorted to the end.
+ * @param {!Array<StatusDef>=} statusDefs
+ * @return {function(string, string): number}
+ */
+function getStatusDefComparator(statusDefs = []) {
+  return (statusStrA, statusStrB) => {
+    // Traverse statusDefs to determine which status is first.
+    for (const statusDef of statusDefs) {
+      if (statusDef.status == statusStrA) {
+        return -1;
+      } else if (statusDef.status == statusStrB) {
+        return 1;
+      }
+    }
+    return 0;
+  };
+}
+
+/**
+ * @param {!Set<string>} headingSet The headers found for the field.
+ * @param {string} fieldName The field on which we're sorting.
+ * @param {function(string): string=} extractTypeForFieldName
+ * @param {!Array<StatusDef>=} statusDefs
+ * @return {!Array<string>}
+ */
+function sortHeadings(headingSet, fieldName, extractTypeForFieldName,
+    statusDefs = []) {
+  let sorter;
+  if (extractTypeForFieldName) {
+    const type = extractTypeForFieldName(fieldName);
+    if (type === fieldTypes.ISSUE_TYPE) {
+      sorter = issueRefComparator;
+    } else if (type === fieldTypes.INT_TYPE) {
+      sorter = intStrComparator;
+    } else if (type === fieldTypes.STATUS_TYPE) {
+      sorter = getStatusDefComparator(statusDefs);
+    }
+  }
+
+  // Track whether EMPTY_FIELD_VALUE is present, and ensure that
+  // it is sorted to the first position of custom fields.
+  // TODO(jessan): although convenient, it is bad practice to mutate parameters.
+  const hasEmptyFieldValue = headingSet.delete(EMPTY_FIELD_VALUE);
+  const headingsList = [...headingSet];
+
+  headingsList.sort(sorter);
+
+  if (hasEmptyFieldValue) {
+    headingsList.unshift(EMPTY_FIELD_VALUE);
+  }
+  return headingsList;
+}
+
+/**
+ * @param {string} x Header value.
+ * @param {string} y Header value.
+ * @return {string} The key for the groupedIssue map.
+ * TODO(jessan): Make a GridData class, which avoids exposing this logic.
+ */
+export function makeGridCellKey(x, y) {
+  // Note: Some possible x and y values contain ':', '-', and other
+  // non-word characters making delimiter options limited.
+  return x + ' + ' + y;
+}
+
+/**
+ * @param {Issue} issue The issue for which we're preparing grid headings.
+ * @param {string} fieldName The field on which we're grouping.
+ * @param {function(Issue, string): Array<string>} extractFieldValuesFromIssue
+ * @return {!Array<string>} The headings the issue should be grouped into.
+ */
+function prepareHeadings(
+    issue, fieldName, extractFieldValuesFromIssue) {
+  const values = extractFieldValuesFromIssue(issue, fieldName);
+
+  return values.length == 0 ?
+     [EMPTY_FIELD_VALUE] :
+     values;
+}
+
+/**
+ * Groups issues by their values for the given fields.
+ * @param {Array<Issue>} required.issues The issues we are grouping
+ * @param {function(Issue, string): Array<string>}
+ *     required.extractFieldValuesFromIssue
+ * @param {string=} options.xFieldName name of the field for grouping columns
+ * @param {string=} options.yFieldName name of the field for grouping rows
+ * @param {function(string): string=} options.extractTypeForFieldName
+ * @param {Array=} options.statusDefs
+ * @param {Map=} options.labelPrefixValueMap
+ * @return {!Object} Grid data
+ *   - groupedIssues: A map of issues grouped by thir xField and yField values.
+ *   - xHeadings: sorted headings for columns.
+ *   - yHeadings: sorted headings for rows.
+ */
+export function extractGridData({issues, extractFieldValuesFromIssue}, {
+  xFieldName = '',
+  yFieldName = '',
+  extractTypeForFieldName = undefined,
+  statusDefs = [],
+  labelPrefixValueMap = new Map(),
+} = {}) {
+  const xHeadingsPredefinedSet = new Set();
+  const xHeadingsAdHocSet = new Set();
+  const yHeadingsSet = new Set();
+  const groupedIssues = new Map();
+  for (const issue of issues) {
+    const xHeadings = !xFieldName ?
+        [DEFAULT_HEADER_VALUE] :
+        prepareHeadings(
+            issue, xFieldName, extractFieldValuesFromIssue);
+    const yHeadings = !yFieldName ?
+        [DEFAULT_HEADER_VALUE] :
+        prepareHeadings(
+            issue, yFieldName, extractFieldValuesFromIssue);
+
+    // Find every combo of 'xValue yValue' that the issue belongs to
+    // and add it into that cell. Also record each header used.
+    for (const xHeading of xHeadings) {
+      if (labelPrefixValueMap.has(xFieldName) &&
+          labelPrefixValueMap.get(xFieldName).has(xHeading)) {
+        xHeadingsPredefinedSet.add(xHeading);
+      } else {
+        xHeadingsAdHocSet.add(xHeading);
+      }
+      for (const yHeading of yHeadings) {
+        yHeadingsSet.add(yHeading);
+        const cellKey = makeGridCellKey(xHeading, yHeading);
+        if (groupedIssues.has(cellKey)) {
+          groupedIssues.get(cellKey).push(issue);
+        } else {
+          groupedIssues.set(cellKey, [issue]);
+        }
+      }
+    }
+  }
+
+  // Predefined labels to be ordered in front of ad hoc labels
+  const xHeadings = [
+    ...sortHeadings(
+        xHeadingsPredefinedSet,
+        xFieldName,
+        extractTypeForFieldName,
+        statusDefs,
+    ),
+    ...sortHeadings(
+        xHeadingsAdHocSet,
+        xFieldName,
+        extractTypeForFieldName,
+        statusDefs,
+    ),
+  ];
+
+  return {
+    groupedIssues,
+    xHeadings,
+    yHeadings: sortHeadings(yHeadingsSet, yFieldName, extractTypeForFieldName,
+        statusDefs),
+  };
+}
diff --git a/static_src/elements/issue-list/mr-grid-page/extract-grid-data.test.js b/static_src/elements/issue-list/mr-grid-page/extract-grid-data.test.js
new file mode 100644
index 0000000..41d5c70
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/extract-grid-data.test.js
@@ -0,0 +1,289 @@
+// 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 {assert} from 'chai';
+import {extractGridData} from './extract-grid-data.js';
+import {extractFieldValuesFromIssue as fieldExtractor,
+  extractTypeForFieldName as typeExtractor} from 'reducers/projectV0.js';
+
+const extractFieldValuesFromIssue = fieldExtractor({});
+const extractTypeForFieldName = typeExtractor({});
+
+
+describe('extract headings from x and y attributes', () => {
+  it('no attributes set', () => {
+    const issues = [
+      {'localId': 1, 'projectName': 'test'},
+      {'localId': 2, 'projectName': 'test'},
+    ];
+
+    const data = extractGridData({
+      issues,
+      extractFieldValuesFromIssue,
+    });
+
+    const expectedIssues = new Map([
+      ['All + All', [
+        {'localId': 1, 'projectName': 'test'},
+        {'localId': 2, 'projectName': 'test'},
+      ]],
+    ]);
+
+    assert.deepEqual(data.xHeadings, ['All']);
+    assert.deepEqual(data.yHeadings, ['All']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from Attachments attribute', () => {
+    const issues = [
+      {'attachmentCount': 1}, {'attachmentCount': 0},
+      {'attachmentCount': 1},
+    ];
+
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: 'Attachments'});
+
+    const expectedIssues = new Map([
+      ['0 + All', [{'attachmentCount': 0}]],
+      ['1 + All', [{'attachmentCount': 1}, {'attachmentCount': 1}]],
+    ]);
+
+    assert.deepEqual(data.xHeadings, ['0', '1']);
+    assert.deepEqual(data.yHeadings, ['All']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from Blocked attribute', () => {
+    const issues = [
+      {'blockedOnIssueRefs': [{'localId': 21}]},
+      {'otherIssueProperty': 'issueProperty'},
+    ];
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: 'Blocked', yFieldName: ''});
+
+    const expectedIssues = new Map();
+    expectedIssues.set('Yes + All',
+        [{'blockedOnIssueRefs': [{'localId': 21}]}]);
+    expectedIssues.set('No + All', [{'otherIssueProperty': 'issueProperty'}]);
+
+    assert.deepEqual(data.xHeadings, ['No', 'Yes']);
+    assert.deepEqual(data.yHeadings, ['All']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from BlockedOn attribute', () => {
+    const issues = [
+      {'otherIssueProperty': 'issueProperty'},
+      {'blockedOnIssueRefs': [
+        {'localId': 3, 'projectName': 'test-projectB'}]},
+      {'blockedOnIssueRefs': [
+        {'localId': 3, 'projectName': 'test-projectA'}]},
+      {'blockedOnIssueRefs': [
+        {'localId': 3, 'projectName': 'test-projectA'}]},
+      {'blockedOnIssueRefs': [
+        {'localId': 1, 'projectName': 'test-projectA'}]},
+    ];
+
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: 'BlockedOn', yFieldName: ''});
+
+    const expectedIssues = new Map();
+    expectedIssues.set('test-projectB:3 + All', [{'blockedOnIssueRefs':
+      [{'localId': 3, 'projectName': 'test-projectB'}]}]);
+    expectedIssues.set('test-projectA:3 + All', [{'blockedOnIssueRefs':
+      [{'localId': 3, 'projectName': 'test-projectA'}]},
+    {'blockedOnIssueRefs':
+      [{'localId': 3, 'projectName': 'test-projectA'}]}]);
+    expectedIssues.set('test-projectA:1 + All', [{'blockedOnIssueRefs':
+      [{'localId': 1, 'projectName': 'test-projectA'}]}]);
+    expectedIssues.set('---- + All', [{'otherIssueProperty': 'issueProperty'}]);
+
+    assert.deepEqual(data.xHeadings, ['----', 'test-projectA:1',
+      'test-projectA:3', 'test-projectB:3']);
+    assert.deepEqual(data.yHeadings, ['All']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from Blocking attribute', () => {
+    const issues = [
+      {'otherIssueProperty': 'issueProperty'},
+      {'blockingIssueRefs': [
+        {'localId': 1, 'projectName': 'test-projectA'}]},
+      {'blockingIssueRefs': [
+        {'localId': 1, 'projectName': 'test-projectA'}]},
+      {'blockingIssueRefs': [
+        {'localId': 3, 'projectName': 'test-projectA'}]},
+      {'blockingIssueRefs': [
+        {'localId': 3, 'projectName': 'test-projectB'}]},
+    ];
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: 'Blocking', yFieldName: ''});
+
+    const expectedIssues = new Map();
+    expectedIssues.set('test-projectA:1 + All', [{'blockingIssueRefs':
+      [{'localId': 1, 'projectName': 'test-projectA'}]},
+    {'blockingIssueRefs':
+      [{'localId': 1, 'projectName': 'test-projectA'}]}]);
+    expectedIssues.set('test-projectA:3 + All', [{'blockingIssueRefs':
+      [{'localId': 3, 'projectName': 'test-projectA'}]}]);
+    expectedIssues.set('test-projectB:3 + All', [{'blockingIssueRefs':
+      [{'localId': 3, 'projectName': 'test-projectB'}]}]);
+    expectedIssues.set('---- + All', [{'otherIssueProperty': 'issueProperty'}]);
+
+    assert.deepEqual(data.xHeadings, ['----', 'test-projectA:1',
+      'test-projectA:3', 'test-projectB:3']);
+    assert.deepEqual(data.yHeadings, ['All']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from Component attribute', () => {
+    const issues = [
+      {'otherIssueProperty': 'issueProperty'},
+      {'componentRefs': [{'path': 'UI'}]},
+      {'componentRefs': [{'path': 'API'}]},
+      {'componentRefs': [{'path': 'UI'}]},
+    ];
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: 'Component', yFieldName: ''});
+
+    const expectedIssues = new Map();
+    expectedIssues.set('UI + All', [{'componentRefs': [{'path': 'UI'}]},
+      {'componentRefs': [{'path': 'UI'}]}]);
+    expectedIssues.set('API + All', [{'componentRefs': [{'path': 'API'}]}]);
+    expectedIssues.set('---- + All', [{'otherIssueProperty': 'issueProperty'}]);
+
+    assert.deepEqual(data.xHeadings, ['----', 'API', 'UI']);
+    assert.deepEqual(data.yHeadings, ['All']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from Reporter attribute', () => {
+    const issues = [
+      {'reporterRef': {'displayName': 'testA@google.com'}},
+      {'reporterRef': {'displayName': 'testB@google.com'}},
+    ];
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: '', yFieldName: 'Reporter'});
+
+    const expectedIssues = new Map();
+    expectedIssues.set('All + testA@google.com',
+        [{'reporterRef': {'displayName': 'testA@google.com'}}]);
+    expectedIssues.set('All + testB@google.com',
+        [{'reporterRef': {'displayName': 'testB@google.com'}}]);
+
+    assert.deepEqual(data.xHeadings, ['All']);
+    assert.deepEqual(data.yHeadings, ['testA@google.com', 'testB@google.com']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from Stars attribute', () => {
+    const issues = [
+      {'starCount': 1}, {'starCount': 6}, {'starCount': 1},
+    ];
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: '', yFieldName: 'Stars'});
+
+    const expectedIssues = new Map();
+    expectedIssues.set('All + 1', [{'starCount': 1}, {'starCount': 1}]);
+    expectedIssues.set('All + 6', [{'starCount': 6}]);
+
+    assert.deepEqual(data.xHeadings, ['All']);
+    assert.deepEqual(data.yHeadings, ['1', '6']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from Status in order of statusDefs provided', () => {
+    const issues = [
+      {'statusRef': {'status': 'New'}},
+      {'statusRef': {'status': '1Unknown'}},
+      {'statusRef': {'status': 'Accepted'}},
+      {'statusRef': {'status': 'New'}},
+      {'statusRef': {'status': 'UltraNew'}},
+    ];
+    const statusDefs = [
+      {status: 'UltraNew'}, {status: 'New'}, {status: 'Unused'},
+      {status: 'Accepted'},
+    ];
+
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {yFieldName: 'Status', extractTypeForFieldName, statusDefs});
+
+    const expectedIssues = new Map();
+    expectedIssues.set(
+        'All + Accepted', [{'statusRef': {'status': 'Accepted'}}]);
+    expectedIssues.set(
+        'All + New',
+        [{'statusRef': {'status': 'New'}}, {'statusRef': {'status': 'New'}}]);
+    expectedIssues.set(
+        'All + UltraNew', [{'statusRef': {'status': 'UltraNew'}}]);
+    expectedIssues.set(
+        'All + 1Unknown', [{'statusRef': {'status': '1Unknown'}}]);
+    assert.deepEqual(data.xHeadings, ['All']);
+    assert.deepEqual(
+        data.yHeadings, ['UltraNew', 'New', 'Accepted', '1Unknown']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('extract headings from the Type attribute', () => {
+    const issues = [
+      {'labelRefs': [{'label': 'Pri-2'}, {'label': 'Milestone-2000Q1'}]},
+      {'labelRefs': [{'label': 'Type-Defect'}]},
+      {'labelRefs': [{'label': 'Type-Defect'}]},
+      {'labelRefs': [{'label': 'Type-Enhancement'}]},
+    ];
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {yFieldName: 'Type'});
+
+    const expectedIssues = new Map();
+    expectedIssues.set('All + Defect', [
+      {'labelRefs': [{'label': 'Type-Defect'}]},
+      {'labelRefs': [{'label': 'Type-Defect'}]},
+    ]);
+    expectedIssues.set('All + Enhancement', [{'labelRefs':
+      [{'label': 'Type-Enhancement'}]}]);
+    expectedIssues.set('All + ----', [{'labelRefs':
+      [{'label': 'Pri-2'}, {'label': 'Milestone-2000Q1'}]}]);
+
+    assert.deepEqual(data.xHeadings, ['All']);
+    assert.deepEqual(data.yHeadings, ['----', 'Defect', 'Enhancement']);
+    assert.deepEqual(data.groupedIssues, expectedIssues);
+  });
+
+  it('puts predefined labels ahead of ad hoc labels', () => {
+    const issues = [
+      {'labelRefs': [{'label': 'Pri-2'}, {'label': 'Type-Defect'}]},
+      {'labelRefs': [{'label': 'Type-Defect'}]},
+      {'labelRefs': [{'label': 'Type-Enhancement'}]},
+      {'labelRefs': [{'label': 'Type-AAA'}]},
+    ];
+    const labelPrefixValueMap = new Map([
+      ['Pri', new Set(['2'])],
+      ['Type', new Set(['Defect', 'Enhancement'])],
+    ]);
+
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: 'Type', yFieldName: 'Pri', labelPrefixValueMap});
+
+    assert.deepEqual(data.xHeadings, ['Defect', 'Enhancement', 'AAA']);
+    assert.deepEqual(data.yHeadings, ['----', '2']);
+  });
+
+  it('has priority order of predefined, empty, then ad hoc labels', () => {
+    const issues = [
+      {'labelRefs': [{'label': 'Pri-2'}, {'label': 'Milestone-2000Q1'}]},
+      {'labelRefs': [{'label': 'Type-Defect'}]},
+      {'labelRefs': [{'label': 'Type-Enhancement'}]},
+      {'labelRefs': [{'label': 'Type-AAA'}]},
+    ];
+    const labelPrefixValueMap = new Map([
+      ['Pri', new Set(['2'])],
+      ['Type', new Set(['Defect', 'Enhancement'])],
+    ]);
+
+    const data = extractGridData({issues, extractFieldValuesFromIssue},
+        {xFieldName: 'Type', yFieldName: 'Pri', labelPrefixValueMap});
+
+    assert.deepEqual(data.xHeadings, ['Defect', 'Enhancement', '----', 'AAA']);
+  });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.js
new file mode 100644
index 0000000..2fe01ea
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.js
@@ -0,0 +1,255 @@
+// 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 {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import 'elements/chops/chops-choice-buttons/chops-choice-buttons.js';
+import '../mr-mode-selector/mr-mode-selector.js';
+import './mr-grid-dropdown.js';
+import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js';
+import {urlWithNewParams} from 'shared/helpers.js';
+import {fieldsForIssue} from 'shared/issue-fields.js';
+
+// A list of the valid default field names available in an issue grid.
+// High cardinality fields must be excluded, so the grid only includes a subset
+// of AVAILABLE FIELDS.
+export const DEFAULT_GRID_FIELDS = Object.freeze([
+  'Project',
+  'Attachments',
+  'Blocked',
+  'BlockedOn',
+  'Blocking',
+  'Component',
+  'MergedInto',
+  'Reporter',
+  'Stars',
+  'Status',
+  'Type',
+  'Owner',
+]);
+
+/**
+ * Component for displaying the controls shown on the Monorail issue grid page.
+ * @extends {LitElement}
+ */
+export class MrGridControls extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        box-sizing: border-box;
+        margin: 0.5em 0;
+        height: 32px;
+      }
+      mr-grid-dropdown {
+        padding-right: 20px;
+      }
+      .left-controls {
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        flex-grow: 0;
+      }
+      .right-controls {
+        display: flex;
+        align-items: center;
+        flex-grow: 0;
+      }
+      .issue-count {
+        display: inline-block;
+        padding-right: 20px;
+      }
+    `;
+  };
+
+  /** @override */
+  render() {
+    const hideCounts = this.totalIssues === 0;
+    return html`
+      <div class="left-controls">
+        <mr-grid-dropdown
+          class="row-selector"
+          .text=${'Rows'}
+          .items=${this.gridOptions}
+          .selection=${this.queryParams.y}
+          @change=${this._rowChanged}>
+        </mr-grid-dropdown>
+        <mr-grid-dropdown
+          class="col-selector"
+          .text=${'Cols'}
+          .items=${this.gridOptions}
+          .selection=${this.queryParams.x}
+          @change=${this._colChanged}>
+        </mr-grid-dropdown>
+        <chops-choice-buttons
+          class="cell-selector"
+          .options=${this.cellOptions}
+          .value=${this.cellType}>
+        </chops-choice-buttons>
+      </div>
+      <div class="right-controls">
+        ${hideCounts ? '' : html`
+          <div class="issue-count">
+            ${this.issueCount}
+            of
+            ${this.totalIssuesDisplay}
+          </div>
+        `}
+        <mr-mode-selector
+          .projectName=${this.projectName}
+          .queryParams=${this.queryParams}
+          value="grid"
+        ></mr-mode-selector>
+      </div>
+    `;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.gridOptions = this._computeGridOptions([]);
+    this.queryParams = {};
+
+    this.totalIssues = 0;
+
+    this._page = page;
+  };
+
+  /** @override */
+  static get properties() {
+    return {
+      gridOptions: {type: Array},
+      projectName: {tupe: String},
+      queryParams: {type: Object},
+      issueCount: {type: Number},
+      totalIssues: {type: Number},
+      _issues: {type: Array},
+    };
+  };
+
+  /** @override */
+  stateChanged(state) {
+    this.totalIssues = issueV0.totalIssues(state) || 0;
+    this._issues = issueV0.issueList(state) || [];
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('_issues')) {
+      this.gridOptions = this._computeGridOptions(this._issues);
+    }
+    super.update(changedProperties);
+  }
+
+  /**
+   * Gets what issue filtering options exist on the grid view.
+   * @param {Array<Issue>} issues The issues to find values on.
+   * @param {Array<string>=} defaultFields Available built in fields.
+   * @return {Array<string>} Array of names of fields you can filter by.
+   */
+  _computeGridOptions(issues, defaultFields = DEFAULT_GRID_FIELDS) {
+    const availableFields = new Set(defaultFields);
+    issues.forEach((issue) => {
+      fieldsForIssue(issue, true).forEach((field) => {
+        availableFields.add(field);
+      });
+    });
+    const options = [...availableFields].sort();
+    options.unshift('None');
+    return options;
+  }
+
+  /**
+   * @return {string} Display text of total issue number.
+   */
+  get totalIssuesDisplay() {
+    if (this.issueCount === 1) {
+      return `${this.issueCount} issue shown`;
+    } else if (this.issueCount === SERVER_LIST_ISSUES_LIMIT) {
+      // Server has hard limit up to 100,000 list results
+      return `100,000+ issues shown`;
+    }
+    return `${this.issueCount} issues shown`;
+  }
+
+  /**
+   * @return {string} What cell mode the user has selected.
+   * ie: Tiles, IDs, Counts
+   */
+  get cellType() {
+    const cells = this.queryParams.cells;
+    return cells || 'tiles';
+  }
+
+  /**
+   * @return {Array<Object>} Cell options available to the user, formatted for
+   *   <mr-mode-selector>
+   */
+  get cellOptions() {
+    return [
+      {text: 'Tile', value: 'tiles',
+        url: this._updatedGridViewUrl({}, ['cells'])},
+      {text: 'IDs', value: 'ids',
+        url: this._updatedGridViewUrl({cells: 'ids'})},
+      {text: 'Counts', value: 'counts',
+        url: this._updatedGridViewUrl({cells: 'counts'})},
+    ];
+  }
+
+  /**
+   * Changes the URL parameters on the page in response to a user changing
+   * their row setting.
+   * @param {Event} e 'change' event fired by <mr-grid-dropdown>
+   */
+  _rowChanged(e) {
+    const y = e.target.selection;
+    let deletedParams;
+    if (y === 'None') {
+      deletedParams = ['y'];
+    }
+    this._changeUrlParams({y}, deletedParams);
+  }
+
+  /**
+   * Changes the URL parameters on the page in response to a user changing
+   * their col setting.
+   * @param {Event} e 'change' event fired by <mr-grid-dropdown>
+   */
+  _colChanged(e) {
+    const x = e.target.selection;
+    let deletedParams;
+    if (x === 'None') {
+      deletedParams = ['x'];
+    }
+    this._changeUrlParams({x}, deletedParams);
+  }
+
+  /**
+   * Helper method to update URL params with a new grid view URL.
+   * @param {Array<Object>} newParams
+   * @param {Array<string>} deletedParams
+   */
+  _changeUrlParams(newParams, deletedParams) {
+    const newUrl = this._updatedGridViewUrl(newParams, deletedParams);
+    this._page(newUrl);
+  }
+
+  /**
+   * Helper to generate a new grid view URL given a set of params.
+   * @param {Array<Object>} newParams
+   * @param {Array<string>} deletedParams
+   * @return {string} The generated URL.
+   */
+  _updatedGridViewUrl(newParams, deletedParams) {
+    return urlWithNewParams(`/p/${this.projectName}/issues/list`,
+        this.queryParams, newParams, deletedParams);
+  }
+};
+
+customElements.define('mr-grid-controls', MrGridControls);
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.test.js
new file mode 100644
index 0000000..d6d7fbf
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-controls.test.js
@@ -0,0 +1,111 @@
+// 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 {MrGridControls} from './mr-grid-controls.js';
+import {SERVER_LIST_ISSUES_LIMIT} from 'shared/consts/index.js';
+
+let element;
+
+describe('mr-grid-controls', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-grid-controls');
+    document.body.appendChild(element);
+
+    element._page = sinon.stub();
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrGridControls);
+  });
+
+  it('selecting row updates y param', async () => {
+    const stub = sinon.stub(element, '_changeUrlParams');
+
+    await element.updateComplete;
+
+    const dropdownRows = element.shadowRoot.querySelector('.row-selector');
+
+    dropdownRows.selection = 'Status';
+    dropdownRows.dispatchEvent(new Event('change'));
+    sinon.assert.calledWith(stub, {y: 'Status'});
+  });
+
+  it('setting row to None deletes y param', async () => {
+    element.queryParams = {y: 'Remove', x: 'Keep'};
+    element.projectName = 'chromium';
+
+    await element.updateComplete;
+
+    const dropdownRows = element.shadowRoot.querySelector('.row-selector');
+
+    dropdownRows.selection = 'None';
+    dropdownRows.dispatchEvent(new Event('change'));
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?x=Keep');
+  });
+
+  it('selecting col updates x param', async () => {
+    const stub = sinon.stub(element, '_changeUrlParams');
+    await element.updateComplete;
+
+    const dropdownCols = element.shadowRoot.querySelector('.col-selector');
+
+    dropdownCols.selection = 'Blocking';
+    dropdownCols.dispatchEvent(new Event('change'));
+    sinon.assert.calledWith(stub, {x: 'Blocking'});
+  });
+
+  it('setting col to None deletes x param', async () => {
+    element.queryParams = {y: 'Keep', x: 'Remove'};
+    element.projectName = 'chromium';
+
+    await element.updateComplete;
+
+    const dropdownCols = element.shadowRoot.querySelector('.col-selector');
+
+    dropdownCols.selection = 'None';
+    dropdownCols.dispatchEvent(new Event('change'));
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?y=Keep');
+  });
+
+  it('cellOptions computes URLs with queryParams and projectName', async () => {
+    element.projectName = 'chromium';
+    element.queryParams = {q: 'hello-world'};
+
+    assert.deepEqual(element.cellOptions, [
+      {text: 'Tile', value: 'tiles',
+        url: '/p/chromium/issues/list?q=hello-world'},
+      {text: 'IDs', value: 'ids',
+        url: '/p/chromium/issues/list?q=hello-world&cells=ids'},
+      {text: 'Counts', value: 'counts',
+        url: '/p/chromium/issues/list?q=hello-world&cells=counts'},
+    ]);
+  });
+
+  describe('displays appropriate messaging for issue count', () => {
+    it('for one issue', () => {
+      element.issueCount = 1;
+      assert.equal(element.totalIssuesDisplay, '1 issue shown');
+    });
+
+    it('for less than 100,000 issues', () => {
+      element.issueCount = 50;
+      assert.equal(element.totalIssuesDisplay, '50 issues shown');
+    });
+
+    it('for 100,000 issues or more', () => {
+      element.issueCount = SERVER_LIST_ISSUES_LIMIT;
+      assert.equal(element.totalIssuesDisplay, '100,000+ issues shown');
+    });
+  });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.js
new file mode 100644
index 0000000..2fc05b6
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.js
@@ -0,0 +1,72 @@
+// 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 {equalsIgnoreCase} from 'shared/helpers.js';
+
+/**
+ * `<mr-grid-dropdown>`
+ *
+ * Component used by the user to select what grid options to use.
+ */
+export class MrGridDropdown extends LitElement {
+  /** @override */
+  render() {
+    return html`
+      ${this.text}:
+      <select
+        class="drop-down"
+        @change=${this._optionChanged}
+      >
+        ${(this.items).map((item) => html`
+          <option .selected=${equalsIgnoreCase(item, this.selection)}>
+            ${item}
+          </option>
+        `)}
+      </select>
+      `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      text: {type: String},
+      items: {type: Array},
+      selection: {type: String},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+    this.items = [];
+    this.selection = 'None';
+  };
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        font-size: var(--chops-large-font-size);
+      }
+      .drop-down {
+        font-size: var(--chops-large-font-size);
+      }
+    `;
+  };
+
+  /**
+   * Syncs values when the user updates their selection.
+   * @param {Event} e
+   * @fires CustomEvent#change
+   * @private
+   */
+  _optionChanged(e) {
+    this.selection = e.target.value;
+    this.dispatchEvent(new CustomEvent('change'));
+  };
+};
+
+customElements.define('mr-grid-dropdown', MrGridDropdown);
+
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.test.js
new file mode 100644
index 0000000..fcd480d
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-dropdown.test.js
@@ -0,0 +1,22 @@
+// 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 {assert} from 'chai';
+import {MrGridDropdown} from './mr-grid-dropdown.js';
+
+let element;
+
+describe('mr-grid-dropdown', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-grid-dropdown');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrGridDropdown);
+  });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-page.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-page.js
new file mode 100644
index 0000000..d96e566
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-page.js
@@ -0,0 +1,180 @@
+// 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.
+
+// TODO(juliacordero): Handle pRPC errors with a FE page
+
+import {LitElement, html, css} from 'lit-element';
+import {store, connectStore} from 'reducers/base.js';
+import {shouldWaitForDefaultQuery} from 'shared/helpers.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import './mr-grid-controls.js';
+import './mr-grid.js';
+
+/**
+ * <mr-grid-page>
+ *
+ * Grid page view containing mr-grid and mr-grid-controls.
+ * @extends {LitElement}
+ */
+export class MrGridPage extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    const displayedProgress = this.progress || 0.02;
+    const doneLoading = this.progress === 1;
+    const noMatches = this.totalIssues === 0 && doneLoading;
+    return html`
+      <div id="grid-area">
+        <mr-grid-controls
+          .projectName=${this.projectName}
+          .queryParams=${this._queryParams}
+          .issueCount=${this.issues.length}>
+        </mr-grid-controls>
+        ${noMatches ? html`
+          <div class="empty-search">
+            Your search did not generate any results.
+          </div>` : html`
+          <progress
+            title="${Math.round(displayedProgress * 100)}%"
+            value=${displayedProgress}
+            ?hidden=${doneLoading}
+          ></progress>`}
+        <br>
+        <mr-grid
+          .issues=${this.issues}
+          .xField=${this._queryParams.x}
+          .yField=${this._queryParams.y}
+          .cellMode=${this._queryParams.cells ? this._queryParams.cells : 'tiles'}
+          .queryParams=${this._queryParams}
+          .projectName=${this.projectName}
+        ></mr-grid>
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      projectName: {type: String},
+      _queryParams: {type: Object},
+      userDisplayName: {type: String},
+      issues: {type: Array},
+      fields: {type: Array},
+      progress: {type: Number},
+      totalIssues: {type: Number},
+      _presentationConfigLoaded: {type: Boolean},
+      /**
+       * The current search string the user is querying for.
+       * Project default if not specified.
+       */
+      _currentQuery: {type: String},
+      /**
+       * The current canned query the user is searching for.
+       * Project default if not specified.
+       */
+      _currentCan: {type: String},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+    this.issues = [];
+    this.progress = 0;
+    /** @type {string} */
+    this.projectName;
+    this._queryParams = {};
+    this._presentationConfigLoaded = false;
+  };
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('userDisplayName')) {
+      store.dispatch(issueV0.fetchStarredIssues());
+    }
+    // TODO(zosha): Abort sets of calls to ListIssues when
+    // queryParams.q is changed.
+    if (this._shouldFetchMatchingIssues(changedProperties)) {
+      this._fetchMatchingIssues();
+    }
+  }
+
+  /**
+   * Computes whether to fetch matching issues based on changedProperties
+   * @param {Map} changedProperties
+   * @return {boolean}
+   */
+  _shouldFetchMatchingIssues(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;
+    }
+    return false;
+  }
+
+  /** @private */
+  _fetchMatchingIssues() {
+    store.dispatch(issueV0.fetchIssueList(this.projectName, {
+      ...this._queryParams,
+      q: this._currentQuery,
+      can: this._currentCan,
+      maxItems: 500, // 500 items * 12 calls = max of 6,000 issues.
+      maxCalls: 12,
+    }));
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.projectName = projectV0.viewedProjectName(state);
+    this.issues = (issueV0.issueList(state) || []);
+    this.progress = (issueV0.issueListProgress(state) || 0);
+    this.totalIssues = (issueV0.totalIssues(state) || 0);
+    this._queryParams = sitewide.queryParams(state);
+    this._currentQuery = sitewide.currentQuery(state);
+    this._currentCan = sitewide.currentCan(state);
+    this._presentationConfigLoaded =
+      projectV0.viewedPresentationConfigLoaded(state);
+  }
+
+  /** @override */
+  static get styles() {
+    return css `
+      :host {
+        display: block;
+        box-sizing: border-box;
+        width: 100%;
+        padding: 0.5em 8px;
+      }
+      progress {
+        background-color: var(--chops-white);
+        border: 1px solid var(--chops-gray-500);
+        width: 40%;
+        margin-left: 1%;
+        margin-top: 0.5em;
+        visibility: visible;
+      }
+      ::-webkit-progress-bar {
+        background-color: var(--chops-white);
+      }
+      progress::-webkit-progress-value {
+        transition: width 1s;
+        background-color: var(--chops-blue-700);
+      }
+      .empty-search {
+        text-align: center;
+        padding-top: 2em;
+      }
+    `;
+  }
+};
+customElements.define('mr-grid-page', MrGridPage);
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-page.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-page.test.js
new file mode 100644
index 0000000..241091b
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-page.test.js
@@ -0,0 +1,126 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+import {MrGridPage} from './mr-grid-page.js';
+
+let element;
+
+describe('mr-grid-page', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-grid-page');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrGridPage);
+  });
+
+  it('progress bar updates properly', async () => {
+    await element.updateComplete;
+    element.progress = .2499;
+    await element.updateComplete;
+    const title =
+      element.shadowRoot.querySelector('progress').getAttribute('title');
+    assert.equal(title, '25%');
+  });
+
+  it('displays error when no issues match query', async () => {
+    await element.updateComplete;
+    element.progress = 1;
+    element.totalIssues = 0;
+    await element.updateComplete;
+    const error =
+      element.shadowRoot.querySelector('.empty-search').textContent;
+    assert.equal(error.trim(), 'Your search did not generate any results.');
+  });
+
+  it('calls to fetchIssueList made when _currentQuery changes', async () => {
+    await element.updateComplete;
+    const issueListCall = sinon.stub(element, '_fetchMatchingIssues');
+    element._queryParams = {x: 'Blocked'};
+    await element.updateComplete;
+    sinon.assert.notCalled(issueListCall);
+
+    element._presentationConfigLoaded = true;
+    element._currentQuery = 'cc:me';
+    await element.updateComplete;
+    sinon.assert.calledOnce(issueListCall);
+  });
+
+  it('calls to fetchIssueList made when _currentCan changes', async () => {
+    await element.updateComplete;
+    const issueListCall = sinon.stub(element, '_fetchMatchingIssues');
+    element._queryParams = {y: 'Blocked'};
+    await element.updateComplete;
+    sinon.assert.notCalled(issueListCall);
+
+    element._presentationConfigLoaded = true;
+    element._currentCan = 1;
+    await element.updateComplete;
+    sinon.assert.calledOnce(issueListCall);
+  });
+
+  describe('_shouldFetchMatchingIssues', () => {
+    it('default returns false', () => {
+      const result = element._shouldFetchMatchingIssues(new Map());
+      assert.isFalse(result);
+    });
+
+    it('returns true for projectName', () => {
+      element._queryParams = {q: ''};
+      const changedProps = new Map();
+      changedProps.set('projectName', 'anything');
+      const result = element._shouldFetchMatchingIssues(changedProps);
+      assert.isTrue(result);
+    });
+
+    it('returns true when _currentQuery changes', () => {
+      element._presentationConfigLoaded = true;
+
+      element._currentQuery = 'owner:me';
+      const changedProps = new Map();
+      changedProps.set('_currentQuery', '');
+
+      const result = element._shouldFetchMatchingIssues(changedProps);
+      assert.isTrue(result);
+    });
+
+    it('returns true when _currentCan changes', () => {
+      element._presentationConfigLoaded = true;
+
+      element._currentCan = 1;
+      const changedProps = new Map();
+      changedProps.set('_currentCan', 2);
+
+      const result = element._shouldFetchMatchingIssues(changedProps);
+      assert.isTrue(result);
+    });
+
+    it('returns false when presentation config not loaded', () => {
+      element._presentationConfigLoaded = false;
+
+      const changedProps = new Map();
+      changedProps.set('projectName', 'anything');
+      const result = element._shouldFetchMatchingIssues(changedProps);
+
+      assert.isFalse(result);
+    });
+
+    it('returns true when presentationConfig fetch completes', () => {
+      element._presentationConfigLoaded = true;
+
+      const changedProps = new Map();
+      changedProps.set('_presentationConfigLoaded', false);
+      const result = element._shouldFetchMatchingIssues(changedProps);
+
+      assert.isTrue(result);
+    });
+  });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.js
new file mode 100644
index 0000000..57ee474
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.js
@@ -0,0 +1,114 @@
+// 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 {issueRefToUrl, issueToIssueRef} from 'shared/convertersV0.js';
+import '../../framework/mr-star/mr-issue-star.js';
+
+/**
+ * Element for rendering a single tile in the grid view.
+ */
+export class MrGridTile extends LitElement {
+  /** @override */
+  render() {
+    return html`
+      <div class="tile-header">
+        <mr-issue-star
+          .issueRef=${this.issueRef}
+        ></mr-issue-star>
+        <a class="issue-id" href=${issueRefToUrl(this.issue, this.queryParams)}>
+          ${this.issue.localId}
+        </a>
+        <div class="status">
+          ${this.issue.statusRef ? this.issue.statusRef.status : ''}
+        </div>
+      </div>
+      <div class="summary">
+        ${this.issue.summary || ''}
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+      issueRef: {type: Object},
+      queryParams: {type: Object},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+    this.issue = {};
+    this.queryParams = '';
+  };
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('issue')) {
+      this.issueRef = issueToIssueRef(this.issue);
+    }
+    super.update(changedProperties);
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        border: 2px solid var(--chops-gray-200);
+        border-radius: 6px;
+        padding: 1px;
+        margin: 3px;
+        background: var(--chops-white);
+        width: 10em;
+        height: 5em;
+        float: left;
+        table-layout: fixed;
+        overflow: hidden;
+      }
+      :host(:hover) {
+        border-color: var(--chops-blue-100);
+      }
+      .tile-header {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        margin-bottom: 0.1em;
+      }
+      mr-issue-star {
+        --mr-star-size: 16px;
+      }
+      a.issue-id {
+        font-weight: 500;
+        text-decoration: none;
+        display: inline-block;
+        padding-left: .25em;
+        color: var(--chops-blue-700);
+      }
+      .status {
+        display: inline-block;
+        font-size: 90%;
+        max-width: 30%;
+        white-space: nowrap;
+        padding-left: 4px;
+      }
+      .summary {
+        height: 3.7em;
+        font-size: 90%;
+        line-height: 94%;
+        padding: .05px .25em .05px .25em;
+        position: relative;
+      }
+      a:hover {
+        text-decoration: underline;
+      }
+    `;
+  };
+};
+
+customElements.define('mr-grid-tile', MrGridTile);
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.test.js
new file mode 100644
index 0000000..c9577c6
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid-tile.test.js
@@ -0,0 +1,56 @@
+// 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 {assert} from 'chai';
+import {MrGridTile} from './mr-grid-tile.js';
+
+let element;
+const summary = 'Testing summary of an issue.';
+const testIssue = {
+  projectName: 'Monorail',
+  localId: '2345',
+  summary: summary,
+};
+
+describe('mr-grid-tile', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-grid-tile');
+    element.issue = testIssue;
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrGridTile);
+  });
+
+  it('properly links', async () => {
+    await element.updateComplete;
+    const tileLink = element.shadowRoot.querySelector('a').getAttribute('href');
+    assert.equal(tileLink, `/p/Monorail/issues/detail?id=2345`);
+  });
+
+  it('summary displays', async () => {
+    await element.updateComplete;
+    const tileSummary =
+      element.shadowRoot.querySelector('.summary').textContent;
+    assert.equal(tileSummary.trim(), summary);
+  });
+
+  it('status displays', async () => {
+    await element.updateComplete;
+    const tileStatus =
+      element.shadowRoot.querySelector('.status').textContent;
+    assert.equal(tileStatus.trim(), '');
+  });
+
+  it('id displays', async () => {
+    await element.updateComplete;
+    const tileId =
+      element.shadowRoot.querySelector('.issue-id').textContent;
+    assert.equal(tileId.trim(), '2345');
+  });
+});
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid.js b/static_src/elements/issue-list/mr-grid-page/mr-grid.js
new file mode 100644
index 0000000..f459489
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid.js
@@ -0,0 +1,291 @@
+// 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 './mr-grid-tile.js';
+
+import {css, html, LitElement} from 'lit-element';
+import qs from 'qs';
+import {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+import {setHasAny} from 'shared/helpers.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import {extractGridData, makeGridCellKey} from './extract-grid-data.js';
+
+const PROPERTIES_TRIGGERING_GROUPING = Object.freeze([
+  'xField',
+  'yField',
+  'issues',
+  '_extractFieldValuesFromIssue',
+  '_extractTypeForFieldName',
+  '_statusDefs',
+]);
+
+/**
+ * <mr-grid>
+ *
+ * A grid of issues grouped optionally horizontally and vertically.
+ *
+ * Throughout the file 'x' corresponds to column headers and 'y' corresponds to
+ * row headers.
+ *
+ * @extends {LitElement}
+ */
+export class MrGrid extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <table>
+        <tr>
+          <th>&nbsp</th>
+          ${this._xHeadings.map((heading) => html`
+              <th>${heading}</th>`)}
+        </tr>
+        ${this._yHeadings.map((yHeading) => html`
+          <tr>
+            <th>${yHeading}</th>
+            ${this._xHeadings.map((xHeading) => html`
+                ${this._renderCell(xHeading, yHeading)}`)}
+          </tr>
+        `)}
+      </table>
+    `;
+  }
+  /**
+   *
+   * @param {string} xHeading
+   * @param {string} yHeading
+   * @return {TemplateResult}
+   */
+  _renderCell(xHeading, yHeading) {
+    const cell = this._groupedIssues.get(makeGridCellKey(xHeading, yHeading));
+    if (!cell) {
+      return html`<td></td>`;
+    }
+
+    const cellMode = this.cellMode.toLowerCase();
+    let content;
+    if (cellMode === 'ids') {
+      content = html`
+        ${cell.map((issue) => html`
+          <mr-issue-link
+            .projectName=${this.projectName}
+            .issue=${issue}
+            .text=${issue.localId}
+            .queryParams=${this.queryParams}
+          ></mr-issue-link>
+        `)}
+      `;
+    } else if (cellMode === 'counts') {
+      const itemCount = cell.length;
+      if (itemCount === 1) {
+        const issue = cell[0];
+        content = html`
+          <a href=${issueRefToUrl(issue, this.queryParams)} class="counts">
+            1 item
+          </a>
+        `;
+      } else {
+        content = html`
+          <a href=${this._formatListUrl(xHeading, yHeading)} class="counts">
+            ${itemCount} items
+          </a>
+        `;
+      }
+    } else {
+      // Default to tiles.
+      content = html`
+        ${cell.map((issue) => html`
+          <mr-grid-tile
+            .issue=${issue}
+            .queryParams=${this.queryParams}
+          ></mr-grid-tile>
+          `)}
+        `;
+    }
+    return html`<td>${content}</td>`;
+  }
+
+  /**
+   * Creates a URL to the list view for the group of issues corresponding to
+   * the given headings.
+   *
+   * @param {string} xHeading
+   * @param {string} yHeading
+   * @return {string}
+   */
+  _formatListUrl(xHeading, yHeading) {
+    let url = 'list?';
+    const params = Object.assign({}, this.queryParams);
+    params.mode = '';
+
+    params.q = this._addHeadingToQuery(params.q, xHeading, this.xField);
+    params.q = this._addHeadingToQuery(params.q, yHeading, this.yField);
+
+    url += qs.stringify(params);
+
+    return url;
+  }
+
+  /**
+   * @param {string} query
+   * @param {string} heading The value of field for the current group.
+   * @param {string} field Field on which we're grouping the issue.
+   * @return {string} The query with an additional clause if needed.
+   */
+  _addHeadingToQuery(query, heading, field) {
+    if (field && field !== 'None') {
+      if (heading === EMPTY_FIELD_VALUE) {
+        query += ' -has:' + field;
+      // The following two cases are to handle grouping issues by Blocked
+      } else if (heading === 'No') {
+        query += ' -is:' + field;
+      } else if (heading === 'Yes') {
+        query += ' is:' + field;
+      } else {
+        query += ' ' + field + '=' + heading;
+      }
+    }
+    return query;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      xField: {type: String},
+      yField: {type: String},
+      issues: {type: Array},
+      cellMode: {type: String},
+      queryParams: {type: Object},
+      projectName: {type: String},
+      _extractFieldValuesFromIssue: {type: Object},
+      _extractTypeForFieldName: {type: Object},
+      _statusDefs: {type: Array},
+      _labelPrefixValueMap: {type: Map},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        table {
+          table-layout: auto;
+          border-collapse: collapse;
+          width: 98%;
+          margin: 0.5em 1%;
+          text-align: left;
+        }
+        th {
+          border: 1px solid white;
+          padding: 5px;
+          background-color: var(--chops-table-header-bg);
+          white-space: nowrap;
+        }
+        td {
+          border: var(--chops-table-divider);
+          padding-left: 0.3em;
+          background-color: var(--chops-white);
+          vertical-align: top;
+        }
+        mr-issue-link {
+          display: inline-block;
+          margin-right: 8px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {string} */
+    this.cellMode = 'tiles';
+    /** @type {Array<Issue>} */
+    this.issues = [];
+    /** @type {string} */
+    this.projectName;
+    this.queryParams = {};
+
+    /** @type {string} The issue field on which to group columns. */
+    this.xField;
+
+    /** @type {string} The issue field on which to group rows. */
+    this.yField;
+
+    /**
+     * Grid cell key mapped to issues associated with that cell.
+     * @type {Map.<string, Array<Issue>>}
+     */
+    this._groupedIssues = new Map();
+
+    /** @type {Array<string>} */
+    this._xHeadings = [];
+
+    /** @type {Array<string>} */
+    this._yHeadings = [];
+
+    /**
+     * Method for extracting values from an issue for a given
+     * project config.
+     * @type {function(Issue, string): Array<string>}
+     */
+    this._extractFieldValuesFromIssue = undefined;
+
+    /**
+     * Method for finding the types of fields based on their names.
+     * @type {function(string): string}
+     */
+    this._extractTypeForFieldName = undefined;
+
+    /**
+     * Note: no default assigned here: it can be undefined in stateChanged.
+     * @type {Array<StatusDef>}
+     */
+    this._statusDefs;
+
+    /**
+     * Mapping predefined label prefix to set of values
+     * @type {Map}
+     */
+    this._labelPrefixValueMap = new Map();
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._extractFieldValuesFromIssue =
+      projectV0.extractFieldValuesFromIssue(state);
+    this._extractTypeForFieldName = projectV0.extractTypeForFieldName(state);
+    this._statusDefs = projectV0.viewedConfig(state).statusDefs;
+    this._labelPrefixValueMap = projectV0.labelPrefixValueMap(state);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (setHasAny(changedProperties, PROPERTIES_TRIGGERING_GROUPING)) {
+      if (this._extractFieldValuesFromIssue) {
+        const gridData = extractGridData({
+          issues: this.issues,
+          extractFieldValuesFromIssue: this._extractFieldValuesFromIssue,
+        }, {
+          xFieldName: this.xField,
+          yFieldName: this.yField,
+          extractTypeForFieldName: this._extractTypeForFieldName,
+          statusDefs: this._statusDefs,
+          labelPrefixValueMap: this._labelPrefixValueMap,
+        });
+
+        this._xHeadings = gridData.xHeadings;
+        this._yHeadings = gridData.yHeadings;
+        this._groupedIssues = gridData.groupedIssues;
+      }
+    }
+
+    super.update(changedProperties);
+  }
+};
+customElements.define('mr-grid', MrGrid);
diff --git a/static_src/elements/issue-list/mr-grid-page/mr-grid.test.js b/static_src/elements/issue-list/mr-grid-page/mr-grid.test.js
new file mode 100644
index 0000000..eb430de
--- /dev/null
+++ b/static_src/elements/issue-list/mr-grid-page/mr-grid.test.js
@@ -0,0 +1,214 @@
+// 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 {assert} from 'chai';
+import {MrGrid} from './mr-grid.js';
+import {MrIssueLink} from
+  'elements/framework/links/mr-issue-link/mr-issue-link.js';
+
+let element;
+
+describe('mr-grid', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-grid');
+    element.queryParams = {x: '', y: ''};
+    element.issues = [{localId: 1, projectName: 'monorail'}];
+    element.projectName = 'monorail';
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrGrid);
+  });
+
+  it('renders issues in ID mode', async () => {
+    element.cellMode = 'IDs';
+
+    await element.updateComplete;
+
+    assert.instanceOf(element.shadowRoot.querySelector(
+        'mr-issue-link'), MrIssueLink);
+  });
+
+  it('renders one issue in counts mode', async () => {
+    element.cellMode = 'Counts';
+
+    await element.updateComplete;
+
+    const href = element.shadowRoot.querySelector('.counts').href;
+    assert.include(href, '/p/monorail/issues/detail?id=1&x=&y=');
+  });
+
+  it('renders as tiles when invalid cell mode set', async () => {
+    element.cellMode = 'InvalidCells';
+
+    await element.updateComplete;
+
+    const tile = element.shadowRoot.querySelector('mr-grid-tile');
+    assert.isDefined(tile);
+    assert.deepEqual(tile.issue, {localId: 1, projectName: 'monorail'});
+  });
+
+  it('groups issues before rendering', async () => {
+    const testIssue = {
+      localId: 1,
+      projectName: 'monorail',
+      starCount: 2,
+      blockedOnIssueRefs: [{localId: 22, projectName: 'chromium'}],
+    };
+
+    element.cellMode = 'Tiles';
+
+    element.issues = [testIssue];
+    element.xField = 'Stars';
+    element.yField = 'Blocked';
+
+    await element.updateComplete;
+
+    assert.deepEqual(element._groupedIssues, new Map([
+      ['2 + Yes', [testIssue]],
+    ]));
+
+    const rows = element.shadowRoot.querySelectorAll('tr');
+
+    const colHeader = rows[0].querySelectorAll('th')[1];
+    assert.equal(colHeader.textContent.trim(), '2');
+
+    const rowHeader = rows[1].querySelector('th');
+    assert.equal(rowHeader.textContent.trim(), 'Yes');
+
+    const issueCell = rows[1].querySelector('td');
+    const tile = issueCell.querySelector('mr-grid-tile');
+
+    assert.isDefined(tile);
+    assert.deepEqual(tile.issue, testIssue);
+  });
+
+  it('renders status groups in statusDef order', async () => {
+    element._statusDefs = [
+      {status: 'UltraNew'},
+      {status: 'New'},
+      {status: 'Accepted'},
+    ];
+
+    element.issues = [
+      {localId: 2, projectName: 'monorail', statusRef: {status: 'New'}},
+      {localId: 4, projectName: 'monorail', statusRef: {status: 'Accepted'}},
+      {localId: 3, projectName: 'monorail', statusRef: {status: 'New'}},
+      {localId: 1, projectName: 'monorail', statusRef: {status: 'UltraNew'}},
+    ];
+
+    element.cellMode = 'IDs';
+    element.xField = 'Status';
+    element.yField = '';
+
+    await element.updateComplete;
+
+    const rows = element.shadowRoot.querySelectorAll('tr');
+
+    const colHeaders = rows[0].querySelectorAll('th');
+    assert.equal(colHeaders[1].textContent.trim(), 'UltraNew');
+    assert.equal(colHeaders[2].textContent.trim(), 'New');
+    assert.equal(colHeaders[3].textContent.trim(), 'Accepted');
+
+    const issueCells = rows[1].querySelectorAll('td');
+
+    const ultraNewIssues = issueCells[0].querySelectorAll('mr-issue-link');
+    assert.equal(ultraNewIssues.length, 1);
+
+    const newIssues = issueCells[1].querySelectorAll('mr-issue-link');
+    assert.equal(newIssues.length, 2);
+
+    const acceptedIssues = issueCells[2].querySelectorAll('mr-issue-link');
+    assert.equal(acceptedIssues.length, 1);
+  });
+
+  it('computes href for multiple items in counts mode', async () => {
+    element.cellMode = 'Counts';
+
+    element.issues = [
+      {localId: 1, projectName: 'monorail'},
+      {localId: 2, projectName: 'monorail'},
+    ];
+
+    await element.updateComplete;
+
+    const href = element.shadowRoot.querySelector('.counts').href;
+    assert.include(href, '/list?x=&y=&mode=');
+  });
+
+  it('computes list link when grouped by row in counts mode', async () => {
+    await element.updateComplete;
+
+    element.cellMode = 'Counts';
+    element.queryParams = {x: 'Type', y: '', q: 'Type:Defect'};
+    element._xHeadings = ['All', 'Defect'];
+    element._yHeadings = ['All'];
+    element._groupedIssues = new Map([
+      ['All + All', [{'localId': 1, 'projectName': 'monorail'}]],
+      ['Defect + All', [
+        {localId: 2, projectName: 'monorail',
+          labelRefs: [{label: 'Type-Defect'}]},
+        {localId: 3, projectName: 'monorail',
+          labelRefs: [{label: 'Type-Defect'}]},
+      ]],
+    ]);
+
+    await element.updateComplete;
+
+    const href = element.shadowRoot.querySelectorAll('.counts')[1].href;
+    assert.include(href, '/list?x=Type&y=&q=Type%3ADefect&mode=');
+  });
+
+  it('computes list link when grouped by col in counts mode', async () => {
+    await element.updateComplete;
+
+    element.cellMode = 'Counts';
+    element.queryParams = {x: '', y: 'Type', q: 'Type:Defect'};
+    element._xHeadings = ['All'];
+    element._yHeadings = ['All', 'Defect'];
+    element._groupedIssues = new Map([
+      ['All + All', [{'localId': 1, 'projectName': 'monorail'}]],
+      ['All + Defect', [
+        {localId: 2, projectName: 'monorail',
+          labelRefs: [{label: 'Type-Defect'}]},
+        {localId: 3, projectName: 'monorail',
+          labelRefs: [{label: 'Type-Defect'}]},
+      ]],
+    ]);
+
+    await element.updateComplete;
+
+    const href = element.shadowRoot.querySelectorAll('.counts')[1].href;
+    assert.include(href, '/list?x=&y=Type&q=Type%3ADefect&mode=');
+  });
+
+  it('computes list link when grouped by row, col in counts mode', async () => {
+    await element.updateComplete;
+
+    element.cellMode = 'Counts';
+    element.queryParams = {x: 'Stars', y: 'Type',
+      q: 'Type:Defect Stars=2'};
+    element._xHeadings = ['All', '2'];
+    element._yHeadings = ['All', 'Defect'];
+    element._groupedIssues = new Map([
+      ['All + All', [{'localId': 1, 'projectName': 'monorail'}]],
+      ['2 + Defect', [
+        {localId: 2, projectName: 'monorail',
+          labelRefs: [{label: 'Type-Defect'}], starCount: 2},
+        {localId: 3, projectName: 'monorail',
+          labelRefs: [{label: 'Type-Defect'}], starCount: 2},
+      ]],
+    ]);
+
+    await element.updateComplete;
+
+    const href = element.shadowRoot.querySelectorAll('.counts')[1].href;
+    assert.include(href,
+        '/list?x=Stars&y=Type&q=Type%3ADefect%20Stars%3D2&mode=');
+  });
+});
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);
+    });
+  });
+});
diff --git a/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.js b/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.js
new file mode 100644
index 0000000..8876402
--- /dev/null
+++ b/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.js
@@ -0,0 +1,54 @@
+// 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 page from 'page';
+import {ChopsChoiceButtons} from
+  'elements/chops/chops-choice-buttons/chops-choice-buttons.js';
+import {urlWithNewParams} from 'shared/helpers.js';
+
+/**
+ * Component for showing the chips to switch between List, Grid, and Chart modes
+ * on the Monorail issue list page.
+ * @extends {ChopsChoiceButtons}
+ */
+export class MrModeSelector extends ChopsChoiceButtons {
+  /** @override */
+  static get properties() {
+    return {
+      ...ChopsChoiceButtons.properties,
+      queryParams: {type: Object},
+      projectName: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.queryParams = {};
+    this.projectName = '';
+
+    this._page = page;
+  };
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('queryParams') ||
+        changedProperties.has('projectName')) {
+      this.options = [
+        {text: 'List', value: 'list', url: this._newListViewPath()},
+        {text: 'Grid', value: 'grid', url: this._newListViewPath('grid')},
+        {text: 'Chart', value: 'chart', url: this._newListViewPath('chart')},
+      ];
+    }
+    super.update(changedProperties);
+  }
+
+  _newListViewPath(mode) {
+    const basePath = `/p/${this.projectName}/issues/list`;
+    const deletedParams = mode ? undefined : ['mode'];
+    return urlWithNewParams(basePath, this.queryParams, {mode}, deletedParams);
+  }
+};
+
+customElements.define('mr-mode-selector', MrModeSelector);
diff --git a/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.test.js b/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.test.js
new file mode 100644
index 0000000..07166d6
--- /dev/null
+++ b/static_src/elements/issue-list/mr-mode-selector/mr-mode-selector.test.js
@@ -0,0 +1,42 @@
+// 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 {MrModeSelector} from './mr-mode-selector.js';
+
+let element;
+
+describe('mr-mode-selector', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-mode-selector');
+    document.body.appendChild(element);
+
+    element._page = sinon.stub();
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrModeSelector);
+  });
+
+  it('renders links with projectName and queryParams', async () => {
+    element.value = 'list';
+    element.projectName = 'chromium';
+    element.queryParams = {q: 'hello-world'};
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('a');
+
+    assert.include(links[0].href, '/p/chromium/issues/list?q=hello-world');
+    assert.include(links[1].href,
+        '/p/chromium/issues/list?q=hello-world&mode=grid');
+    assert.include(links[2].href,
+        '/p/chromium/issues/list?q=hello-world&mode=chart');
+  });
+});