Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
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=');
+  });
+});