Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/autolink.js b/static_src/autolink.js
new file mode 100644
index 0000000..5419d9c
--- /dev/null
+++ b/static_src/autolink.js
@@ -0,0 +1,440 @@
+// 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.
+
+'use strict';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/* eslint-disable max-len */
+// When crbug links don't specify a project, the default project is Chromium.
+const CRBUG_DEFAULT_PROJECT = 'chromium';
+const CRBUG_LINK_RE = /(\b(https?:\/\/)?crbug\.com\/)((\b[-a-z0-9]+)(\/))?(\d+)\b(\#c[0-9]+)?/gi;
+const CRBUG_LINK_RE_PROJECT_GROUP = 4;
+const CRBUG_LINK_RE_ID_GROUP = 6;
+const CRBUG_LINK_RE_COMMENT_GROUP = 7;
+const ISSUE_TRACKER_RE = /(\b(issues?|bugs?)[ \t]*(:|=|\b)|\bfixed[ \t]*:)([ \t]*((\b[-a-z0-9]+)[:\#])?(\#?)(\d+)\b(,?[ \t]*(and|or)?)?)+/gi;
+const PROJECT_LOCALID_RE = /((\b(issue|bug)[ \t]*(:|=)?[ \t]*|\bfixed[ \t]*:[ \t]*)?((\b[-a-z0-9]+)[:\#])?(\#?)(\d+))/gi;
+const PROJECT_COMMENT_BUG_RE = /(((\b(issue|bug)[ \t]*(:|=)?[ \t]*)(\#?)(\d+)[ \t*])?((\b((comment)[ \t]*(:|=)?[ \t]*(\#?))|(\B((\#))(c)))(\d+)))/gi;
+const PROJECT_LOCALID_RE_PROJECT_GROUP = 6;
+const PROJECT_LOCALID_RE_ID_GROUP = 8;
+const IMPLIED_EMAIL_RE = /\b[a-z]((-|\.)?[a-z0-9])+@[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b/gi;
+// TODO(zhangtiff): Replace (^|[^-/._]) with (?<![-/._]) on the 3 Regexes below
+// once Firefox supports lookaheads.
+const SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/|mailto:)?(go|g|shortn|who|teams)\/([^\s<]+)/gi;
+const NUMERIC_SHORT_LINK_RE = /(^|[^-\/._])\b(https?:\/\/|ftp:\/\/)?(b|t|o|omg|cl|cr|fxr|fxrev|fxb|tqr)\/([0-9]+)/gi;
+const IMPLIED_LINK_RE = /(^|[^-\/._])\b[a-z]((-|\.)?[a-z0-9])+\.(com|net|org|edu|dev)\b(\/[^\s<]*)?/gi;
+const IS_LINK_RE = /()\b(https?:\/\/|ftp:\/\/|mailto:)([^\s<]+)/gi;
+const GIT_HASH_RE = /\b(r(evision\s+#?)?)?([a-f0-9]{40})\b/gi;
+const SVN_REF_RE = /\b(r(evision\s+#?)?)([0-9]{4,7})\b/gi;
+const NEW_LINE_REGEX = /^(\r\n?|\n)$/;
+const NEW_LINE_OR_BOLD_REGEX = /(<b>[^<\n]+<\/b>)|(\r\n?|\n)/;
+// The revNum is in the same position for the above two Regexes. The
+// extraction function uses this similar format to allow switching out
+// Regexes easily, so be careful about changing GIT_HASH_RE and SVN_HASH_RE.
+const REV_NUM_GROUP = 3;
+const LINK_TRAILING_CHARS = [
+  [null, ':'],
+  [null, '.'],
+  [null, ','],
+  [null, '>'],
+  ['(', ')'],
+  ['[', ']'],
+  ['{', '}'],
+  ['\'', '\''],
+  ['"', '"'],
+];
+const GOOG_SHORT_LINK_RE = /^(b|t|o|omg|cl|cr|go|g|shortn|who|teams|fxr|fxrev|fxb|tqr)\/.*/gi;
+/* eslint-enable max-len */
+
+const Components = new Map();
+// TODO(zosha): Combine functions of Component 00 with 01 so that
+// user can only reference valid issues in the issue/comment linking.
+// Allow user to reference multiple comments on the same issue.
+// Additionally, allow for the user to reference this on a specific project.
+// Note: the order of the components is important for proper autolinking.
+Components.set(
+    '00-commentbug',
+    {
+      lookup: null,
+      extractRefs: null,
+      refRegs: [PROJECT_COMMENT_BUG_RE],
+      replacer: ReplaceCommentBugRef,
+    },
+);
+Components.set(
+    '01-tracker-crbug',
+    {
+      lookup: LookupReferencedIssues,
+      extractRefs: ExtractCrbugProjectAndIssueIds,
+      refRegs: [CRBUG_LINK_RE],
+      replacer: ReplaceCrbugIssueRef,
+
+    },
+);
+Components.set(
+    '02-full-urls',
+    {
+      lookup: null,
+      extractRefs: (match, _currentProjectName) => {
+        return [match[0]];
+      },
+      refRegs: [IS_LINK_RE],
+      replacer: ReplaceLinkRef,
+    },
+);
+Components.set(
+    '03-user-emails',
+    {
+      lookup: LookupReferencedUsers,
+      extractRefs: (match, _currentProjectName) => {
+        return [match[0]];
+      },
+      refRegs: [IMPLIED_EMAIL_RE],
+      replacer: ReplaceUserRef,
+    },
+);
+Components.set(
+    '04-tracker-regular',
+    {
+      lookup: LookupReferencedIssues,
+      extractRefs: ExtractTrackerProjectAndIssueIds,
+      refRegs: [ISSUE_TRACKER_RE],
+      replacer: ReplaceTrackerIssueRef,
+    },
+);
+Components.set(
+    '05-linkify-shorthand',
+    {
+      lookup: null,
+      extractRefs: (match, _currentProjectName) => {
+        return [match[0]];
+      },
+      refRegs: [
+        SHORT_LINK_RE,
+        NUMERIC_SHORT_LINK_RE,
+        IMPLIED_LINK_RE,
+      ],
+      replacer: ReplaceLinkRef,
+    },
+);
+Components.set(
+    '06-versioncontrol',
+    {
+      lookup: null,
+      extractRefs: (match, _currentProjectName) => {
+        return [match[0]];
+      },
+      refRegs: [GIT_HASH_RE, SVN_REF_RE],
+      replacer: ReplaceRevisionRef,
+    },
+);
+
+// Lookup referenced artifacts functions.
+function LookupReferencedIssues(issueRefs, componentName) {
+  return new Promise((resolve, reject) => {
+    issueRefs = issueRefs.filter(
+        ({projectName, localId}) => projectName && parseInt(localId));
+    const listReferencedIssues = prpcClient.call(
+        'monorail.Issues', 'ListReferencedIssues', {issueRefs});
+    return listReferencedIssues.then((response) => {
+      resolve({'componentName': componentName, 'existingRefs': response});
+    });
+  });
+}
+
+function LookupReferencedUsers(emails, componentName) {
+  return new Promise((resolve, reject) => {
+    const userRefs = emails.map((displayName) => {
+      return {displayName};
+    });
+    const listReferencedUsers = prpcClient.call(
+        'monorail.Users', 'ListReferencedUsers', {userRefs});
+    return listReferencedUsers.then((response) => {
+      resolve({'componentName': componentName, 'existingRefs': response});
+    });
+  });
+}
+
+// Extract referenced artifacts info functions.
+function ExtractCrbugProjectAndIssueIds(match, _currentProjectName) {
+  // When crbug links don't specify a project, the default project is Chromium.
+  const projectName = match[CRBUG_LINK_RE_PROJECT_GROUP] ||
+    CRBUG_DEFAULT_PROJECT;
+  const localId = match[CRBUG_LINK_RE_ID_GROUP];
+  return [{projectName: projectName, localId: localId}];
+}
+
+function ExtractTrackerProjectAndIssueIds(match, currentProjectName) {
+  const issueRefRE = PROJECT_LOCALID_RE;
+  let refMatch;
+  const refs = [];
+  while ((refMatch = issueRefRE.exec(match[0])) !== null) {
+    if (refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP]) {
+      currentProjectName = refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP];
+    }
+    refs.push({
+      projectName: currentProjectName,
+      localId: refMatch[PROJECT_LOCALID_RE_ID_GROUP],
+    });
+  }
+  return refs;
+}
+
+// Replace plain text references with links functions.
+function ReplaceIssueRef(stringMatch, projectName, localId, components,
+    commentId) {
+  if (components.openRefs && components.openRefs.length) {
+    const openRef = components.openRefs.find((ref) => {
+      return ref.localId && ref.projectName && (ref.localId == localId) &&
+          (ref.projectName.toLowerCase() === projectName.toLowerCase());
+    });
+    if (openRef) {
+      return createIssueRefRun(
+          projectName, localId, openRef.summary, false, stringMatch, commentId);
+    }
+  }
+  if (components.closedRefs && components.closedRefs.length) {
+    const closedRef = components.closedRefs.find((ref) => {
+      return ref.localId && ref.projectName && (ref.localId == localId) &&
+          (ref.projectName.toLowerCase() === projectName.toLowerCase());
+    });
+    if (closedRef) {
+      return createIssueRefRun(
+          projectName, localId, closedRef.summary, true, stringMatch,
+          commentId);
+    }
+  }
+  return {content: stringMatch};
+}
+
+function ReplaceCrbugIssueRef(match, components, _currentProjectName) {
+  components = components || {};
+  // When crbug links don't specify a project, the default project is Chromium.
+  const projectName =
+    match[CRBUG_LINK_RE_PROJECT_GROUP] || CRBUG_DEFAULT_PROJECT;
+  const localId = match[CRBUG_LINK_RE_ID_GROUP];
+  let commentId = '';
+  if (match[CRBUG_LINK_RE_COMMENT_GROUP] !== undefined) {
+    commentId = match[CRBUG_LINK_RE_COMMENT_GROUP];
+  }
+  return [ReplaceIssueRef(match[0], projectName, localId, components,
+      commentId)];
+}
+
+function ReplaceTrackerIssueRef(match, components, currentProjectName) {
+  components = components || {};
+  const issueRefRE = PROJECT_LOCALID_RE;
+  const commentId = '';
+  const textRuns = [];
+  let refMatch;
+  let pos = 0;
+  while ((refMatch = issueRefRE.exec(match[0])) !== null) {
+    if (refMatch.index > pos) {
+      // Create textrun for content between previous and current match.
+      textRuns.push({content: match[0].slice(pos, refMatch.index)});
+    }
+    if (refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP]) {
+      currentProjectName = refMatch[PROJECT_LOCALID_RE_PROJECT_GROUP];
+    }
+    textRuns.push(ReplaceIssueRef(
+        refMatch[0], currentProjectName,
+        refMatch[PROJECT_LOCALID_RE_ID_GROUP], components, commentId));
+    pos = refMatch.index + refMatch[0].length;
+  }
+  if (match[0].slice(pos) !== '') {
+    textRuns.push({content: match[0].slice(pos)});
+  }
+  return textRuns;
+}
+
+function ReplaceUserRef(match, components, _currentProjectName) {
+  components = components || {};
+  const textRun = {content: match[0], tag: 'a'};
+  if (components.users && components.users.length) {
+    const existingUser = components.users.find((user) => {
+      return user.displayName.toLowerCase() === match[0].toLowerCase();
+    });
+    if (existingUser) {
+      textRun.href = `/u/${match[0]}`;
+      return [textRun];
+    }
+  }
+  textRun.href = `mailto:${match[0]}`;
+  return [textRun];
+}
+
+function ReplaceCommentBugRef(match) {
+  let textRun;
+  const issueNum = match[7];
+  const commentNum = match[18];
+  if (issueNum && commentNum) {
+    textRun = {content: match[0], tag: 'a', href: `?id=${issueNum}#c${commentNum}`};
+  } else if (commentNum) {
+    textRun = {content: match[0], tag: 'a', href: `#c${commentNum}`};
+  } else {
+    textRun = {content: match[0]};
+  }
+  return [textRun];
+}
+
+function ReplaceLinkRef(match, _components, _currentProjectName) {
+  const textRuns = [];
+  let content = match[0];
+  let trailing = '';
+  if (match[1]) {
+    textRuns.push({content: match[1]});
+    content = content.slice(match[1].length);
+  }
+  LINK_TRAILING_CHARS.forEach(([begin, end]) => {
+    if (content.endsWith(end)) {
+      if (!begin || !content.slice(0, -end.length).includes(begin)) {
+        trailing = end + trailing;
+        content = content.slice(0, -end.length);
+      }
+    }
+  });
+  let href = content;
+  const lowerHref = href.toLowerCase();
+  if (!lowerHref.startsWith('http') && !lowerHref.startsWith('ftp') &&
+      !lowerHref.startsWith('mailto')) {
+    // Prepend google-internal short links with http to
+    // prevent HTTPS error interstitial.
+    // SHORT_LINK_RE should not be used here as it might be
+    // in the middle of another match() process in an outer loop.
+    if (GOOG_SHORT_LINK_RE.test(lowerHref)) {
+      href = 'http://' + href;
+    } else {
+      href = 'https://' + href;
+    }
+    GOOG_SHORT_LINK_RE.lastIndex = 0;
+  }
+  textRuns.push({content: content, tag: 'a', href: href});
+  if (trailing.length) {
+    textRuns.push({content: trailing});
+  }
+  return textRuns;
+}
+
+function ReplaceRevisionRef(
+    match, _components, _currentProjectName, revisionUrlFormat) {
+  const content = match[0];
+  const href = revisionUrlFormat.replace('{revnum}', match[REV_NUM_GROUP]);
+  return [{content: content, tag: 'a', href: href}];
+}
+
+// Create custom textrun functions.
+function createIssueRefRun(projectName, localId, summary, isClosed, content,
+    commentId) {
+  return {
+    tag: 'a',
+    css: isClosed ? 'strike-through' : '',
+    href: `/p/${projectName}/issues/detail?id=${localId}${commentId}`,
+    title: summary || '',
+    content: content,
+  };
+}
+
+/**
+ * @typedef {Object} CommentReference
+ * @property {string} componentName A key identifying the kind of autolinking
+ *   text the reference matches.
+ * @property {Array<any>} existingRefs Array of full data for referenced
+ *   Objects. Each entry in this Array could be any kind of data depending
+ *   on what the text references. For example, the Array could contain Issue
+ *   or User Objects.
+ */
+
+/**
+ * Iterates through a list of comments, requests data for referenced objects
+ * in those comments, and returns all fetched data.
+ * @param {Array<IssueComment>} comments Array of comments to check.
+ * @param {string} currentProjectName Project these comments exist in the
+ *   context of.
+ * @return {Promise<Array<CommentReference>>}
+ */
+function getReferencedArtifacts(comments, currentProjectName) {
+  return new Promise((resolve, reject) => {
+    const fetchPromises = [];
+    Components.forEach(({lookup, extractRefs, refRegs}, componentName) => {
+      if (lookup !== null) {
+        const refs = [];
+        refRegs.forEach((re) => {
+          let match;
+          comments.forEach((comment) => {
+            while ((match = re.exec(comment.content)) !== null) {
+              refs.push(...extractRefs(match, currentProjectName));
+            };
+          });
+        });
+        if (refs.length) {
+          fetchPromises.push(lookup(refs, componentName));
+        }
+      }
+    });
+    resolve(Promise.all(fetchPromises));
+  });
+}
+
+function markupAutolinks(
+    plainString, componentRefs, currentProjectName, revisionUrlFormat) {
+  plainString = plainString || '';
+  const chunks = plainString.trim().split(NEW_LINE_OR_BOLD_REGEX);
+  const textRuns = [];
+  chunks.filter(Boolean).forEach((chunk) => {
+    if (chunk.match(NEW_LINE_REGEX)) {
+      textRuns.push({tag: 'br'});
+    } else if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
+      textRuns.push({content: chunk.slice(3, -4), tag: 'b'});
+    } else {
+      textRuns.push(
+          ...autolinkChunk(
+              chunk, componentRefs, currentProjectName, revisionUrlFormat));
+    }
+  });
+  return textRuns;
+}
+
+function autolinkChunk(
+    chunk, componentRefs, currentProjectName, revisionUrlFormat) {
+  let textRuns = [{content: chunk}];
+  Components.forEach(({refRegs, replacer}, componentName) => {
+    refRegs.forEach((re) => {
+      textRuns = applyLinks(
+          textRuns, replacer, re, componentRefs.get(componentName),
+          currentProjectName, revisionUrlFormat);
+    });
+  });
+  return textRuns;
+}
+
+function applyLinks(
+    textRuns, replacer, re, existingRefs, currentProjectName,
+    revisionUrlFormat) {
+  const resultRuns = [];
+  textRuns.forEach((textRun) => {
+    if (textRun.tag) {
+      resultRuns.push(textRun);
+    } else {
+      const content = textRun.content;
+      let pos = 0;
+      let match;
+      while ((match = re.exec(content)) !== null) {
+        if (match.index > pos) {
+          // Create textrun for content between previous and current match.
+          resultRuns.push({content: content.slice(pos, match.index)});
+        }
+        resultRuns.push(
+            ...replacer(
+                match, existingRefs, currentProjectName, revisionUrlFormat));
+        pos = match.index + match[0].length;
+      }
+      if (content.slice(pos) !== '') {
+        resultRuns.push({content: content.slice(pos)});
+      }
+    }
+  });
+  return resultRuns;
+}
+
+
+export const autolink = {Components, getReferencedArtifacts, markupAutolinks};
diff --git a/static_src/autolink.test.js b/static_src/autolink.test.js
new file mode 100644
index 0000000..fcb2af2
--- /dev/null
+++ b/static_src/autolink.test.js
@@ -0,0 +1,948 @@
+import sinon from 'sinon';
+import {assert} from 'chai';
+import {autolink} from './autolink.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+const components = autolink.Components;
+const markupAutolinks = autolink.markupAutolinks;
+
+describe('autolink', () => {
+  describe('crbug component functions', () => {
+    const {extractRefs, refRegs, replacer} = components.get('01-tracker-crbug');
+
+    it('Extract crbug project and local ids', () => {
+      const match = refRegs[0].exec('https://crbug.com/monorail/1234');
+      refRegs[0].lastIndex = 0;
+      const ref = extractRefs(match);
+      assert.deepEqual(ref, [{projectName: 'monorail', localId: '1234'}]);
+    });
+
+    it('Extract crbug default project name', () => {
+      const match = refRegs[0].exec('http://crbug.com/1234');
+      refRegs[0].lastIndex = 0;
+      const ref = extractRefs(match);
+      assert.deepEqual(ref, [{projectName: 'chromium', localId: '1234'}]);
+    });
+
+    it('Extract crbug passed project name is ignored', () => {
+      const match = refRegs[0].exec('https://crbug.com/1234');
+      refRegs[0].lastIndex = 0;
+      const ref = extractRefs(match, 'foo');
+      assert.deepEqual(ref, [{projectName: 'chromium', localId: '1234'}]);
+    });
+
+    it('Replace crbug with found components', () => {
+      const str = 'crbug.com/monorail/1234';
+      const match = refRegs[0].exec(str);
+      refRegs[0].lastIndex = 0;
+      const components = {
+        closedRefs: [
+          {summary: 'Issue summary', localId: 1234, projectName: 'monorail'},
+          {},
+        ]};
+      const actualRun = replacer(match, components);
+      assert.deepEqual(
+          actualRun,
+          [{
+            tag: 'a',
+            css: 'strike-through',
+            href: '/p/monorail/issues/detail?id=1234',
+            title: 'Issue summary',
+            content: str,
+          }],
+      );
+    });
+
+    it('Replace crbug with found components, with comment', () => {
+      const str = 'crbug.com/monorail/1234#c1';
+      const match = refRegs[0].exec(str);
+      refRegs[0].lastIndex = 0;
+      const components = {
+        closedRefs: [
+          {summary: 'Issue summary', localId: 1234, projectName: 'monorail'},
+          {},
+        ]};
+      const actualRun = replacer(match, components);
+      assert.deepEqual(
+          actualRun,
+          [{
+            tag: 'a',
+            css: 'strike-through',
+            href: '/p/monorail/issues/detail?id=1234#c1',
+            title: 'Issue summary',
+            content: str,
+          }],
+      );
+    });
+
+    it('Replace crbug with default project_name', () => {
+      const str = 'crbug.com/1234';
+      const match = refRegs[0].exec(str);
+      refRegs[0].lastIndex = 0;
+      const components = {
+        openRefs: [
+          {localId: 134},
+          {summary: 'Issue 1234', localId: 1234, projectName: 'chromium'},
+        ],
+      };
+      const actualRun = replacer(match, components);
+      assert.deepEqual(
+          actualRun,
+          [{
+            tag: 'a',
+            href: '/p/chromium/issues/detail?id=1234',
+            css: '',
+            title: 'Issue 1234',
+            content: str,
+          }],
+      );
+    });
+
+    it('Replace crbug incomplete responses', () => {
+      const str = 'crbug.com/1234';
+      const match = refRegs[0].exec(str);
+      refRegs[0].lastIndex = 0;
+      const components = {
+        openRefs: [{localId: 1234}, {projectName: 'chromium'}],
+        closedRefs: [{localId: 1234}, {projectName: 'chromium'}],
+      };
+      const actualRun = replacer(match, components);
+      assert.deepEqual(actualRun, [{content: str}]);
+    });
+
+    it('Replace crbug passed project name is ignored', () => {
+      const str = 'crbug.com/1234';
+      const match = refRegs[0].exec(str);
+      refRegs[0].lastIndex = 0;
+      const components = {
+        openRefs: [
+          {localId: 134},
+          {summary: 'Issue 1234', localId: 1234, projectName: 'chromium'},
+        ],
+      };
+      const actualRun = replacer(match, components, 'foo');
+      assert.deepEqual(
+          actualRun,
+          [{
+            tag: 'a',
+            href: '/p/chromium/issues/detail?id=1234',
+            css: '',
+            title: 'Issue 1234',
+            content: str,
+          }],
+      );
+    });
+
+    it('Replace crbug with no found components', () => {
+      const str = 'crbug.com/1234';
+      const match = refRegs[0].exec(str);
+      refRegs[0].lastIndex = 0;
+      const components = {};
+      const actualRun = replacer(match, components);
+      assert.deepEqual(actualRun, [{content: str}]);
+    });
+
+    it('Replace crbug with no issue summary', () => {
+      const str = 'crbug.com/monorail/1234';
+      const match = refRegs[0].exec(str);
+      refRegs[0].lastIndex = 0;
+      const components = {
+        closedRefs: [
+          {localId: 1234, projectName: 'monorail'},
+          {},
+        ]};
+      const actualRun = replacer(match, components);
+      assert.deepEqual(
+          actualRun,
+          [{
+            tag: 'a',
+            css: 'strike-through',
+            href: '/p/monorail/issues/detail?id=1234',
+            title: '',
+            content: str,
+          }],
+      );
+    });
+  });
+
+  describe('regular tracker component functions', () => {
+    const {extractRefs, refRegs, replacer} =
+      components.get('04-tracker-regular');
+    const str = 'bugs=123, monorail:234 or #345 and PROJ:#456';
+    const match = refRegs[0].exec(str);
+    refRegs[0].lastIndex = 0;
+
+    it('Extract tracker projects and local ids', () => {
+      const actualRefs = extractRefs(match, 'foo-project');
+      assert.deepEqual(
+          actualRefs,
+          [{projectName: 'foo-project', localId: '123'},
+            {projectName: 'monorail', localId: '234'},
+            {projectName: 'monorail', localId: '345'},
+            {projectName: 'PROJ', localId: '456'}]);
+    });
+
+    it('Replace tracker refs.', () => {
+      const components = {
+        openRefs: [
+          {summary: 'sum', projectName: 'monorail', localId: 888},
+          {summary: 'ma', projectName: 'chromium', localId: '123'},
+        ],
+        closedRefs: [
+          {summary: 'ry', projectName: 'proj', localId: 456},
+        ],
+      };
+      const actualTextRuns = replacer(match, components, 'chromium');
+      assert.deepEqual(
+          actualTextRuns,
+          [
+            {content: 'bugs='},
+            {
+              tag: 'a',
+              href: '/p/chromium/issues/detail?id=123',
+              css: '',
+              title: 'ma',
+              content: '123',
+            },
+            {content: ', '},
+            {content: 'monorail:234'},
+            {content: ' or '},
+            {content: '#345'},
+            {content: ' and '},
+            {
+              tag: 'a',
+              href: '/p/PROJ/issues/detail?id=456',
+              css: 'strike-through',
+              title: 'ry',
+              content: 'PROJ:#456',
+            },
+          ],
+      );
+    });
+
+    it('Replace tracker refs mixed case refs.', () => {
+      const components = {
+        openRefs: [
+          {projectName: 'mOnOrAIl', localId: 234},
+        ],
+        closedRefs: [
+          {projectName: 'LeMuR', localId: 123},
+        ],
+      };
+      const actualTextRuns = replacer(match, components, 'lEmUr');
+      assert.deepEqual(
+          actualTextRuns,
+          [
+            {content: 'bugs='},
+            {
+              tag: 'a',
+              href: '/p/lEmUr/issues/detail?id=123',
+              css: 'strike-through',
+              title: '',
+              content: '123',
+            },
+            {content: ', '},
+            {
+              tag: 'a',
+              href: '/p/monorail/issues/detail?id=234',
+              css: '',
+              title: '',
+              content: 'monorail:234',
+            },
+            {content: ' or '},
+            {content: '#345'},
+            {content: ' and '},
+            {content: 'PROJ:#456'},
+          ],
+      );
+    });
+
+    it('Recognizes Fixed: syntax', () => {
+      const str = 'Fixed : 123, proj:456';
+      const match = refRegs[0].exec(str);
+      refRegs[0].lastIndex = 0;
+
+      const components = {
+        openRefs: [
+          {summary: 'summ', projectName: 'chromium', localId: 123},
+        ],
+        closedRefs: [
+          {summary: 'ary', projectName: 'proj', localId: 456},
+        ],
+      };
+
+      const actualTextRuns = replacer(match, components, 'chromium');
+      assert.deepEqual(
+          actualTextRuns,
+          [
+            {
+              tag: 'a',
+              href: '/p/chromium/issues/detail?id=123',
+              css: '',
+              title: 'summ',
+              content: 'Fixed : 123',
+            },
+            {content: ', '},
+            {
+              tag: 'a',
+              href: '/p/proj/issues/detail?id=456',
+              css: 'strike-through',
+              title: 'ary',
+              content: 'proj:456',
+            },
+          ],
+      );
+    });
+  });
+
+  describe('user email component functions', () => {
+    const {extractRefs, refRegs, replacer} = components.get('03-user-emails');
+    const str = 'We should ask User1@gmail.com to confirm.';
+    const match = refRegs[0].exec(str);
+    refRegs[0].lastIndex = 0;
+
+    it('Extract user email', () => {
+      const actualEmail = extractRefs(match, 'unusedProjectName');
+      assert.equal('User1@gmail.com', actualEmail);
+    });
+
+    it('Replace existing user.', () => {
+      const components = {
+        users: [{displayName: 'user2@gmail.com'},
+          {displayName: 'user1@gmail.com'}]};
+      const actualTextRun = replacer(match, components);
+      assert.deepEqual(
+          actualTextRun,
+          [{tag: 'a', href: '/u/User1@gmail.com', content: 'User1@gmail.com'}],
+      );
+    });
+
+    it('Replace non-existent user.', () => {
+      const actualTextRun = replacer(match, {});
+      assert.deepEqual(
+          actualTextRun,
+          [{
+            tag: 'a',
+            href: 'mailto:User1@gmail.com',
+            content: 'User1@gmail.com',
+          }],
+      );
+    });
+  });
+
+  describe('full url component functions.', () => {
+    const {refRegs, replacer} = components.get('02-full-urls');
+
+    it('test full link regex string', () => {
+      const isLinkRE = refRegs[0];
+      const str =
+        'https://www.go.com ' +
+        'nospacehttps://www.blah.com http://website.net/other="(}])"><)';
+      let match;
+      const actualMatches = [];
+      while ((match = isLinkRE.exec(str)) !== null) {
+        actualMatches.push(match[0]);
+      }
+      assert.deepEqual(
+          actualMatches,
+          ['https://www.go.com', 'http://website.net/other="(}])">']);
+    });
+
+    it('Replace URL existing http', () => {
+      const match = refRegs[0].exec('link here: (https://website.net/other="here").');
+      refRegs[0].lastIndex = 0;
+      const actualTextRuns = replacer(match);
+      assert.deepEqual(
+          actualTextRuns,
+          [{tag: 'a',
+            href: 'https://website.net/other="here"',
+            content: 'https://website.net/other="here"',
+          },
+          {content: ').'}],
+      );
+    });
+
+    it('Replace URL with short-link as substring', () => {
+      const match = refRegs[0].exec('https://website.net/who/me/yes/you');
+      refRegs[0].lastIndex = 0;
+      const actualTextRuns = replacer(match);
+
+      assert.deepEqual(
+          actualTextRuns,
+          [{tag: 'a',
+            href: 'https://website.net/who/me/yes/you',
+            content: 'https://website.net/who/me/yes/you',
+          }],
+      );
+    });
+
+    it('Replace URL with email as substring', () => {
+      const match = refRegs[0].exec('https://website.net/who/foo@example.com');
+      refRegs[0].lastIndex = 0;
+      const actualTextRuns = replacer(match);
+
+      assert.deepEqual(
+          actualTextRuns,
+          [{tag: 'a',
+            href: 'https://website.net/who/foo@example.com',
+            content: 'https://website.net/who/foo@example.com',
+          }],
+      );
+    });
+  });
+
+  describe('shorthand url component functions.', () => {
+    const {refRegs, replacer} = components.get('05-linkify-shorthand');
+
+    it('Short link does not match URL with short-link as substring', () => {
+      refRegs[0].lastIndex = 0;
+      assert.isNull(refRegs[0].exec('https://website.net/who/me/yes/you'));
+    });
+
+    it('test short link regex string', () => {
+      const shortLinkRE = refRegs[0];
+      const str =
+        'go/shortlinks ./_go/shortlinks bo/short bo/1234  ' +
+        'https://who/shortlinks go/hey/?wct=(go)';
+      let match;
+      const actualMatches = [];
+      while ((match = shortLinkRE.exec(str)) !== null) {
+        actualMatches.push(match[0]);
+      }
+      assert.deepEqual(
+          actualMatches,
+          ['go/shortlinks', ' https://who/shortlinks', ' go/hey/?wct=(go)'],
+      );
+    });
+
+    it('test numeric short link regex string', () => {
+      const shortNumLinkRE = refRegs[1];
+      const str = 'go/nono omg/ohno omg/123 .cl/123 b/1234';
+      let match;
+      const actualMatches = [];
+      while ((match = shortNumLinkRE.exec(str)) !== null) {
+        actualMatches.push(match[0]);
+      }
+      assert.deepEqual(actualMatches, [' omg/123', ' b/1234']);
+    });
+
+    it('test fuchsia short links', () => {
+      const shortNumLinkRE = refRegs[1];
+      const str = 'ignore fxr/123 fxrev/789 fxb/456 tqr/123 ';
+      let match;
+      const actualMatches = [];
+      while ((match = shortNumLinkRE.exec(str)) !== null) {
+        actualMatches.push(match[0]);
+      }
+      assert.deepEqual(actualMatches, [' fxr/123', ' fxrev/789', ' fxb/456',
+        ' tqr/123']);
+    });
+
+    it('test implied link regex string', () => {
+      const impliedLinkRE = refRegs[2];
+      const str = 'incomplete.com .help.com hey.net/other="(blah)"';
+      let match;
+      const actualMatches = [];
+      while ((match = impliedLinkRE.exec(str)) !== null) {
+        actualMatches.push(match[0]);
+      }
+      assert.deepEqual(
+          actualMatches, ['incomplete.com', ' hey.net/other="(blah)"']);
+    });
+
+    it('test implied link alternate domains', () => {
+      const impliedLinkRE = refRegs[2];
+      const str = 'what.net hey.edu google.org fuchsia.dev ignored.domain';
+      let match;
+      const actualMatches = [];
+      while ((match = impliedLinkRE.exec(str)) !== null) {
+        actualMatches.push(match[0]);
+      }
+      assert.deepEqual(
+          actualMatches, ['what.net', ' hey.edu', ' google.org',
+            ' fuchsia.dev']);
+    });
+
+    it('Replace URL plain text', () => {
+      const match = refRegs[2].exec('link here: (website.net/other="here").');
+      refRegs[2].lastIndex = 0;
+      const actualTextRuns = replacer(match);
+      assert.deepEqual(
+          actualTextRuns,
+          [{content: '('},
+            {tag: 'a',
+              href: 'https://website.net/other="here"',
+              content: 'website.net/other="here"',
+            },
+            {content: ').'}],
+      );
+    });
+
+    it('Replace short link existing http', () => {
+      const match = refRegs[0].exec('link here: (http://who/me).');
+      refRegs[0].lastIndex = 0;
+      const actualTextRuns = replacer(match);
+      assert.deepEqual(
+          actualTextRuns,
+          [{content: '('},
+            {tag: 'a',
+              href: 'http://who/me',
+              content: 'http://who/me',
+            },
+            {content: ').'}],
+      );
+    });
+
+    it('Replace short-link plain text', () => {
+      const match = refRegs[0].exec('link here: (who/me).');
+      refRegs[0].lastIndex = 0;
+      const actualTextRuns = replacer(match);
+      assert.deepEqual(
+          actualTextRuns,
+          [{content: '('},
+            {tag: 'a',
+              href: 'http://who/me',
+              content: 'who/me',
+            },
+            {content: ').'}],
+      );
+    });
+
+    it('Replace short-link plain text initial characters', () => {
+      const match = refRegs[0].exec('link here: who/me');
+      refRegs[0].lastIndex = 0;
+      const actualTextRuns = replacer(match);
+      assert.deepEqual(
+          actualTextRuns,
+          [{content: ' '},
+            {tag: 'a',
+              href: 'http://who/me',
+              content: 'who/me',
+            }],
+      );
+    });
+
+    it('Replace URL short link', () => {
+      ['go', 'g', 'shortn', 'who', 'teams'].forEach((prefix) => {
+        const match = refRegs[0].exec(`link here: (${prefix}/abcd).`);
+        refRegs[0].lastIndex = 0;
+        const actualTextRuns = replacer(match);
+        assert.deepEqual(
+            actualTextRuns,
+            [{content: '('},
+              {tag: 'a',
+                href: `http://${prefix}/abcd`,
+                content: `${prefix}/abcd`,
+              },
+              {content: ').'}],
+        );
+      });
+    });
+
+    it('Replace URL numeric short link', () => {
+      ['b', 't', 'o', 'omg', 'cl', 'cr'].forEach((prefix) => {
+        const match = refRegs[1].exec(`link here: (${prefix}/1234).`);
+        refRegs[1].lastIndex = 0;
+        const actualTextRuns = replacer(match);
+        assert.deepEqual(
+            actualTextRuns,
+            [{content: '('},
+              {tag: 'a',
+                href: `http://${prefix}/1234`,
+                content: `${prefix}/1234`,
+              }],
+        );
+      });
+    });
+  });
+
+  describe('versioncontrol component functions.', () => {
+    const {refRegs, replacer} = components.get('06-versioncontrol');
+
+    it('test git hash regex', () => {
+      const gitHashRE = refRegs[0];
+      const str =
+          'r63b72a71d5fbce6739c51c3846dd94bd62b91091 blha blah ' +
+          'Revision 63b72a71d5fbce6739c51c3846dd94bd62b91091 blah balh ' +
+          '63b72a71d5fbce6739c51c3846dd94bd62b91091 ' +
+          'Revision63b72a71d5fbce6739c51c3846dd94bd62b91091';
+      let match;
+      const actualMatches = [];
+      while ((match = gitHashRE.exec(str)) !== null) {
+        actualMatches.push(match[0]);
+      }
+      assert.deepEqual(
+          actualMatches, [
+            'r63b72a71d5fbce6739c51c3846dd94bd62b91091',
+            'Revision 63b72a71d5fbce6739c51c3846dd94bd62b91091',
+            '63b72a71d5fbce6739c51c3846dd94bd62b91091',
+          ]);
+    });
+
+    it('test svn regex', () => {
+      const svnRE = refRegs[1];
+      const str =
+          'r1234 blah blah ' +
+          'Revision 123456 blah balh ' +
+          'r12345678' +
+          '1234';
+      let match;
+      const actualMatches = [];
+      while ((match = svnRE.exec(str)) !== null) {
+        actualMatches.push(match[0]);
+      }
+      assert.deepEqual(
+          actualMatches, [
+            'r1234',
+            'Revision 123456',
+          ]);
+    });
+
+    it('replace revision refs plain text', () => {
+      const str = 'r63b72a71d5fbce6739c51c3846dd94bd62b91091';
+      const match = refRegs[0].exec(str);
+      const actualTextRuns = replacer(
+          match, null, null, 'https://crrev.com/{revnum}');
+      refRegs[0].lastIndex = 0;
+      assert.deepEqual(
+          actualTextRuns,
+          [{
+            content: 'r63b72a71d5fbce6739c51c3846dd94bd62b91091',
+            tag: 'a',
+            href: 'https://crrev.com/63b72a71d5fbce6739c51c3846dd94bd62b91091',
+          }]);
+    });
+
+    it('replace revision refs plain text different template', () => {
+      const str = 'r63b72a71d5fbce6739c51c3846dd94bd62b91091';
+      const match = refRegs[0].exec(str);
+      const actualTextRuns = replacer(
+          match, null, null, 'https://foo.bar/{revnum}/baz');
+      refRegs[0].lastIndex = 0;
+      assert.deepEqual(
+          actualTextRuns,
+          [{
+            content: 'r63b72a71d5fbce6739c51c3846dd94bd62b91091',
+            tag: 'a',
+            href: 'https://foo.bar/63b72a71d5fbce6739c51c3846dd94bd62b91091/baz',
+          }]);
+    });
+  });
+
+
+  describe('markupAutolinks tests', () => {
+    const componentRefs = new Map();
+    componentRefs.set('01-tracker-crbug', {
+      openRefs: [],
+      closedRefs: [{projectName: 'chromium', localId: 99}],
+    });
+    componentRefs.set('04-tracker-regular', {
+      openRefs: [{summary: 'monorail', projectName: 'monorail', localId: 123}],
+      closedRefs: [{projectName: 'chromium', localId: 456}],
+    });
+    componentRefs.set('03-user-emails', {
+      users: [{displayName: 'user2@example.com'}],
+    });
+
+    it('empty string does not cause error', () => {
+      const actualTextRuns = markupAutolinks('', componentRefs);
+      assert.deepEqual(actualTextRuns, []);
+    });
+
+    it('no nested autolinking', () => {
+      const plainString = 'test <b>autolinking go/testlink</b> is not nested';
+      const actualTextRuns = markupAutolinks(plainString, componentRefs);
+      assert.deepEqual(
+          actualTextRuns, [
+            {content: 'test '},
+            {content: 'autolinking go/testlink', tag: 'b'},
+            {content: ' is not nested'},
+          ]);
+    });
+
+    it('URLs are autolinked', () => {
+      const plainString = 'this http string contains http://google.com for you';
+      const actualTextRuns = markupAutolinks(plainString, componentRefs);
+      assert.deepEqual(
+          actualTextRuns, [
+            {content: 'this http string contains '},
+            {content: 'http://google.com', tag: 'a', href: 'http://google.com'},
+            {content: ' for you'},
+          ]);
+    });
+
+    it('different component types are correctly linked', () => {
+      const plainString = 'test (User2@example.com and crbug.com/99) get link';
+      const actualTextRuns = markupAutolinks(plainString, componentRefs);
+      assert.deepEqual(
+          actualTextRuns,
+          [
+            {
+              content: 'test (',
+            },
+            {
+              content: 'User2@example.com',
+              tag: 'a',
+              href: '/u/User2@example.com',
+            },
+            {
+              content: ' and ',
+            },
+            {
+              content: 'crbug.com/99',
+              tag: 'a',
+              href: '/p/chromium/issues/detail?id=99',
+              title: '',
+              css: 'strike-through',
+            },
+            {
+              content: ') get link',
+            },
+          ],
+      );
+    });
+
+    it('Invalid issue refs do not get linked', () => {
+      const plainString =
+        'bug123, bug 123a, bug-123 and https://bug:123.example.com ' +
+        'do not get linked.';
+      const actualTextRuns= markupAutolinks(
+          plainString, componentRefs, 'chromium');
+      assert.deepEqual(
+          actualTextRuns,
+          [
+            {
+              content: 'bug123, bug 123a, bug-123 and ',
+            },
+            {
+              content: 'https://bug:123.example.com',
+              tag: 'a',
+              href: 'https://bug:123.example.com',
+            },
+            {
+              content: ' do not get linked.',
+            },
+          ]);
+    });
+
+    it('Only existing issues get linked', () => {
+      const plainString =
+        'only existing bugs = 456, monorail:123, 234 and chromium:345 get ' +
+        'linked';
+      const actualTextRuns = markupAutolinks(
+          plainString, componentRefs, 'chromium');
+      assert.deepEqual(
+          actualTextRuns,
+          [
+            {
+              content: 'only existing ',
+            },
+            {
+              content: 'bugs = ',
+            },
+            {
+              content: '456',
+              tag: 'a',
+              href: '/p/chromium/issues/detail?id=456',
+              title: '',
+              css: 'strike-through',
+            },
+            {
+              content: ', ',
+            },
+            {
+              content: 'monorail:123',
+              tag: 'a',
+              href: '/p/monorail/issues/detail?id=123',
+              title: 'monorail',
+              css: '',
+            },
+            {
+              content: ', ',
+            },
+            {
+              content: '234',
+            },
+            {
+              content: ' and ',
+            },
+            {
+              content: 'chromium:345',
+            },
+            {
+              content: ' ',
+            },
+            {
+              content: 'get linked',
+            },
+          ],
+      );
+    });
+
+    it('multilined bolds are not bolded', () => {
+      const plainString =
+        '<b>no multiline bolding \n' +
+        'not allowed go/survey is still linked</b>';
+      const actualTextRuns = markupAutolinks(plainString, componentRefs);
+      assert.deepEqual(
+          actualTextRuns, [
+            {content: '<b>no multiline bolding '},
+            {tag: 'br'},
+            {content: 'not allowed'},
+            {content: ' '},
+            {content: 'go/survey', tag: 'a', href: 'http://go/survey'},
+            {content: ' is still linked</b>'},
+          ]);
+
+      const plainString2 =
+        '<b>no multiline bold \rwith carriage \r\nreturns</b>';
+      const actualTextRuns2 = markupAutolinks(plainString2, componentRefs);
+
+      assert.deepEqual(
+          actualTextRuns2, [
+            {content: '<b>no multiline bold '},
+            {tag: 'br'},
+            {content: 'with carriage '},
+            {tag: 'br'},
+            {content: 'returns</b>'},
+          ]);
+    });
+
+    // Check that comment references are properly linked.
+    it('comments are correctly linked', () => {
+      const plainString =
+      'comment1, comment : 5, Comment =10, comment #4, #c57';
+      const actualTextRuns = markupAutolinks(plainString, componentRefs);
+      assert.deepEqual(
+          actualTextRuns, [
+            {
+              content: 'comment1',
+              tag: 'a',
+              href: '#c1',
+            },
+            {
+              content: ', ',
+            },
+            {
+              content: 'comment : 5',
+              tag: 'a',
+              href: '#c5',
+            },
+            {
+              content: ', ',
+            },
+            {
+              content: 'Comment =10',
+              tag: 'a',
+              href: '#c10',
+            },
+            {
+              content: ', ',
+            },
+            {
+              content: 'comment #4',
+              tag: 'a',
+              href: '#c4',
+            },
+            {
+              content: ', ',
+            },
+            {
+              content: '#c57',
+              tag: 'a',
+              href: '#c57',
+            },
+          ],
+      );
+    });
+
+    // Check that improperly formatted comment references do not get linked.
+    it('comments that should not be linked', () => {
+      const plainString =
+      'comment number 4, comment-4, comment= # 5, comment#c56';
+      const actualTextRuns = markupAutolinks(plainString, componentRefs);
+      assert.deepEqual(
+          actualTextRuns, [
+            {
+              content: 'comment number 4, comment-4, comment= # 5, comment#c56',
+            },
+          ],
+      );
+    });
+
+    // Check that issue/comment references are properly linked.
+    it('issue/comment that should be linked', () => {
+      const plainString =
+      'issue 2 comment 3, issue2 comment 9, bug #3 comment=4';
+      const actualTextRuns = markupAutolinks(plainString, componentRefs);
+      assert.deepEqual(
+          actualTextRuns, [
+            {
+              content: 'issue 2 comment 3',
+              tag: 'a',
+              href: '?id=2#c3',
+            },
+            {
+              content: ', ',
+            },
+            {
+              content: 'issue2 comment 9',
+              tag: 'a',
+              href: '?id=2#c9',
+            },
+            {
+              content: ', ',
+            },
+            {
+              content: 'bug #3 comment=4',
+              tag: 'a',
+              href: '?id=3#c4',
+            },
+          ],
+      );
+    });
+
+    // Check that improperly formatted issue/comment references do not get linked.
+    it('issue/comment that should not be linked', () => {
+      const plainString =
+      'theissue 2comment 3, issue2comment 9';
+      const actualTextRuns = markupAutolinks(plainString, componentRefs);
+      assert.deepEqual(
+          actualTextRuns, [
+            {
+              content: 'theissue 2comment 3, issue2comment 9',
+            },
+          ],
+      );
+    });
+  });
+
+  describe('getReferencedArtifacts', () => {
+    beforeEach(() => {
+      sinon.stub(prpcClient, 'call').returns(Promise.resolve({}));
+    });
+
+    afterEach(() => {
+      prpcClient.call.restore();
+    });
+
+    it('filters invalid issue refs', async () => {
+      const comments = [
+        {
+          content: 'issue 0 issue 1 Bug: chromium:3 Bug: chromium:0',
+        },
+      ];
+      autolink.getReferencedArtifacts(comments, 'proj');
+      assert.isTrue(prpcClient.call.calledWith(
+          'monorail.Issues',
+          'ListReferencedIssues',
+          {
+            issueRefs: [
+              {projectName: 'proj', localId: '1'},
+              {projectName: 'chromium', localId: '3'},
+            ],
+          },
+      ));
+    });
+  });
+});
diff --git a/static_src/elements/chdir/mr-activity-table/mr-activity-table.js b/static_src/elements/chdir/mr-activity-table/mr-activity-table.js
new file mode 100644
index 0000000..a0f4715
--- /dev/null
+++ b/static_src/elements/chdir/mr-activity-table/mr-activity-table.js
@@ -0,0 +1,129 @@
+// 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 './mr-day-icon.js';
+
+const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
+  'July', 'August', 'September', 'October', 'November', 'December'];
+const WEEKDAY_ABBREVIATIONS = 'M T W T F S S'.split(' ');
+const SECONDS_PER_DAY = 24 * 60 * 60;
+// Only show comments from this many days ago and later.
+const MAX_COMMENT_AGE = 31 * 3;
+
+export class MrActivityTable extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: grid;
+        grid-auto-flow: column;
+        grid-auto-columns: repeat(13, auto);
+        grid-template-rows: repeat(7, auto);
+        margin: auto;
+        width: 90%;
+        text-align: center;
+        line-height: 110%;
+        align-items: center;
+        justify-content: space-between;
+      }
+      :host[hidden] {
+        display: none;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      ${WEEKDAY_ABBREVIATIONS.map((weekday) => html`<span>${weekday}</span>`)}
+      ${this._weekdayOffset.map(() => html`<span></span>`)}
+      ${this._activityArray.map((day) => html`
+        <mr-day-icon
+          .selected=${this.selectedDate === day.date}
+          .commentCount=${day.commentCount}
+          .date=${day.date}
+          @click=${this._selectDay}
+        ></mr-day-icon>
+      `)}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      comments: {type: Array},
+      selectedDate: {type: Number},
+    };
+  }
+
+  _selectDay(event) {
+    const target = event.target;
+    if (this.selectedDate === target.date) {
+      this.selectedDate = undefined;
+    } else {
+      this.selectedDate = target.date;
+    }
+
+    this.dispatchEvent(new CustomEvent('dateChange', {
+      detail: {
+        date: this.selectedDate,
+      },
+    }));
+  }
+
+  get months() {
+    const currentMonth = (new Date()).getMonth();
+    return [MONTH_NAMES[currentMonth],
+      MONTH_NAMES[currentMonth - 1],
+      MONTH_NAMES[currentMonth - 2]];
+  }
+
+  get _weekdayOffset() {
+    const startDate = new Date(this._activityArray[0].date * 1000);
+    const startWeekdayNum = startDate.getDay()-1;
+    const emptyDays = [];
+    for (let i = 0; i < startWeekdayNum; i++) {
+      emptyDays.push(' ');
+    }
+    return emptyDays;
+  }
+
+  get _todayUnixTime() {
+    const now = new Date();
+    const today = new Date(Date.UTC(
+        now.getUTCFullYear(),
+        now.getUTCMonth(),
+        now.getUTCDate(),
+        24, 0, 0));
+    const todayEndTime = today.getTime() / 1000;
+    return todayEndTime;
+  }
+
+  get _activityArray() {
+    const todayUnixEndTime = this._todayUnixTime;
+    const comments = this.comments || [];
+
+    const activityArray = [];
+    for (let i = 0; i < MAX_COMMENT_AGE; i++) {
+      const arrayDate = (todayUnixEndTime - ((i) * SECONDS_PER_DAY));
+      activityArray.unshift({
+        commentCount: 0,
+        date: arrayDate,
+      });
+    }
+
+    for (let i = 0; i < comments.length; i++) {
+      const commentAge = Math.floor(
+          (todayUnixEndTime - comments[i].timestamp) / SECONDS_PER_DAY);
+      if (commentAge < MAX_COMMENT_AGE) {
+        const pos = MAX_COMMENT_AGE - commentAge - 1;
+        activityArray[pos].commentCount++;
+      }
+    }
+
+    return activityArray;
+  }
+}
+customElements.define('mr-activity-table', MrActivityTable);
diff --git a/static_src/elements/chdir/mr-activity-table/mr-activity-table.test.js b/static_src/elements/chdir/mr-activity-table/mr-activity-table.test.js
new file mode 100644
index 0000000..0eb9d30
--- /dev/null
+++ b/static_src/elements/chdir/mr-activity-table/mr-activity-table.test.js
@@ -0,0 +1,57 @@
+// 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 {MrActivityTable} from './mr-activity-table.js';
+import sinon from 'sinon';
+
+const SECONDS_PER_DAY = 24 * 60 * 60;
+
+let element;
+
+describe('mr-activity-table', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-activity-table');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrActivityTable);
+  });
+
+  it('no comments makes empty activity array', () => {
+    element.comments = [];
+
+    for (let i = 0; i < 93; i++) {
+      assert.equal(0, element._activityArray[i].commentCount);
+    }
+  });
+
+  it('activity array handles old comments', () => {
+    // 94 days since EPOCH.
+    sinon.stub(element, '_todayUnixTime').get(() => 94 * SECONDS_PER_DAY);
+
+    element.comments = [
+      {content: 'blah', timestamp: 0}, // too old.
+      {content: 'ignore', timestamp: 100}, // too old.
+      {
+        content: 'comment',
+        timestamp: SECONDS_PER_DAY + 1, // barely young enough.
+      },
+      {content: 'hello', timestamp: SECONDS_PER_DAY + 10}, // same day as above.
+      {content: 'world', timestamp: SECONDS_PER_DAY * 94}, // today
+    ];
+
+    assert.equal(93, element._activityArray.length);
+    assert.equal(2, element._activityArray[0].commentCount);
+    for (let i = 1; i < 92; i++) {
+      assert.equal(0, element._activityArray[i].commentCount);
+    }
+    assert.equal(1, element._activityArray[92].commentCount);
+  });
+});
diff --git a/static_src/elements/chdir/mr-activity-table/mr-day-icon.js b/static_src/elements/chdir/mr-activity-table/mr-day-icon.js
new file mode 100644
index 0000000..82f62b3
--- /dev/null
+++ b/static_src/elements/chdir/mr-activity-table/mr-day-icon.js
@@ -0,0 +1,91 @@
+// 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';
+
+export class MrDayIcon extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        background-color: hsl(0, 0%, 95%);
+        margin: 0.25em 8px;
+        height: 20px;
+        width: 20px;
+        border: 2px solid white;
+        transition: border-color .5s ease-in-out;
+      }
+      :host(:hover) {
+        cursor: pointer;
+        border-color: hsl(87, 20%, 45%);
+      }
+      :host([activityLevel="0"]) {
+        background-color: var(--chops-blue-gray-50);
+      }
+      :host([activityLevel="1"]) {
+        background-color: hsl(87, 70%, 87%);
+      }
+      :host([activityLevel="2"]) {
+        background-color: hsl(88, 67%, 72%);
+      }
+      :host([activityLevel="3"]) {
+        background-color: hsl(87, 80%, 40%);
+      }
+      :host([selected]) {
+        border-color: hsl(0, 0%, 13%);
+      }
+      .hover-card {
+        display: none;
+      }
+      :host(:hover) .hover-card {
+        display: block;
+        position: relative;
+        width: 150px;
+        padding: 0.5em 8px;
+        background: rgba(0, 0, 0, 0.6);
+        color: var(--chops-white);
+        border-radius: 8px;
+        top: 120%;
+        left: 50%;
+        transform: translateX(-50%);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <div class="hover-card">
+        ${this.commentCount} Comments<br>
+        <chops-timestamp .timestamp=${this.date}></chops-timestamp>
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      activityLevel: {
+        type: Number,
+        reflect: true,
+      },
+      commentCount: {type: Number},
+      date: {type: Number},
+      selected: {
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('commentCount')) {
+      const level = Math.ceil(this.commentCount / 2);
+      this.activityLevel = Math.min(level, 3);
+    }
+    super.update(changedProperties);
+  }
+}
+customElements.define('mr-day-icon', MrDayIcon);
diff --git a/static_src/elements/chdir/mr-activity-table/mr-day-icon.test.js b/static_src/elements/chdir/mr-activity-table/mr-day-icon.test.js
new file mode 100644
index 0000000..3c35a10
--- /dev/null
+++ b/static_src/elements/chdir/mr-activity-table/mr-day-icon.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 {MrDayIcon} from './mr-day-icon.js';
+
+
+let element;
+
+describe('mr-day-icon', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-day-icon');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrDayIcon);
+  });
+});
diff --git a/static_src/elements/chdir/mr-comment-table/mr-comment-table.js b/static_src/elements/chdir/mr-comment-table/mr-comment-table.js
new file mode 100644
index 0000000..a6d0f19
--- /dev/null
+++ b/static_src/elements/chdir/mr-comment-table/mr-comment-table.js
@@ -0,0 +1,130 @@
+// 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 'elements/framework/mr-comment-content/mr-comment-content.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+
+/**
+ * `<mr-comment-table>`
+ *
+ * The list of comments for a Monorail Polymer profile.
+ *
+ */
+export class MrCommentTable extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      .ellipsis {
+        max-width: 50%;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        white-space: nowrap;
+      }
+      table {
+        word-wrap: break-word;
+        width: 100%;
+      }
+      tr {
+        font-size: var(--chops-main-font-size);
+        font-weight: normal;
+        text-align: left;
+        line-height: 180%;
+      }
+      td, th {
+        border-bottom: var(--chops-normal-border);
+        padding: 0.25em 16px;
+      }
+      td {
+        text-overflow: ellipsis;
+      }
+      th {
+        text-align: left;
+      }
+      .no-wrap {
+        white-space: nowrap;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    const comments = this._displayedComments(this.selectedDate, this.comments);
+    // TODO(zhangtiff): render deltas for comment changes.
+    return html`
+      <table cellspacing="0" cellpadding="0">
+        <tbody>
+           <tr id="heading-row">
+            <th>Date</th>
+            <th>Project</th>
+            <th>Comment</th>
+            <th>Issue Link</th>
+          </tr>
+
+          ${comments && comments.length ? comments.map((comment) => html`
+            <tr id="row">
+              <td class="no-wrap">
+                <chops-timestamp
+                  .timestamp=${comment.timestamp}
+                  short
+                ></chops-timestamp>
+              </td>
+              <td>${comment.projectName}</td>
+              <td class="ellipsis">
+                <mr-comment-content
+                  .content=${this._truncateMessage(comment.content)}
+                ></mr-comment-content>
+              </td>
+              <td class="no-wrap">
+                <a href="/p/${comment.projectName}/issues/detail?id=${comment.localId}">
+                  Issue ${comment.localId}
+                </a>
+              </td>
+            </tr>
+          `) : html`
+            <tr>
+              <td colspan="4"><i>No comments.</i></td>
+            </tr>
+          `}
+        </tbody>
+      </table>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      comments: {type: Array},
+      selectedDate: {type: Number},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.comments = [];
+  }
+
+  _truncateMessage(message) {
+    return message && message.substring(0, message.indexOf('\n'));
+  }
+
+  _displayedComments(selectedDate, comments) {
+    if (!selectedDate) {
+      return comments;
+    } else {
+      const computedComments = [];
+      if (!comments) return computedComments;
+
+      for (let i = 0; i < comments.length; i++) {
+        if (comments[i].timestamp <= selectedDate &&
+           comments[i].timestamp >= (selectedDate - 86400)) {
+          computedComments.push(comments[i]);
+        }
+      }
+      return computedComments;
+    }
+  }
+}
+customElements.define('mr-comment-table', MrCommentTable);
diff --git a/static_src/elements/chdir/mr-comment-table/mr-comment-table.test.js b/static_src/elements/chdir/mr-comment-table/mr-comment-table.test.js
new file mode 100644
index 0000000..6925dc4
--- /dev/null
+++ b/static_src/elements/chdir/mr-comment-table/mr-comment-table.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 {MrCommentTable} from './mr-comment-table.js';
+
+
+let element;
+
+describe('mr-comment-table', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-comment-table');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCommentTable);
+  });
+});
diff --git a/static_src/elements/chdir/mr-profile-page/mr-profile-page.js b/static_src/elements/chdir/mr-profile-page/mr-profile-page.js
new file mode 100644
index 0000000..5fadff6
--- /dev/null
+++ b/static_src/elements/chdir/mr-profile-page/mr-profile-page.js
@@ -0,0 +1,156 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+
+import 'elements/framework/mr-header/mr-header.js';
+import '../mr-activity-table/mr-activity-table.js';
+import '../mr-comment-table/mr-comment-table.js';
+
+/**
+ * `<mr-profile-page>`
+ *
+ * The main entry point for a Monorail web components profile.
+ *
+ */
+export class MrProfilePage extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      .history-container {
+        padding: 1em 16px;
+        display: flex;
+        flex-direction: column;
+        min-height: 100%;
+        box-sizing: border-box;
+        flex-grow: 1;
+      }
+      mr-comment-table {
+        width: 100%;
+        margin-bottom: 1em;
+        box-sizing: border-box;
+      }
+      mr-activity-table {
+        width: 70%;
+        flex-grow: 0;
+        margin: auto;
+        margin-bottom: 5em;
+        height: 200px;
+        box-sizing: border-box;
+      }
+      .metadata-container {
+        font-size: var(--chops-main-font-size);
+        border-right: var(--chops-normal-border);
+        width: 15%;
+        min-width: 256px;
+        flex-grow: 0;
+        flex-shrink: 0;
+        box-sizing: border-box;
+        min-height: 100%;
+      }
+      .container-outside {
+        box-sizing: border-box;
+        width: 100%;
+        max-width: 100%;
+        margin: auto;
+        padding: 0.75em 8px;
+        display: flex;
+        align-items: stretch;
+        justify-content: space-between;
+        flex-direction: row;
+        flex-wrap: no-wrap;
+        flex-grow: 0;
+        min-height: 100%;
+      }
+      .profile-data {
+        text-align: center;
+        padding-top: 40%;
+        font-size: var(--chops-main-font-size);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-header
+        .userDisplayName=${this.user}
+        .loginUrl=${this.loginUrl}
+        .logoutUrl=${this.logoutUrl}
+      >
+        <span slot="subheader">
+          &gt; Viewing Profile: ${this.viewedUser}
+        </span>
+      </mr-header>
+      <div class="container-outside">
+        <div class="metadata-container">
+          <div class="profile-data">
+            ${this.viewedUser} <br>
+            <b>Last visit:</b> ${this.lastVisitStr} <br>
+            <b>Starred Developers:</b>
+            ${this.starredUsers.length ? this.starredUsers.join(', ') : 'None'}
+          </div>
+        </div>
+        <div class="history-container">
+          ${this.user === this.viewedUser ? html`
+            <mr-activity-table
+              .comments=${this.comments}
+              @dateChange=${this._changeDate}
+            ></mr-activity-table>
+          `: ''}
+          <mr-comment-table
+            .user=${this.viewedUser}
+            .viewedUserId=${this.viewedUserId}
+            .comments=${this.comments}
+            .selectedDate=${this.selectedDate}>
+          </mr-comment-table>
+        </div>
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      user: {type: String},
+      logoutUrl: {type: String},
+      loginUrl: {type: String},
+      viewedUser: {type: String},
+      viewedUserId: {type: Number},
+      lastVisitStr: {type: String},
+      starredUsers: {type: Array},
+      comments: {type: Array},
+      selectedDate: {type: Number},
+    };
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('viewedUserId')) {
+      this._fetchActivity();
+    }
+  }
+
+  async _fetchActivity() {
+    const commentMessage = {
+      userRef: {
+        userId: this.viewedUserId,
+      },
+    };
+
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListActivities', commentMessage
+    );
+
+    this.comments = resp.comments;
+  }
+
+  _changeDate(e) {
+    if (!e.detail) return;
+    this.selectedDate = e.detail.date;
+  }
+}
+
+customElements.define('mr-profile-page', MrProfilePage);
diff --git a/static_src/elements/chdir/mr-profile-page/mr-profile-page.test.js b/static_src/elements/chdir/mr-profile-page/mr-profile-page.test.js
new file mode 100644
index 0000000..c967704
--- /dev/null
+++ b/static_src/elements/chdir/mr-profile-page/mr-profile-page.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 {MrProfilePage} from './mr-profile-page.js';
+
+
+let element;
+
+describe('mr-profile-page', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-profile-page');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrProfilePage);
+  });
+});
diff --git a/static_src/elements/chops/chops-announcement/chops-announcement.js b/static_src/elements/chops/chops-announcement/chops-announcement.js
new file mode 100644
index 0000000..477e7d2
--- /dev/null
+++ b/static_src/elements/chops/chops-announcement/chops-announcement.js
@@ -0,0 +1,181 @@
+// Copyright 2020 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';
+
+// URL where announcements are fetched from.
+const ANNOUNCEMENT_SERVICE =
+  'https://chopsdash.appspot.com/prpc/dashboard.ChopsAnnouncements/SearchAnnouncements';
+
+// Prefix prepended to responses for security reasons.
+export const XSSI_PREFIX = ')]}\'';
+
+const FETCH_HEADERS = Object.freeze({
+  'accept': 'application/json',
+  'content-type': 'application/json',
+});
+
+// How often to refresh announcements.
+export const REFRESH_TIME_MS = 5 * 60 * 1000;
+
+/**
+ * @typedef {Object} Announcement
+ * @property {string} id
+ * @property {string} messageContent
+ */
+
+/**
+ * @typedef {Object} AnnouncementResponse
+ * @property {Array<Announcement>} announcements
+ */
+
+/**
+ * `<chops-announcement>` displays a ChopsDash message when there's an outage
+ * or other important announcement.
+ *
+ * @customElement chops-announcement
+ */
+export class ChopsAnnouncement extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        width: 100%;
+      }
+      p {
+        display: block;
+        color: #222;
+        font-size: 13px;
+        background: #FFCDD2; /* Material design red */
+        width: 100%;
+        text-align: center;
+        padding: 0.5em 16px;
+        box-sizing: border-box;
+        margin: 0;
+        /* Using a red-tinted grey border makes hues feel harmonious. */
+        border-bottom: 1px solid #D6B3B6;
+      }
+    `;
+  }
+  /** @override */
+  render() {
+    if (this._error) {
+      return html`<p><strong>Error: </strong>${this._error}</p>`;
+    }
+    return html`
+      ${this._announcements.map(
+      ({messageContent}) => html`<p>${messageContent}</p>`)}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      service: {type: String},
+      _error: {type: String},
+      _announcements: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {string} */
+    this.service = undefined;
+    /** @type {string} */
+    this._error = undefined;
+    /** @type {Array<Announcement>} */
+    this._announcements = [];
+
+    /** @type {number} Interval ID returned by window.setInterval. */
+    this._interval = undefined;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('service')) {
+      if (this.service) {
+        this.startRefresh();
+      } else {
+        this.stopRefresh();
+      }
+    }
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    this.stopRefresh();
+  }
+
+  /**
+   * Set up autorefreshing logic or announcement information.
+   */
+  startRefresh() {
+    this.stopRefresh();
+    this.refresh();
+    this._interval = window.setInterval(() => this.refresh(), REFRESH_TIME_MS);
+  }
+
+  /**
+   * Logic for clearing refresh behavior.
+   */
+  stopRefresh() {
+    if (this._interval) {
+      window.clearInterval(this._interval);
+    }
+  }
+
+  /**
+   * Refresh the announcement banner.
+   */
+  async refresh() {
+    try {
+      const {announcements = []} = await this.fetch(this.service);
+      this._error = undefined;
+      this._announcements = announcements;
+    } catch (e) {
+      this._error = e.message;
+      this._announcements = [];
+    }
+  }
+
+  /**
+   * Fetches the announcement for a given service.
+   * @param {string} service Name of the service to fetch from ChopsDash.
+   *   ie: "monorail"
+   * @return {Promise<AnnouncementResponse>} ChopsDash response JSON.
+   * @throws {Error} If something went wrong while fetching.
+   */
+  async fetch(service) {
+    const message = {
+      retired: false,
+      platformName: service,
+    };
+
+    const response = await window.fetch(ANNOUNCEMENT_SERVICE, {
+      method: 'POST',
+      headers: FETCH_HEADERS,
+      body: JSON.stringify(message),
+    });
+
+    if (!response.ok) {
+      throw new Error('Something went wrong while fetching announcements');
+    }
+
+    // We can't use response.json() because of the XSSI prefix.
+    const text = await response.text();
+
+    if (!text.startsWith(XSSI_PREFIX)) {
+      throw new Error(`No XSSI prefix in announce response: ${XSSI_PREFIX}`);
+    }
+
+    return JSON.parse(text.substr(XSSI_PREFIX.length));
+  }
+}
+
+customElements.define('chops-announcement', ChopsAnnouncement);
diff --git a/static_src/elements/chops/chops-announcement/chops-announcement.test.js b/static_src/elements/chops/chops-announcement/chops-announcement.test.js
new file mode 100644
index 0000000..fa9643f
--- /dev/null
+++ b/static_src/elements/chops/chops-announcement/chops-announcement.test.js
@@ -0,0 +1,194 @@
+// Copyright 2020 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 {ChopsAnnouncement, REFRESH_TIME_MS,
+  XSSI_PREFIX} from './chops-announcement.js';
+import sinon from 'sinon';
+
+let element;
+let clock;
+
+describe('chops-announcement', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-announcement');
+    document.body.appendChild(element);
+
+    clock = sinon.useFakeTimers({
+      now: new Date(0),
+      shouldAdvanceTime: false,
+    });
+
+    sinon.stub(window, 'fetch');
+  });
+
+  afterEach(() => {
+    if (document.body.contains(element)) {
+      document.body.removeChild(element);
+    }
+
+    clock.restore();
+
+    window.fetch.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsAnnouncement);
+  });
+
+  it('does not request announcements when no service specified', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.fetch);
+  });
+
+  it('requests announcements when service is specified', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('refreshes announcements regularly', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    clock.tick(REFRESH_TIME_MS);
+
+    await element.updateComplete;
+
+    sinon.assert.calledTwice(element.fetch);
+  });
+
+  it('stops refreshing when service removed', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    element.service = '';
+
+    await element.updateComplete;
+    clock.tick(REFRESH_TIME_MS);
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('stops refreshing when element is disconnected', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    document.body.removeChild(element);
+
+    await element.updateComplete;
+    clock.tick(REFRESH_TIME_MS);
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('renders error when thrown', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.throws(() => Error('Something went wrong'));
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.equal(element._error, 'Something went wrong');
+    assert.include(element.shadowRoot.textContent, 'Something went wrong');
+  });
+
+  it('renders fetched announcement', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.returns(
+        {announcements: [{id: '1234', messageContent: 'test thing'}]});
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.deepEqual(element._announcements,
+        [{id: '1234', messageContent: 'test thing'}]);
+    assert.include(element.shadowRoot.textContent, 'test thing');
+  });
+
+  it('renders empty on empty announcement', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.returns({});
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.deepEqual(element._announcements, []);
+    assert.equal(0, element.shadowRoot.children.length);
+  });
+
+  it('fetch returns response data', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = XSSI_PREFIX + JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse));
+
+    const resp = await element.fetch('monorail');
+
+    assert.deepEqual(resp, json);
+  });
+
+  it('fetch errors when no XSSI prefix', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse));
+
+    try {
+      await element.fetch('monorail');
+    } catch (e) {
+      assert.include(e.message, 'No XSSI prefix in announce response:');
+    }
+  });
+
+  it('fetch errors when response is not okay', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = XSSI_PREFIX + JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse, {status: 500}));
+
+    try {
+      await element.fetch('monorail');
+    } catch (e) {
+      assert.include(e.message,
+          'Something went wrong while fetching announcements');
+    }
+  });
+});
diff --git a/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js
new file mode 100644
index 0000000..dab8f85
--- /dev/null
+++ b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js
@@ -0,0 +1,632 @@
+// 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} from 'lit-element';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+
+/**
+ * @type {RegExp} Autocomplete options are matched at word boundaries. This
+ *   Regex specifies what counts as a boundary between words.
+ */
+const DELIMITER_REGEX = /[^a-z0-9]+/i;
+
+/**
+ * Specifies what happens to the input element an autocomplete
+ * instance is attached to when a user selects an autocomplete option. This
+ * constant specifies the default behavior where a form's entire value is
+ * replaced with the selected value.
+ * @param {HTMLInputElement} input An input element.
+ * @param {string} value The value of the selected autocomplete option.
+ */
+const DEFAULT_REPLACER = (input, value) => {
+  input.value = value;
+};
+
+/**
+ * @type {number} The default maximum of completions to render at a time.
+ */
+const DEFAULT_MAX_COMPLETIONS = 200;
+
+/**
+ * @type {number} Globally shared counter for autocomplete instances to help
+ *   ensure that no two <chops-autocomplete> options have the same ID.
+ */
+let idCount = 1;
+
+/**
+ * `<chops-autocomplete>` shared autocomplete UI code that inter-ops with
+ * other code.
+ *
+ * chops-autocomplete inter-ops with any input element, whether custom or
+ * native that can receive change handlers and has a 'value' property which
+ * can be read and set.
+ *
+ * NOTE: This element disables ShadowDOM for accessibility reasons: to allow
+ * aria attributes from the outside to reference features in this element.
+ *
+ * @customElement chops-autocomplete
+ */
+export class ChopsAutocomplete extends LitElement {
+  /** @override */
+  render() {
+    const completions = this.completions;
+    const currentValue = this._prefix.trim().toLowerCase();
+    const index = this._selectedIndex;
+    const currentCompletion = index >= 0 &&
+      index < completions.length ? completions[index] : '';
+
+    return html`
+      <style>
+        /*
+         * Really specific class names are necessary because ShadowDOM
+         * is disabled for this component.
+         */
+        .chops-autocomplete-container {
+          position: relative;
+        }
+        .chops-autocomplete-container table {
+          padding: 0;
+          font-size: var(--chops-main-font-size);
+          color: var(--chops-link-color);
+          position: absolute;
+          background: var(--chops-white);
+          border: var(--chops-accessible-border);
+          z-index: 999;
+          box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
+          border-spacing: 0;
+          border-collapse: collapse;
+          /* In the case when the autocomplete extends the
+           * height of the viewport, we want to make sure
+           * there's spacing. */
+          margin-bottom: 1em;
+        }
+        .chops-autocomplete-container tbody {
+          display: block;
+          min-width: 100px;
+          max-height: 500px;
+          overflow: auto;
+        }
+        .chops-autocomplete-container tr {
+          cursor: pointer;
+          transition: background 0.2s ease-in-out;
+        }
+        .chops-autocomplete-container tr[data-selected] {
+          background: var(--chops-active-choice-bg);
+          text-decoration: underline;
+        }
+        .chops-autocomplete-container td {
+          padding: 0.25em 8px;
+          white-space: nowrap;
+        }
+        .screenreader-hidden {
+          clip: rect(1px, 1px, 1px, 1px);
+          height: 1px;
+          overflow: hidden;
+          position: absolute;
+          white-space: nowrap;
+          width: 1px;
+        }
+      </style>
+      <div class="chops-autocomplete-container">
+        <span class="screenreader-hidden" aria-live="polite">
+          ${currentCompletion}
+        </span>
+        <table
+          ?hidden=${!completions.length}
+        >
+          <tbody>
+            ${completions.map((completion, i) => html`
+              <tr
+                id=${completionId(this.id, i)}
+                ?data-selected=${i === index}
+                data-index=${i}
+                data-value=${completion}
+                @mouseover=${this._hoverCompletion}
+                @mousedown=${this._clickCompletion}
+                role="option"
+                aria-selected=${completion.toLowerCase() ===
+                  currentValue ? 'true' : 'false'}
+              >
+                <td class="completion">
+                  ${this._renderCompletion(completion)}
+                </td>
+                <td class="docstring">
+                  ${this._renderDocstring(completion)}
+                </td>
+              </tr>
+            `)}
+          </tbody>
+        </table>
+      </div>
+    `;
+  }
+
+  /**
+   * Renders a single autocomplete result.
+   * @param {string} completion The string for the currently selected
+   *   autocomplete value.
+   * @return {TemplateResult}
+   */
+  _renderCompletion(completion) {
+    const matchDict = this._matchDict;
+
+    if (!(completion in matchDict)) return completion;
+
+    const {index, matchesDoc} = matchDict[completion];
+
+    if (matchesDoc) return completion;
+
+    const prefix = this._prefix;
+    const start = completion.substr(0, index);
+    const middle = completion.substr(index, prefix.length);
+    const end = completion.substr(index + prefix.length);
+
+    return html`${start}<b>${middle}</b>${end}`;
+  }
+
+  /**
+   * Finds the docstring for a given autocomplete result and renders it.
+   * @param {string} completion The autocomplete result rendered.
+   * @return {TemplateResult}
+   */
+  _renderDocstring(completion) {
+    const matchDict = this._matchDict;
+    const docDict = this.docDict;
+
+    if (!completion in docDict) return '';
+
+    const doc = docDict[completion];
+
+    if (!(completion in matchDict)) return doc;
+
+    const {index, matchesDoc} = matchDict[completion];
+
+    if (!matchesDoc) return doc;
+
+    const prefix = this._prefix;
+    const start = doc.substr(0, index);
+    const middle = doc.substr(index, prefix.length);
+    const end = doc.substr(index + prefix.length);
+
+    return html`${start}<b>${middle}</b>${end}`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * The input this element is for.
+       */
+      for: {type: String},
+      /**
+       * Generated id for the element.
+       */
+      id: {
+        type: String,
+        reflect: true,
+      },
+      /**
+       * The role attribute, set for accessibility.
+       */
+      role: {
+        type: String,
+        reflect: true,
+      },
+      /**
+       * Array of strings for possible autocompletion values.
+       */
+      strings: {type: Array},
+      /**
+       * A dictionary containing optional doc strings for each autocomplete
+       * string.
+       */
+      docDict: {type: Object},
+      /**
+       * An optional function to compute what happens when the user selects
+       * a value.
+       */
+      replacer: {type: Object},
+      /**
+       * An Array of the currently suggested autcomplte values.
+       */
+      completions: {type: Array},
+      /**
+       * Maximum number of completion values that can display at once.
+       */
+      max: {type: Number},
+      /**
+       * Dict of locations of matched substrings. Value format:
+       * {index, matchesDoc}.
+       */
+      _matchDict: {type: Object},
+      _selectedIndex: {type: Number},
+      _prefix: {type: String},
+      _forRef: {type: Object},
+      _boundToggleCompletionsOnFocus: {type: Object},
+      _boundNavigateCompletions: {type: Object},
+      _boundUpdateCompletions: {type: Object},
+      _oldAttributes: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.strings = [];
+    this.docDict = {};
+    this.completions = [];
+    this.max = DEFAULT_MAX_COMPLETIONS;
+
+    this.role = 'listbox';
+    this.id = `chops-autocomplete-${idCount++}`;
+
+    this._matchDict = {};
+    this._selectedIndex = -1;
+    this._prefix = '';
+    this._boundToggleCompletionsOnFocus =
+      this._toggleCompletionsOnFocus.bind(this);
+    this._boundUpdateCompletions = this._updateCompletions.bind(this);
+    this._boundNavigateCompletions = this._navigateCompletions.bind(this);
+    this._oldAttributes = {};
+  }
+
+  // Disable shadow DOM to allow aria attributes to propagate.
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    this._disconnectAutocomplete(this._forRef);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('for')) {
+      const forRef = this.getRootNode().querySelector('#' + this.for);
+
+      // TODO(zhangtiff): Make this element work with custom input components
+      // in the future as well.
+      this._forRef = (forRef.tagName || '').toUpperCase() === 'INPUT' ?
+        forRef : undefined;
+      this._connectAutocomplete(this._forRef);
+    }
+    if (this._forRef) {
+      if (changedProperties.has('id')) {
+        this._forRef.setAttribute('aria-owns', this.id);
+      }
+      if (changedProperties.has('completions')) {
+        // a11y. Tell screenreaders whether the autocomplete is expanded.
+        this._forRef.setAttribute('aria-expanded',
+          this.completions.length ? 'true' : 'false');
+      }
+
+      if (changedProperties.has('_selectedIndex') ||
+          changedProperties.has('completions')) {
+        this._updateAriaActiveDescendant(this._forRef);
+
+        this._scrollCompletionIntoView(this._selectedIndex);
+      }
+    }
+  }
+
+  /**
+   * Sets the aria-activedescendant attribute of the element (ie: an input form)
+   * that the autocomplete is attached to, in order to tell screenreaders about
+   * which autocomplete option is currently selected.
+   * @param {HTMLInputElement} element
+   */
+  _updateAriaActiveDescendant(element) {
+    const i = this._selectedIndex;
+
+    if (i >= 0 && i < this.completions.length) {
+      const selectedId = completionId(this.id, i);
+
+      // a11y. Set the ID of the currently selected element.
+      element.setAttribute('aria-activedescendant', selectedId);
+
+      // Scroll the container to make sure the selected element is in view.
+    } else {
+      element.setAttribute('aria-activedescendant', '');
+    }
+  }
+
+  /**
+   * When a user moves up or down from an autocomplete option that's at the top
+   * or bottom of the autocomplete option container, we must scroll the
+   * container to make sure the user always sees the option they've selected.
+   * @param {number} i The index of the autocomplete option to put into view.
+   */
+  _scrollCompletionIntoView(i) {
+    const selectedId = completionId(this.id, i);
+
+    const container = this.querySelector('tbody');
+    const completion = this.querySelector(`#${selectedId}`);
+
+    if (!completion) return;
+
+    const distanceFromTop = completion.offsetTop - container.scrollTop;
+
+    // If the completion is above the viewport for the container.
+    if (distanceFromTop < 0) {
+      // Position the completion at the top of the container.
+      container.scrollTop = completion.offsetTop;
+    }
+
+    // If the compltion is below the viewport for the container.
+    if (distanceFromTop > (container.offsetHeight - completion.offsetHeight)) {
+      // Position the compltion at the bottom of the container.
+      container.scrollTop = completion.offsetTop - (container.offsetHeight -
+        completion.offsetHeight);
+    }
+  }
+
+  /**
+   * Changes the input's value according to the rules of the replacer function.
+   * @param {string} value - the value to swap in.
+   * @return {undefined}
+   */
+  completeValue(value) {
+    if (!this._forRef) return;
+
+    const replacer = this.replacer || DEFAULT_REPLACER;
+    replacer(this._forRef, value);
+
+    this.hideCompletions();
+  }
+
+  /**
+   * Computes autocomplete values matching the current input in the field.
+   * @return {boolean} Whether any completions were found.
+   */
+  showCompletions() {
+    if (!this._forRef) {
+      this.hideCompletions();
+      return false;
+    }
+    this._prefix = this._forRef.value.trim().toLowerCase();
+    // Always select the first completion by default when recomputing
+    // completions.
+    this._selectedIndex = 0;
+
+    const matchDict = {};
+    const accepted = [];
+    matchDict;
+    for (let i = 0; i < this.strings.length &&
+        accepted.length < this.max; i++) {
+      const s = this.strings[i];
+      let matchIndex = this._matchIndex(this._prefix, s);
+      let matches = matchIndex >= 0;
+      if (matches) {
+        matchDict[s] = {index: matchIndex, matchesDoc: false};
+      } else if (s in this.docDict) {
+        matchIndex = this._matchIndex(this._prefix, this.docDict[s]);
+        matches = matchIndex >= 0;
+        if (matches) {
+          matchDict[s] = {index: matchIndex, matchesDoc: true};
+        }
+      }
+      if (matches) {
+        accepted.push(s);
+      }
+    }
+
+    this._matchDict = matchDict;
+
+    this.completions = accepted;
+
+    return !!this.completions.length;
+  }
+
+  /**
+   * Finds where a given user input matches an autocomplete option. Note that
+   * a match is only found if the substring is at either the beginning of the
+   * string or the beginning of a delimited section of the string. Hence, we
+   * refer to the "needle" in this function a "prefix".
+   * @param {string} prefix The value that the user inputed into the form.
+   * @param {string} s The autocomplete option that's being compared.
+   * @return {number} An integer for what index the substring is found in the
+   *   autocomplete option. Returns -1 if no match.
+   */
+  _matchIndex(prefix, s) {
+    const matchStart = s.toLowerCase().indexOf(prefix.toLocaleLowerCase());
+    if (matchStart === 0 ||
+        (matchStart > 0 && s[matchStart - 1].match(DELIMITER_REGEX))) {
+      return matchStart;
+    }
+    return -1;
+  }
+
+  /**
+   * Hides autocomplete options.
+   */
+  hideCompletions() {
+    this.completions = [];
+    this._prefix = '';
+    this._selectedIndex = -1;
+  }
+
+  /**
+   * Sets an autocomplete option that a user hovers over as the selected option.
+   * @param {MouseEvent} e
+   */
+  _hoverCompletion(e) {
+    const target = e.currentTarget;
+
+    if (!target.dataset || !target.dataset.index) return;
+
+    const index = Number.parseInt(target.dataset.index);
+    if (index >= 0 && index < this.completions.length) {
+      this._selectedIndex = index;
+    }
+  }
+
+  /**
+   * Sets the value of the form input that the user is editing to the
+   * autocomplete option that the user just clicked.
+   * @param {MouseEvent} e
+   */
+  _clickCompletion(e) {
+    e.preventDefault();
+    const target = e.currentTarget;
+    if (!target.dataset || !target.dataset.value) return;
+
+    this.completeValue(target.dataset.value);
+  }
+
+  /**
+   * Hides and shows the autocomplete completions when a user focuses and
+   * unfocuses a form.
+   * @param {FocusEvent} e
+   */
+  _toggleCompletionsOnFocus(e) {
+    const target = e.target;
+
+    // Check if the input is focused or not.
+    if (target.matches(':focus')) {
+      this.showCompletions();
+    } else {
+      this.hideCompletions();
+    }
+  }
+
+  /**
+   * Implements hotkeys to allow the user to navigate autocomplete options with
+   * their keyboard. ie: pressing up and down to select options or Esc to close
+   * the form.
+   * @param {KeyboardEvent} e
+   */
+  _navigateCompletions(e) {
+    const completions = this.completions;
+    if (!completions.length) return;
+
+    switch (e.key) {
+      // TODO(zhangtiff): Throttle or control keyboard navigation so the user
+      // can't navigate faster than they can can perceive.
+      case 'ArrowUp':
+        e.preventDefault();
+        this._navigateUp();
+        break;
+      case 'ArrowDown':
+        e.preventDefault();
+        this._navigateDown();
+        break;
+      case 'Enter':
+      // TODO(zhangtiff): Add Tab to this case as well once all issue detail
+      // inputs use chops-autocomplete.
+        e.preventDefault();
+        if (this._selectedIndex >= 0 &&
+            this._selectedIndex <= completions.length) {
+          this.completeValue(completions[this._selectedIndex]);
+        }
+        break;
+      case 'Escape':
+        e.preventDefault();
+        this.hideCompletions();
+        break;
+    }
+  }
+
+  /**
+   * Selects the completion option above the current one.
+   */
+  _navigateUp() {
+    const completions = this.completions;
+    this._selectedIndex -= 1;
+    if (this._selectedIndex < 0) {
+      this._selectedIndex = completions.length - 1;
+    }
+  }
+
+  /**
+   * Selects the completion option below the current one.
+   */
+  _navigateDown() {
+    const completions = this.completions;
+    this._selectedIndex += 1;
+    if (this._selectedIndex >= completions.length) {
+      this._selectedIndex = 0;
+    }
+  }
+
+  /**
+   * Recomputes autocomplete completions when the user types a new input.
+   * Ignores KeyboardEvents that don't change the input value of the form
+   * to prevent excess recomputations.
+   * @param {KeyboardEvent} e
+   */
+  _updateCompletions(e) {
+    if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+    this.showCompletions();
+  }
+
+  /**
+   * Initializes the input element that this autocomplete instance is
+   * attached to with aria attributes required for accessibility.
+   * @param {HTMLInputElement} node The input element that the autocomplete is
+   *   attached to.
+   */
+  _connectAutocomplete(node) {
+    if (!node) return;
+
+    node.addEventListener('keyup', this._boundUpdateCompletions);
+    node.addEventListener('keydown', this._boundNavigateCompletions);
+    node.addEventListener('focus', this._boundToggleCompletionsOnFocus);
+    node.addEventListener('blur', this._boundToggleCompletionsOnFocus);
+
+    this._oldAttributes = {
+      'aria-owns': node.getAttribute('aria-owns'),
+      'aria-autocomplete': node.getAttribute('aria-autocomplete'),
+      'aria-expanded': node.getAttribute('aria-expanded'),
+      'aria-haspopup': node.getAttribute('aria-haspopup'),
+      'aria-activedescendant': node.getAttribute('aria-activedescendant'),
+    };
+    node.setAttribute('aria-owns', this.id);
+    node.setAttribute('aria-autocomplete', 'both');
+    node.setAttribute('aria-expanded', 'false');
+    node.setAttribute('aria-haspopup', 'listbox');
+    node.setAttribute('aria-activedescendant', '');
+  }
+
+  /**
+   * When <chops-autocomplete> is disconnected or moved to a difference form,
+   * this function removes the side effects added by <chops-autocomplete> on the
+   * input element that <chops-autocomplete> is attached to.
+   * @param {HTMLInputElement} node The input element that the autocomplete is
+   *   attached to.
+   */
+  _disconnectAutocomplete(node) {
+    if (!node) return;
+
+    node.removeEventListener('keyup', this._boundUpdateCompletions);
+    node.removeEventListener('keydown', this._boundNavigateCompletions);
+    node.removeEventListener('focus', this._boundToggleCompletionsOnFocus);
+    node.removeEventListener('blur', this._boundToggleCompletionsOnFocus);
+
+    for (const key of Object.keys(this._oldAttributes)) {
+      node.setAttribute(key, this._oldAttributes[key]);
+    }
+    this._oldAttributes = {};
+  }
+}
+
+/**
+ * Generates a unique HTML ID for a given autocomplete option, for use by
+ * aria-activedescendant. Note that because the autocomplete element has
+ * ShadowDOM disabled, we need to make sure the ID is specific enough to be
+ * globally unique across the entire application.
+ * @param {string} prefix A unique prefix to differentiate this autocomplete
+ *   instance from other autocomplete instances.
+ * @param {number} i The index of the autocomplete option.
+ * @return {string} A unique HTML ID for a given autocomplete option.
+ */
+function completionId(prefix, i) {
+  return `${prefix}-option-${i}`;
+}
+
+customElements.define('chops-autocomplete', ChopsAutocomplete);
diff --git a/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js
new file mode 100644
index 0000000..e470312
--- /dev/null
+++ b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js
@@ -0,0 +1,358 @@
+// 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 {ChopsAutocomplete} from './chops-autocomplete.js';
+
+let element;
+let input;
+
+describe('chops-autocomplete', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-autocomplete');
+    document.body.appendChild(element);
+
+    input = document.createElement('input');
+    input.id = 'autocomplete-input';
+    document.body.appendChild(input);
+
+    element.for = 'autocomplete-input';
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    document.body.removeChild(input);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsAutocomplete);
+  });
+
+  it('registers child input', async () => {
+    await element.updateComplete;
+
+    assert.isNotNull(element._forRef);
+    assert.equal(element._forRef.tagName.toUpperCase(), 'INPUT');
+  });
+
+  it('completeValue sets input value', async () => {
+    await element.updateComplete;
+
+    element.completeValue('test');
+    assert.equal(input.value, 'test');
+
+    element.completeValue('again');
+    assert.equal(input.value, 'again');
+  });
+
+  it('completeValue can run a custom replacer', async () => {
+    element.replacer = (input, value) => input.value = value + ',';
+    await element.updateComplete;
+
+    element.completeValue('trailing');
+    assert.equal(input.value, 'trailing,');
+
+    element.completeValue('comma');
+    assert.equal(input.value, 'comma,');
+  });
+
+  it('completions render', async () => {
+    element.completions = ['hello', 'world'];
+    element.docDict = {'hello': 'well hello there'};
+    await element.updateComplete;
+
+    const completions = element.querySelectorAll('.completion');
+    const docstrings = element.querySelectorAll('.docstring');
+
+    assert.equal(completions.length, 2);
+    assert.equal(docstrings.length, 2);
+
+    assert.include(completions[0].textContent, 'hello');
+    assert.include(completions[1].textContent, 'world');
+
+    assert.include(docstrings[0].textContent, 'well hello there');
+    assert.include(docstrings[1].textContent, '');
+  });
+
+  it('completions bold matched section when rendering', async () => {
+    element.completions = ['hello-world'];
+    element._prefix = 'wor';
+    element._matchDict = {
+      'hello-world': {'index': 6},
+    };
+
+    await element.updateComplete;
+
+    const completion = element.querySelector('.completion');
+
+    assert.include(completion.textContent, 'hello-world');
+
+    assert.equal(completion.querySelector('b').textContent.trim(), 'wor');
+  });
+
+
+  it('showCompletions populates completions with matches', async () => {
+    element.strings = [
+      'test-one',
+      'test-two',
+      'ignore',
+      'hello',
+      'woah-test',
+      'i-am-a-tester',
+    ];
+    input.value = 'test';
+    await element.updateComplete;
+
+    element.showCompletions();
+
+    assert.deepEqual(element.completions, [
+      'test-one',
+      'test-two',
+      'woah-test',
+      'i-am-a-tester',
+    ]);
+  });
+
+  it('showCompletions matches docs', async () => {
+    element.strings = [
+      'hello',
+      'world',
+      'no-op',
+    ];
+    element.docDict = {'world': 'this is a test'};
+    input.value = 'test';
+    await element.updateComplete;
+
+    element.showCompletions();
+
+    assert.deepEqual(element.completions, [
+      'world',
+    ]);
+  });
+
+  it('showCompletions caps completions at max', async () => {
+    element.max = 2;
+    element.strings = [
+      'test-one',
+      'test-two',
+      'ignore',
+      'hello',
+      'woah-test',
+      'i-am-a-tester',
+    ];
+    input.value = 'test';
+    await element.updateComplete;
+
+    element.showCompletions();
+
+    assert.deepEqual(element.completions, [
+      'test-one',
+      'test-two',
+    ]);
+  });
+
+  it('hideCompletions hides completions', async () => {
+    element.completions = [
+      'test-one',
+      'test-two',
+    ];
+
+    await element.updateComplete;
+
+    const completionTable = element.querySelector('table');
+    assert.isFalse(completionTable.hidden);
+
+    element.hideCompletions();
+
+    await element.updateComplete;
+
+    assert.isTrue(completionTable.hidden);
+  });
+
+  it('clicking completion completes it', async () => {
+    element.completions = [
+      'test-one',
+      'test-two',
+      'click me!',
+      'test',
+    ];
+
+    await element.updateComplete;
+
+    const completions = element.querySelectorAll('tr');
+
+    assert.equal(input.value, '');
+
+    // Note: the click() event can only trigger click events, not mousedown
+    // events, so we are instead manually running the event handler.
+    element._clickCompletion({
+      preventDefault: sinon.stub(),
+      currentTarget: completions[2],
+    });
+
+    assert.equal(input.value, 'click me!');
+  });
+
+  it('completion is scrolled into view when outside viewport', async () => {
+    element.completions = [
+      'i',
+      'am',
+      'an option',
+    ];
+    element._selectedIndex = 0;
+    element.id = 'chops-autocomplete-1';
+
+    await element.updateComplete;
+
+    const container = element.querySelector('tbody');
+    const completion = container.querySelector('tr');
+    const completionHeight = completion.offsetHeight;
+    // Make the table one row tall.
+    container.style.height = `${completionHeight}px`;
+
+    element._selectedIndex = 1;
+    await element.updateComplete;
+
+    assert.equal(container.scrollTop, completionHeight);
+
+    element._selectedIndex = 2;
+    await element.updateComplete;
+
+    assert.equal(container.scrollTop, completionHeight * 2);
+
+    element._selectedIndex = 0;
+    await element.updateComplete;
+
+    assert.equal(container.scrollTop, 0);
+  });
+
+  it('aria-activedescendant set based on selected option', async () => {
+    element.completions = [
+      'i',
+      'am',
+      'an option',
+    ];
+    element._selectedIndex = 1;
+    element.id = 'chops-autocomplete-1';
+
+    await element.updateComplete;
+
+    assert.equal(input.getAttribute('aria-activedescendant'),
+        'chops-autocomplete-1-option-1');
+  });
+
+  it('hovering over a completion selects it', async () => {
+    element.completions = [
+      'hover',
+      'over',
+      'me',
+    ];
+
+    await element.updateComplete;
+
+    const completions = element.querySelectorAll('tr');
+
+    element._hoverCompletion({
+      currentTarget: completions[2],
+    });
+
+    assert.equal(element._selectedIndex, 2);
+
+    element._hoverCompletion({
+      currentTarget: completions[1],
+    });
+
+    assert.equal(element._selectedIndex, 1);
+  });
+
+  it('ArrowDown moves through completions', async () => {
+    element.completions = [
+      'move',
+      'down',
+      'me',
+    ];
+
+    element._selectedIndex = 0;
+
+    await element.updateComplete;
+
+    const preventDefault = sinon.stub();
+
+    element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+    assert.equal(element._selectedIndex, 1);
+
+    element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+    assert.equal(element._selectedIndex, 2);
+
+    // Wrap around.
+    element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+    assert.equal(element._selectedIndex, 0);
+
+    sinon.assert.callCount(preventDefault, 3);
+  });
+
+  it('ArrowUp moves through completions', async () => {
+    element.completions = [
+      'move',
+      'up',
+      'me',
+    ];
+
+    element._selectedIndex = 0;
+
+    await element.updateComplete;
+
+    const preventDefault = sinon.stub();
+
+    // Wrap around.
+    element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+    assert.equal(element._selectedIndex, 2);
+
+    element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+    assert.equal(element._selectedIndex, 1);
+
+    element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+    assert.equal(element._selectedIndex, 0);
+
+    sinon.assert.callCount(preventDefault, 3);
+  });
+
+  it('Enter completes with selected completion', async () => {
+    element.completions = [
+      'hello',
+      'pick me',
+      'world',
+    ];
+
+    element._selectedIndex = 1;
+
+    await element.updateComplete;
+
+    const preventDefault = sinon.stub();
+
+    element._navigateCompletions({preventDefault, key: 'Enter'});
+
+    assert.equal(input.value, 'pick me');
+    sinon.assert.callCount(preventDefault, 1);
+  });
+
+  it('Escape hides completions', async () => {
+    element.completions = [
+      'hide',
+      'me',
+    ];
+
+    await element.updateComplete;
+
+    const preventDefault = sinon.stub();
+    element._navigateCompletions({preventDefault, key: 'Escape'});
+
+    sinon.assert.callCount(preventDefault, 1);
+
+    await element.updateComplete;
+
+    assert.equal(element.completions.length, 0);
+  });
+});
diff --git a/static_src/elements/chops/chops-button/chops-button.js b/static_src/elements/chops/chops-button/chops-button.js
new file mode 100644
index 0000000..2139e22
--- /dev/null
+++ b/static_src/elements/chops/chops-button/chops-button.js
@@ -0,0 +1,112 @@
+// 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-button>` displays a styled button component with a few niceties.
+ *
+ * @customElement chops-button
+ * @demo /demo/chops-button_demo.html
+ */
+export class ChopsButton extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --chops-button-padding: 0.5em 16px;
+        background: hsla(0, 0%, 95%, 1);
+        margin: 0.25em 4px;
+        cursor: pointer;
+        border-radius: 3px;
+        text-align: center;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        user-select: none;
+        transition: filter 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
+        font-family: var(--chops-font-family);
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      :host([raised]) {
+        box-shadow: 0px 2px 8px -1px hsla(0, 0%, 0%, 0.5);
+      }
+      :host(:hover) {
+        filter: brightness(95%);
+      }
+      :host(:active) {
+        filter: brightness(115%);
+      }
+      :host([raised]:active) {
+        box-shadow: 0px 1px 8px -1px hsla(0, 0%, 0%, 0.5);
+      }
+      :host([disabled]),
+      :host([disabled]:hover) {
+        filter: grayscale(30%);
+        opacity: 0.4;
+        background: hsla(0, 0%, 87%, 1);
+        cursor: default;
+        pointer-events: none;
+        box-shadow: none;
+      }
+      button {
+        background: none;
+        width: 100%;
+        height: 100%;
+        border: 0;
+        padding: var(--chops-button-padding);
+        margin: 0;
+        color: inherit;
+        cursor: inherit;
+        text-align: center;
+        font-family: inherit;
+        text-align: inherit;
+        font-weight: inherit;
+        font-size: inherit;
+        line-height: inherit;
+        border-radius: inherit;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <button ?disabled=${this.disabled}>
+        <slot></slot>
+      </button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /** Whether the button is available for input or not. */
+      disabled: {
+        type: Boolean,
+        reflect: true,
+      },
+      /** Whether the button should have a shadow or not. */
+      raised: {
+        type: Boolean,
+        value: false,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.disabled = false;
+    this.raised = false;
+  }
+}
+customElements.define('chops-button', ChopsButton);
diff --git a/static_src/elements/chops/chops-button/chops-button.test.js b/static_src/elements/chops/chops-button/chops-button.test.js
new file mode 100644
index 0000000..4487564
--- /dev/null
+++ b/static_src/elements/chops/chops-button/chops-button.test.js
@@ -0,0 +1,45 @@
+// 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 {ChopsButton} from './chops-button.js';
+import {auditA11y} from 'shared/test/helpers';
+
+let element;
+
+describe('chops-button', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-button');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsButton);
+  });
+
+  it('initial a11y', async () => {
+    const text = document.createTextNode('button text');
+    element.appendChild(text);
+    await auditA11y(element);
+  });
+
+  it('chops-button can be disabled', async () => {
+    await element.updateComplete;
+
+    const innerButton = element.shadowRoot.querySelector('button');
+
+    assert.isFalse(element.hasAttribute('disabled'));
+    assert.isFalse(innerButton.hasAttribute('disabled'));
+
+    element.disabled = true;
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('disabled'));
+    assert.isTrue(innerButton.hasAttribute('disabled'));
+  });
+});
diff --git a/static_src/elements/chops/chops-checkbox/chops-checkbox.js b/static_src/elements/chops/chops-checkbox/chops-checkbox.js
new file mode 100644
index 0000000..d752347
--- /dev/null
+++ b/static_src/elements/chops/chops-checkbox/chops-checkbox.js
@@ -0,0 +1,135 @@
+// 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-checkbox>`
+ *
+ * A checkbox component. This component is primarily a wrapper
+ * around a native checkbox to allow easy sharing of styles.
+ *
+ */
+export class ChopsCheckbox extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --chops-checkbox-color: var(--chops-primary-accent-color);
+        /* A bit brighter than Chrome's default focus color to
+        * avoid blending into the checkbox's blue. */
+        --chops-checkbox-focus-color: hsl(193, 82%, 63%);
+        --chops-checkbox-size: 16px;
+        --chops-checkbox-check-size: 18px;
+      }
+      label {
+        cursor: pointer;
+        display: inline-flex;
+        align-items: center;
+      }
+      input[type="checkbox"] {
+        /* We need the checkbox to be hidden but still accessible. */
+        opacity: 0;
+        width: 0;
+        height: 0;
+        position: absolute;
+        top: -9999;
+        left: -9999;
+      }
+      label::before {
+        width: var(--chops-checkbox-size);
+        height: var(--chops-checkbox-size);
+        margin-right: 8px;
+        box-sizing: border-box;
+        content: "\\2713";
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        border: 2px solid #222;
+        border-radius: 2px;
+        background: #fff;
+        font-size: var(--chops-checkbox-check-size);
+        padding: 0;
+        color: transparent;
+      }
+      input[type="checkbox"]:focus + label::before {
+        /* Make sure an outline shows around this element for
+        * accessibility.
+        */
+        box-shadow: 0 0 5px 1px var(--chops-checkbox-focus-color);
+      }
+      input[type="checkbox"]:checked + label::before {
+        background: var(--chops-checkbox-color);
+        border-color: var(--chops-checkbox-color);
+        color: #fff;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <!-- Note: Avoiding 2-way data binding to futureproof this code
+        for LitElement. -->
+      <input id="checkbox" type="checkbox"
+        .checked=${this.checked} @change=${this._checkedChangeHandler}>
+      <label for="checkbox">
+        <slot></slot>
+      </label>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      label: {type: String},
+
+      /**
+       * Note: At the moment, this component does not manage its own
+       * internal checked state. It expects its checked state to come
+       * from its parent, and its parent is expected to update the
+       * chops-checkbox's checked state on a change event.
+       *
+       * This can be generalized in the future to support multiple
+       * ways of managing checked state if needed.
+       **/
+      checked: {type: Boolean},
+    };
+  }
+
+  /**
+   * Clicks the checkbox. Helpful for automated testing.
+   */
+  click() {
+    super.click();
+    /** @type {HTMLInputElement} */ (
+      this.shadowRoot.querySelector('#checkbox')).click();
+  }
+
+  /**
+   * Listens to the native checkbox's change event and runs internal
+   * logic based on changes.
+   * @param {Event} evt
+   * @private
+   */
+  _checkedChangeHandler(evt) {
+    this._checkedChange(evt.target.checked);
+  }
+
+  /**
+   * @param {boolean} checked Whether the box was checked or unchecked.
+   * @fires CustomEvent#checked-change
+   * @private
+   */
+  _checkedChange(checked) {
+    if (checked === this.checked) return;
+    const customEvent = new CustomEvent('checked-change', {
+      detail: {
+        checked: checked,
+      },
+    });
+    this.dispatchEvent(customEvent);
+  }
+}
+customElements.define('chops-checkbox', ChopsCheckbox);
diff --git a/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js b/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js
new file mode 100644
index 0000000..5a11111
--- /dev/null
+++ b/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js
@@ -0,0 +1,86 @@
+// 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 {ChopsCheckbox} from './chops-checkbox.js';
+
+let element;
+
+describe('chops-checkbox', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-checkbox');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsCheckbox);
+  });
+
+  it('clicking checkbox dispatches checked-change event', async () => {
+    element.checked = false;
+    sinon.stub(window, 'CustomEvent');
+    sinon.stub(element, 'dispatchEvent');
+
+    await element.updateComplete;
+
+    element.shadowRoot.querySelector('#checkbox').click();
+
+    assert.deepEqual(window.CustomEvent.args[0][0], 'checked-change');
+    assert.deepEqual(window.CustomEvent.args[0][1], {
+      detail: {checked: true},
+    });
+
+    assert.isTrue(window.CustomEvent.calledOnce);
+    assert.isTrue(element.dispatchEvent.calledOnce);
+
+    window.CustomEvent.restore();
+    element.dispatchEvent.restore();
+  });
+
+  it('updating checked property updates native <input>', async () => {
+    element.checked = false;
+
+    await element.updateComplete;
+
+    assert.isFalse(element.checked);
+    assert.isFalse(element.shadowRoot.querySelector('input').checked);
+
+    element.checked = true;
+
+    await element.updateComplete;
+
+    assert.isTrue(element.checked);
+    assert.isTrue(element.shadowRoot.querySelector('input').checked);
+  });
+
+  it('updating checked attribute updates native <input>', async () => {
+    element.setAttribute('checked', true);
+    await element.updateComplete;
+
+    assert.equal(element.getAttribute('checked'), 'true');
+    assert.isTrue(element.shadowRoot.querySelector('input').checked);
+
+    element.click();
+    await element.updateComplete;
+
+    // We expect the 'checked' attribute to remain the same even as the
+    // corresponding property changes when the user clicks the checkbox.
+    assert.equal(element.getAttribute('checked'), 'true');
+    assert.isFalse(element.shadowRoot.querySelector('input').checked);
+
+    element.click();
+    await element.updateComplete;
+    assert.isTrue(element.shadowRoot.querySelector('input').checked);
+
+    element.removeAttribute('checked');
+    await element.updateComplete;
+    assert.isNotTrue(element.getAttribute('checked'));
+    assert.isFalse(element.shadowRoot.querySelector('input').checked);
+  });
+});
diff --git a/static_src/elements/chops/chops-chip/chops-chip.js b/static_src/elements/chops/chops-chip/chops-chip.js
new file mode 100644
index 0000000..ce8319e
--- /dev/null
+++ b/static_src/elements/chops/chops-chip/chops-chip.js
@@ -0,0 +1,122 @@
+// 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-chip>` displays a chip.
+ * "Chips are compact elements that represent an input, attribute, or action."
+ * https://material.io/components/chips/
+ */
+export class ChopsChip extends LitElement {
+  /** @override */
+  static get properties() {
+    return {
+      focusable: {type: Boolean, reflect: true},
+      thumbnail: {type: String},
+      buttonIcon: {type: String},
+      buttonLabel: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {boolean} */
+    this.focusable = false;
+
+    /** @type {string} */
+    this.thumbnail = '';
+
+    /** @type {string} */
+    this.buttonIcon = '';
+    /** @type {string} */
+    this.buttonLabel = '';
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --chops-chip-bg-color: var(--chops-blue-gray-50);
+        display: inline-flex;
+        padding: 0px 10px;
+        line-height: 22px;
+        margin: 0 2px;
+        border-radius: 12px;
+        background: var(--chops-chip-bg-color);
+        align-items: center;
+        font-size: var(--chops-main-font-size);
+        box-sizing: border-box;
+        border: 1px solid var(--chops-chip-bg-color);
+      }
+      :host(:focus), :host(.selected) {
+        background: var(--chops-active-choice-bg);
+        border: 1px solid var(--chops-light-accent-color);
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      i.left {
+        margin: 0 4px 0 -6px;
+      }
+      button {
+        border-radius: 50%;
+        cursor: pointer;
+        background: none;
+        border: 0;
+        padding: 0;
+        margin: 0 -6px 0 4px;
+        display: inline-flex;
+        align-items: center;
+        transition: background-color 0.2s ease-in-out;
+      }
+      button[hidden] {
+        display: none;
+      }
+      button:hover {
+        background: var(--chops-gray-300);
+      }
+      i.material-icons {
+        color: var(--chops-primary-icon-color);
+        font-size: 14px;
+        user-select: none;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      ${this.thumbnail ? html`
+        <i class="material-icons left">${this.thumbnail}</i>
+      ` : ''}
+      <slot></slot>
+      ${this.buttonIcon ? html`
+        <button @click=${this.clickButton} aria-label=${this.buttonLabel}>
+          <i class="material-icons" aria-hidden="true"}>${this.buttonIcon}</i>
+        </button>
+      `: ''}
+    `;
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('focusable')) {
+      this.tabIndex = this.focusable ? '0' : undefined;
+    }
+    super.update(changedProperties);
+  }
+
+  /**
+   * @param {MouseEvent} e A click event.
+   * @fires CustomEvent#click-button
+   */
+  clickButton(e) {
+    this.dispatchEvent(new CustomEvent('click-button'));
+  }
+}
+customElements.define('chops-chip', ChopsChip);
diff --git a/static_src/elements/chops/chops-chip/chops-chip.test.js b/static_src/elements/chops/chops-chip/chops-chip.test.js
new file mode 100644
index 0000000..843000b
--- /dev/null
+++ b/static_src/elements/chops/chops-chip/chops-chip.test.js
@@ -0,0 +1,52 @@
+// 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 {ChopsChip} from './chops-chip.js';
+
+let element;
+
+describe('chops-chip', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-chip');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsChip);
+  });
+
+  it('icon is visible when defined', async () => {
+    await element.updateComplete;
+    assert.isNull(element.shadowRoot.querySelector('button'));
+
+    element.buttonIcon = 'close';
+
+    await element.updateComplete;
+
+    assert.isNotNull(element.shadowRoot.querySelector('button'));
+  });
+
+  it('clicking icon fires event', async () => {
+    const onClickStub = sinon.stub();
+
+    element.buttonIcon = 'close';
+
+    await element.updateComplete;
+
+    element.addEventListener('click-button', onClickStub);
+
+    assert.isFalse(onClickStub.calledOnce);
+
+    const icon = element.shadowRoot.querySelector('button');
+    icon.click();
+
+    assert.isTrue(onClickStub.calledOnce);
+  });
+});
diff --git a/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js
new file mode 100644
index 0000000..e300588
--- /dev/null
+++ b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js
@@ -0,0 +1,133 @@
+// 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 'elements/chops/chops-button/chops-button.js';
+
+/**
+ * @typedef {Object} ChoiceOption
+ * @property {string=} value a unique string identifier for this option.
+ * @property {string=} text the text displayed to the user for this option.
+ * @property {string=} url the url this option navigates to.
+ */
+
+/**
+ * Shared component for rendering a set of choice chips.
+ * @extends {LitElement}
+ */
+export class ChopsChoiceButtons extends LitElement {
+  /** @override */
+  render() {
+    return html`
+      ${(this.options).map((option) => this._renderOption(option))}
+    `;
+  }
+
+  /**
+   * Rendering helper for rendering a single option.
+   * @param {ChoiceOption} option
+   * @return {TemplateResult}
+   */
+  _renderOption(option) {
+    const isSelected = this.value === option.value;
+    if (option.url) {
+      return html`
+        <a
+          ?selected=${isSelected}
+          aria-current=${isSelected ? 'true' : 'false'}
+          href=${option.url}
+        >${option.text}</a>
+      `;
+    }
+    return html`
+      <button
+        ?selected=${isSelected}
+        aria-current=${isSelected ? 'true' : 'false'}
+        @click=${this._setValue}
+        value=${option.value}
+      >${option.text}</button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Array of options where each option is an Object with keys:
+       * {value, text, url}
+       */
+      options: {type: Array},
+      /**
+       * Which button is currently selected.
+       */
+      value: {type: String},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+
+    /**
+     * @type {Array<ChoiceOption>}
+     */
+    this.options = [];
+    this.value = '';
+  };
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: grid;
+        grid-auto-flow: column;
+        grid-template-columns: auto;
+      }
+      button, a {
+        display: block;
+        cursor: pointer;
+        border: 0;
+        color: var(--chops-gray-700);
+        font-weight: var(--chops-link-font-weight);
+        font-size: var(--chops-normal-font-size);
+        margin: 0.1em 4px;
+        padding: 4px 10px;
+        line-height: 1.4;
+        background: var(--chops-choice-bg);
+        text-decoration: none;
+        border-radius: 16px;
+      }
+      button[selected], a[selected] {
+        background: var(--chops-active-choice-bg);
+        color: var(--chops-link-color);
+        font-weight: var(--chops-link-font-weight);
+        border-radius: 16px;
+      }
+    `;
+  };
+
+  /**
+   * Public method for allowing parents to change the value of this component.
+   * @param {string} newValue
+   * @fires CustomEvent#change
+   */
+  setValue(newValue) {
+    if (newValue !== this.value) {
+      this.value = newValue;
+      this.dispatchEvent(new CustomEvent('change'));
+    }
+  }
+
+  /**
+   * Private setter for updating the value of the component based on an internal
+   * click event.
+   * @param {MouseEvent} e
+   * @private
+   */
+  _setValue(e) {
+    this.setValue(e.target.getAttribute('value'));
+  }
+};
+
+customElements.define('chops-choice-buttons', ChopsChoiceButtons);
diff --git a/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js
new file mode 100644
index 0000000..e529735
--- /dev/null
+++ b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js
@@ -0,0 +1,99 @@
+// 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 {ChopsChoiceButtons} from './chops-choice-buttons';
+
+let element;
+
+describe('chops-choice-buttons', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-choice-buttons');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsChoiceButtons);
+  });
+
+  it('clicking option fires change event', async () => {
+    element.options = [{value: 'test', text: 'click me'}];
+    element.value = '';
+
+    await element.updateComplete;
+
+    const changeStub = sinon.stub();
+    element.addEventListener('change', changeStub);
+
+    const option = element.shadowRoot.querySelector('button');
+    option.click();
+
+    sinon.assert.calledOnce(changeStub);
+  });
+
+  it('clicking selected value does not fire change event', async () => {
+    element.options = [{value: 'test', text: 'click me'}];
+    element.value = 'test';
+
+    await element.updateComplete;
+
+    const changeStub = sinon.stub();
+    element.addEventListener('change', changeStub);
+
+    const option = element.shadowRoot.querySelector('button');
+    option.click();
+
+    sinon.assert.notCalled(changeStub);
+  });
+
+  it('selected value highlighted and has aria-current="true"', async () => {
+    element.options = [
+      {value: 'test', text: 'test'},
+      {value: 'selected', text: 'highlighted!'},
+    ];
+    element.value = 'selected';
+
+    await element.updateComplete;
+
+    const options = element.shadowRoot.querySelectorAll('button');
+
+    assert.isFalse(options[0].hasAttribute('selected'));
+    assert.isTrue(options[1].hasAttribute('selected'));
+
+    assert.equal(options[0].getAttribute('aria-current'), 'false');
+    assert.equal(options[1].getAttribute('aria-current'), 'true');
+  });
+
+  it('renders <a> tags when url set', async () => {
+    element.options = [
+      {value: 'test', text: 'test', url: 'http://google.com/'},
+    ];
+
+    await element.updateComplete;
+
+    const options = element.shadowRoot.querySelectorAll('a');
+
+    assert.equal(options[0].textContent.trim(), 'test');
+    assert.equal(options[0].href, 'http://google.com/');
+  });
+
+  it('selected value highlighted for <a> tags', async () => {
+    element.options = [
+      {value: 'test', text: 'test', url: 'http://google.com/'},
+      {value: 'selected', text: 'highlighted!', url: 'http://localhost/'},
+    ];
+    element.value = 'selected';
+
+    await element.updateComplete;
+
+    const options = element.shadowRoot.querySelectorAll('a');
+
+    assert.isFalse(options[0].hasAttribute('selected'));
+    assert.isTrue(options[1].hasAttribute('selected'));
+  });
+});
diff --git a/static_src/elements/chops/chops-collapse/chops-collapse.js b/static_src/elements/chops/chops-collapse/chops-collapse.js
new file mode 100644
index 0000000..0df3e21
--- /dev/null
+++ b/static_src/elements/chops/chops-collapse/chops-collapse.js
@@ -0,0 +1,62 @@
+// 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-collapse>` displays a collapsible element.
+ *
+ */
+export class ChopsCollapse extends LitElement {
+  /** @override */
+  static get properties() {
+    return {
+      opened: {
+        type: Boolean,
+        reflect: true,
+      },
+      ariaHidden: {
+        attribute: 'aria-hidden',
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host, :host([hidden]) {
+        display: none;
+      }
+      :host([opened]) {
+        display: block;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <slot></slot>
+    `;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.opened = false;
+    this.ariaHidden = true;
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('opened')) {
+      this.ariaHidden = !this.opened;
+    }
+    super.update(changedProperties);
+  }
+}
+customElements.define('chops-collapse', ChopsCollapse);
diff --git a/static_src/elements/chops/chops-collapse/chops-collapse.test.js b/static_src/elements/chops/chops-collapse/chops-collapse.test.js
new file mode 100644
index 0000000..7058b65
--- /dev/null
+++ b/static_src/elements/chops/chops-collapse/chops-collapse.test.js
@@ -0,0 +1,33 @@
+// 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 {ChopsCollapse} from './chops-collapse.js';
+
+
+let element;
+describe('chops-collapse', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-collapse');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsCollapse);
+  });
+
+  it('toggling chops-collapse changes aria-hidden', () => {
+    element.opened = true;
+
+    assert.isNull(element.getAttribute('aria-hidden'));
+
+    element.opened = false;
+
+    assert.isDefined(element.getAttribute('aria-hidden'));
+  });
+});
diff --git a/static_src/elements/chops/chops-dialog/chops-dialog.js b/static_src/elements/chops/chops-dialog/chops-dialog.js
new file mode 100644
index 0000000..0d40aa2
--- /dev/null
+++ b/static_src/elements/chops/chops-dialog/chops-dialog.js
@@ -0,0 +1,254 @@
+// 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-dialog>` displays a modal/dialog overlay.
+ *
+ * @customElement
+ */
+export class ChopsDialog extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        position: fixed;
+        z-index: 9999;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+        overflow: auto;
+        background-color: rgba(0,0,0,0.4);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+      :host(:not([opened])), [hidden] {
+        display: none;
+        visibility: hidden;
+      }
+      :host([closeOnOutsideClick]),
+      :host([closeOnOutsideClick]) .dialog::backdrop {
+        /* TODO(zhangtiff): Deprecate custom backdrop in favor of native
+        * browser backdrop.
+        */
+        cursor: pointer;
+      }
+      .dialog {
+        background: none;
+        border: 0;
+        max-width: 90%;
+      }
+      .dialog-content {
+        /* This extra div is here because otherwise the browser can't
+        * differentiate between a click event that hits the dialog element or
+        * its backdrop pseudoelement.
+        */
+        box-sizing: border-box;
+        background: var(--chops-white);
+        padding: 1em 16px;
+        cursor: default;
+        box-shadow: 0px 3px 20px 0px hsla(0, 0%, 0%, 0.4);
+        width: var(--chops-dialog-width);
+        max-width: var(--chops-dialog-max-width, 100%);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <dialog class="dialog" role="dialog" @cancel=${this._cancelHandler}>
+        <div class="dialog-content">
+          <slot></slot>
+        </div>
+      </dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Whether the dialog should currently be displayed or not.
+       */
+      opened: {
+        type: Boolean,
+        reflect: true,
+      },
+      /**
+       * A boolean that determines whether clicking outside of the dialog
+       * window should close it.
+       */
+      closeOnOutsideClick: {
+        type: Boolean,
+      },
+      /**
+       * A function fired when the element tries to change its own opened
+       * state. This is useful if you want the dialog state managed outside
+       * of the dialog instead of with internal state. (ie: with Redux)
+       */
+      onOpenedChange: {
+        type: Object,
+      },
+      /**
+       * When True, disables exiting keys and closing on outside clicks.
+       * Forces the user to interact with the dialog rather than just dismissing
+       * it.
+       */
+      forced: {
+        type: Boolean,
+      },
+      _boundKeydownHandler: {
+        type: Object,
+      },
+      _previousFocusedElement: {
+        type: Object,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.opened = false;
+    this.closeOnOutsideClick = false;
+    this.forced = false;
+    this._boundKeydownHandler = this._keydownHandler.bind(this);
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    this.addEventListener('click', (evt) => {
+      if (!this.opened || !this.closeOnOutsideClick || this.forced) return;
+
+      const hasDialog = evt.composedPath().find(
+          (node) => {
+            return node.classList && node.classList.contains('dialog-content');
+          }
+      );
+      if (hasDialog) return;
+
+      this.close();
+    });
+
+    window.addEventListener('keydown', this._boundKeydownHandler, true);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener('keydown', this._boundKeydownHandler,
+        true);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('opened')) {
+      this._openedChanged(this.opened);
+    }
+  }
+
+  _keydownHandler(event) {
+    if (!this.opened) return;
+    if (event.key === 'Escape' && this.forced) {
+      // Stop users from using the Escape key in a forced dialog.
+      e.preventDefault();
+    }
+  }
+
+  /**
+   * Closes the dialog.
+   * May have its logic overridden by a custom onOpenChanged function.
+   */
+  close() {
+    if (this.onOpenedChange) {
+      this.onOpenedChange(false);
+    } else {
+      this.opened = false;
+    }
+  }
+
+  /**
+   * Opens the dialog.
+   * May have its logic overridden by a custom onOpenChanged function.
+   */
+  open() {
+    if (this.onOpenedChange) {
+      this.onOpenedChange(true);
+    } else {
+      this.opened = true;
+    }
+  }
+
+  /**
+   * Switches the dialog from open to closed or vice versa.
+   */
+  toggle() {
+    this.opened = !this.opened;
+  }
+
+  _cancelHandler(evt) {
+    if (!this.forced) {
+      this.close();
+    } else {
+      evt.preventDefault();
+    }
+  }
+
+  _getActiveElement() {
+    // document.activeElement alone isn't sufficient to find the active
+    // element within shadow dom.
+    let active = document.activeElement || document.body;
+    let activeRoot = active.shadowRoot || active.root;
+    while (activeRoot && activeRoot.activeElement) {
+      active = activeRoot.activeElement;
+      activeRoot = active.shadowRoot || active.root;
+    }
+    return active;
+  }
+
+  _openedChanged(opened) {
+    const dialog = this.shadowRoot.querySelector('dialog');
+    if (opened) {
+      // For accessibility, we want to ensure we remember the element that was
+      // focused before this dialog opened.
+      this._previousFocusedElement = this._getActiveElement();
+
+      if (dialog.showModal) {
+        dialog.showModal();
+      } else {
+        dialog.setAttribute('open', 'true');
+      }
+      if (this._previousFocusedElement) {
+        this._previousFocusedElement.blur();
+      }
+    } else {
+      if (dialog.close) {
+        dialog.close();
+      } else {
+        dialog.setAttribute('open', undefined);
+      }
+
+      if (this._previousFocusedElement) {
+        const element = this._previousFocusedElement;
+        requestAnimationFrame(() => {
+          // HACK. This is to prevent a possible accessibility bug where
+          // using a keypress to trigger a button that exits a modal causes
+          // the modal to immediately re-open because the button that
+          // originally opened the modal refocuses, and the keypress
+          // propagates.
+          element.focus();
+        });
+      }
+    }
+  }
+}
+
+customElements.define('chops-dialog', ChopsDialog);
diff --git a/static_src/elements/chops/chops-dialog/chops-dialog.test.js b/static_src/elements/chops/chops-dialog/chops-dialog.test.js
new file mode 100644
index 0000000..376496a
--- /dev/null
+++ b/static_src/elements/chops/chops-dialog/chops-dialog.test.js
@@ -0,0 +1,37 @@
+// 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 {expect, assert} from 'chai';
+import {ChopsDialog} from './chops-dialog.js';
+
+let element;
+
+describe('chops-dialog', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-dialog');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsDialog);
+  });
+
+  it('chops-dialog is visible when open', async () => {
+    element.opened = false;
+
+    await element.updateComplete;
+
+    expect(element).not.to.be.visible;
+
+    element.opened = true;
+
+    await element.updateComplete;
+
+    expect(element).to.be.visible;
+  });
+});
diff --git a/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js
new file mode 100644
index 0000000..3bcc0c6
--- /dev/null
+++ b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js
@@ -0,0 +1,70 @@
+// Copyright 2020 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 'elements/chops/chops-chip/chops-chip.js';
+
+/**
+ * `<chops-filter-chips>` displays a set of filter chips.
+ * https://material.io/components/chips/#filter-chips
+ */
+export class ChopsFilterChips extends LitElement {
+  /** @override */
+  static get properties() {
+    return {
+      options: {type: Array},
+      selected: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {Array<string>} */
+    this.options = [];
+    /** @type {Object<string, boolean>} */
+    this.selected = {};
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: inline-flex;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`${this.options.map((option) => this._renderChip(option))}`;
+  }
+
+  /**
+   * Render a single chip.
+   * @param {string} option The text on the chip.
+   * @return {TemplateResult}
+   */
+  _renderChip(option) {
+    return html`
+      <chops-chip
+          @click=${this.select.bind(this, option)}
+          class=${this.selected[option] ? 'selected' : ''}
+          .thumbnail=${this.selected[option] ? 'check' : ''}>
+        ${option}
+      </chops-chip>
+    `;
+  }
+
+  /**
+   * Selects or unselects an option.
+   * @param {string} option The option to select or unselect.
+   * @fires Event#change
+   */
+  select(option) {
+    this.selected = {...this.selected, [option]: !this.selected[option]};
+    this.dispatchEvent(new Event('change'));
+  }
+}
+customElements.define('chops-filter-chips', ChopsFilterChips);
diff --git a/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js
new file mode 100644
index 0000000..3fd2671
--- /dev/null
+++ b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js
@@ -0,0 +1,58 @@
+// Copyright 2020 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 {ChopsFilterChips} from './chops-filter-chips.js';
+
+/** @type {ChopsFilterChips} */
+let element;
+
+describe('chops-filter-chips', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('chops-filter-chips');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsFilterChips);
+  });
+
+  it('renders', async () => {
+    element.options = ['one', 'two'];
+    element.selected = {two: true};
+    await element.updateComplete;
+
+    const firstChip = element.shadowRoot.firstElementChild;
+    assert.deepEqual(firstChip.className, '');
+    assert.deepEqual(firstChip.thumbnail, '');
+
+    const lastChip = element.shadowRoot.lastElementChild;
+    assert.deepEqual(lastChip.className, 'selected');
+    assert.deepEqual(lastChip.thumbnail, 'check');
+  });
+
+  it('click', async () => {
+    const onChangeStub = sinon.stub();
+
+    element.options = ['one'];
+    await element.updateComplete;
+
+    element.addEventListener('change', onChangeStub);
+    element.shadowRoot.firstElementChild.click();
+
+    assert.isTrue(element.selected.one);
+    sinon.assert.calledOnce(onChangeStub);
+
+    element.shadowRoot.firstElementChild.click();
+
+    assert.isFalse(element.selected.one);
+    sinon.assert.calledTwice(onChangeStub);
+  });
+});
diff --git a/static_src/elements/chops/chops-snackbar/chops-snackbar.js b/static_src/elements/chops/chops-snackbar/chops-snackbar.js
new file mode 100644
index 0000000..aea71b8
--- /dev/null
+++ b/static_src/elements/chops/chops-snackbar/chops-snackbar.js
@@ -0,0 +1,63 @@
+// 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-snackbar>`
+ *
+ * A container for showing messages in a snackbar.
+ *
+ */
+export class ChopsSnackbar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        align-items: center;
+        background-color: #333;
+        border-radius: 6px;
+        bottom: 1em;
+        left: 1em;
+        color: hsla(0, 0%, 100%, .87);
+        display: flex;
+        font-size: var(--chops-large-font-size);
+        padding: 16px;
+        position: fixed;
+        z-index: 1000;
+      }
+      button {
+        background: none;
+        border: none;
+        color: inherit;
+        cursor: pointer;
+        margin: 0;
+        margin-left: 8px;
+        padding: 0;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <slot></slot>
+      <button @click=${this.close}>
+        <i class="material-icons">close</i>
+      </button>
+    `;
+  }
+
+  /**
+   * Closes the snackbar.
+   * @fires CustomEvent#close
+   */
+  close() {
+    this.dispatchEvent(new CustomEvent('close'));
+  }
+}
+
+customElements.define('chops-snackbar', ChopsSnackbar);
diff --git a/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js b/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js
new file mode 100644
index 0000000..fa45d68
--- /dev/null
+++ b/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js
@@ -0,0 +1,36 @@
+// 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 {ChopsSnackbar} from './chops-snackbar.js';
+
+let element;
+
+describe('chops-snackbar', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-snackbar');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsSnackbar);
+  });
+
+  it('dispatches close event on close click', async () => {
+    element.opened = true;
+    await element.updateComplete;
+
+    const listener = sinon.stub();
+    element.addEventListener('close', listener);
+
+    element.shadowRoot.querySelector('button').click();
+
+    sinon.assert.calledOnce(listener);
+  });
+});
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js
new file mode 100644
index 0000000..2fa1dc2
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js
@@ -0,0 +1,109 @@
+// 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.
+
+const DEFAULT_DATE_LOCALE = 'en-US';
+
+// Creating the datetime formatter costs ~1.5 ms, so when formatting
+// multiple timestamps, it's more performant to reuse the formatter object.
+// Export FORMATTER and SHORT_FORMATTER for testing. The return value differs
+// based on time zone and browser, so we can't use static strings for testing.
+// We can't stub out the method because it's native code and can't be modified.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat/format#Avoid_comparing_formatted_date_values_to_static_values
+export const FORMATTER = new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, {
+  weekday: 'short',
+  year: 'numeric',
+  month: 'short',
+  day: 'numeric',
+  hour: 'numeric',
+  minute: '2-digit',
+  timeZoneName: 'short',
+});
+
+export const SHORT_FORMATTER = new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, {
+  year: 'numeric',
+  month: 'short',
+  day: 'numeric',
+});
+
+export const MS_PER_MINUTE = 60 * 1000;
+export const MS_PER_HOUR = MS_PER_MINUTE * 60;
+export const MS_PER_DAY = MS_PER_HOUR * 24;
+export const MS_PER_MONTH = MS_PER_DAY * 30;
+
+/**
+ * Helper to determine if a Date was less than a month ago.
+ * @param {Date} date The date to check.
+ * @return {boolean} Whether the date was less than a
+ *   month ago.
+ */
+function isLessThanAMonthAgo(date) {
+  const now = new Date();
+  const msDiff = Math.abs(Math.floor((now.getTime() - date.getTime())));
+  return msDiff < MS_PER_MONTH;
+}
+
+/**
+ * Displays timestamp in a standardized format to be re-used.
+ * @param {Date} date
+ * @return {string}
+ */
+export function standardTime(date) {
+  if (!date) return;
+  const absoluteTime = FORMATTER.format(date);
+
+  let timeAgoBit = '';
+  if (isLessThanAMonthAgo(date)) {
+    // Only show relative time if the time is less than a
+    // month ago because otherwise, it's not as useful.
+    timeAgoBit = ` (${relativeTime(date)})`;
+  }
+  return `${absoluteTime}${timeAgoBit}`;
+}
+
+/**
+ * Displays a timestamp in a format that's easy for a human to immediately
+ * reason about, based on long ago the time was.
+ * @param {Date} date native JavaScript Data Object.
+ * @return {string} Human-readable string of the date.
+ */
+export function relativeTime(date) {
+  if (!date) return;
+
+  const now = new Date();
+  let msDiff = now.getTime() - date.getTime();
+
+  // Use different wording depending on whether the time is in the
+  // future or past.
+  const pastOrPresentSuffix = msDiff < 0 ? 'from now' : 'ago';
+  msDiff = Math.abs(msDiff);
+
+  if (msDiff < MS_PER_MINUTE) {
+    // Less than a minute.
+    return 'just now';
+  } else if (msDiff < MS_PER_HOUR) {
+    // Less than an hour.
+    const minutes = Math.floor(msDiff / MS_PER_MINUTE);
+    if (minutes === 1) {
+      return `a minute ${pastOrPresentSuffix}`;
+    }
+    return `${minutes} minutes ${pastOrPresentSuffix}`;
+  } else if (msDiff < MS_PER_DAY) {
+    // Less than an day.
+    const hours = Math.floor(msDiff / MS_PER_HOUR);
+    if (hours === 1) {
+      return `an hour ${pastOrPresentSuffix}`;
+    }
+    return `${hours} hours ${pastOrPresentSuffix}`;
+  } else if (msDiff < MS_PER_MONTH) {
+    // Less than a month.
+    const days = Math.floor(msDiff / MS_PER_DAY);
+    if (days === 1) {
+      return `a day ${pastOrPresentSuffix}`;
+    }
+    return `${days} days ${pastOrPresentSuffix}`;
+  }
+
+  // A month or more ago. Better to show an exact date at this point.
+  return SHORT_FORMATTER.format(date);
+}
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js
new file mode 100644
index 0000000..5fe344b
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js
@@ -0,0 +1,112 @@
+// 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 {FORMATTER, MS_PER_MONTH, standardTime,
+  relativeTime} from './chops-timestamp-helpers.js';
+import sinon from 'sinon';
+
+// The formatted date strings differ based on time zone and browser, so we can't
+// use static strings for testing. We can't stub out the format method because
+// it's native code and can't be modified. So just use the FORMATTER object.
+
+let clock;
+
+describe('chops-timestamp-helpers', () => {
+  beforeEach(() => {
+    // Set clock to the Epoch.
+    clock = sinon.useFakeTimers({
+      now: new Date(0),
+      shouldAdvanceTime: false,
+    });
+  });
+
+  afterEach(() => {
+    clock.restore();
+  });
+
+  describe('standardTime', () => {
+    it('shows relative timestamp when less than a month ago', () => {
+      const date = new Date();
+      assert.equal(standardTime(date), `${FORMATTER.format(date)} (just now)`);
+    });
+
+    it('no relative time when more than a month in the future', () => {
+      const date = new Date(1548808276 * 1000);
+      assert.equal(standardTime(date), 'Tue, Jan 29, 2019, 4:31 PM PST');
+    });
+
+    it('no relative time when more than a month in the past', () => {
+      // Jan 29, 2019, 4:31 PM PST
+      const now = 1548808276 * 1000;
+      clock.tick(now);
+
+      const date = new Date(now - MS_PER_MONTH);
+      assert.equal(standardTime(date), 'Sun, Dec 30, 2018, 4:31 PM PST');
+    });
+  });
+
+  it('relativeTime future', () => {
+    assert.equal(relativeTime(new Date()), `just now`);
+
+    assert.equal(relativeTime(new Date(59 * 1000)), `just now`);
+
+    assert.equal(relativeTime(new Date(60 * 1000)), `a minute from now`);
+    assert.equal(relativeTime(new Date(2 * 60 * 1000)),
+        `2 minutes from now`);
+    assert.equal(relativeTime(new Date(59 * 60 * 1000)),
+        `59 minutes from now`);
+
+    assert.equal(relativeTime(new Date(60 * 60 * 1000)), `an hour from now`);
+    assert.equal(relativeTime(new Date(2 * 60 * 60 * 1000)),
+        `2 hours from now`);
+    assert.equal(relativeTime(new Date(23 * 60 * 60 * 1000)),
+        `23 hours from now`);
+
+    assert.equal(relativeTime(new Date(24 * 60 * 60 * 1000)),
+        `a day from now`);
+    assert.equal(relativeTime(new Date(2 * 24 * 60 * 60 * 1000)),
+        `2 days from now`);
+    assert.equal(relativeTime(new Date(29 * 24 * 60 * 60 * 1000)),
+        `29 days from now`);
+
+    assert.equal(relativeTime(new Date(30 * 24 * 60 * 60 * 1000)),
+        'Jan 30, 1970');
+  });
+
+  it('relativeTime past', () => {
+    const baseTime = 234234 * 1000;
+
+    clock.tick(baseTime);
+
+    assert.equal(relativeTime(new Date()), `just now`);
+
+    assert.equal(relativeTime(new Date(baseTime - 59 * 1000)),
+        `just now`);
+
+    assert.equal(relativeTime(new Date(baseTime - 60 * 1000)),
+        `a minute ago`);
+    assert.equal(relativeTime(new Date(baseTime - 2 * 60 * 1000)),
+        `2 minutes ago`);
+    assert.equal(relativeTime(new Date(baseTime - 59 * 60 * 1000)),
+        `59 minutes ago`);
+
+    assert.equal(relativeTime(new Date(baseTime - 60 * 60 * 1000)),
+        `an hour ago`);
+    assert.equal(relativeTime(new Date(baseTime - 2 * 60 * 60 * 1000)),
+        `2 hours ago`);
+    assert.equal(relativeTime(new Date(baseTime - 23 * 60 * 60 * 1000)),
+        `23 hours ago`);
+
+    assert.equal(relativeTime(new Date(
+        baseTime - 24 * 60 * 60 * 1000)), `a day ago`);
+    assert.equal(relativeTime(new Date(
+        baseTime - 2 * 24 * 60 * 60 * 1000)), `2 days ago`);
+    assert.equal(relativeTime(new Date(
+        baseTime - 29 * 24 * 60 * 60 * 1000)), `29 days ago`);
+
+    assert.equal(relativeTime(new Date(
+        baseTime - 30 * 24 * 60 * 60 * 1000)), 'Dec 4, 1969');
+  });
+});
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp.js b/static_src/elements/chops/chops-timestamp/chops-timestamp.js
new file mode 100644
index 0000000..b7f157f
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp.js
@@ -0,0 +1,93 @@
+// 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} from 'lit-element';
+
+import {standardTime, relativeTime} from './chops-timestamp-helpers.js';
+
+/**
+ * `<chops-timestamp>`
+ *
+ * This element shows a time in a human readable form.
+ *
+ * @customElement
+ */
+export class ChopsTimestamp extends LitElement {
+  /** @override */
+  render() {
+    return html`
+      ${this._displayedTime}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /** The data for the time which can be in any format readable by
+       *  Date.parse.
+       */
+      timestamp: {type: String},
+      /** When true, a shorter version of the date will be displayed. */
+      short: {type: Boolean},
+      /**
+       * The Date object, which is stored in UTC, to be converted to a string.
+      */
+      _date: {type: Object},
+    };
+  }
+
+  /**
+   * @return {string} Human-readable timestamp.
+   */
+  get _displayedTime() {
+    const date = this._date;
+    const short = this.short;
+    // TODO(zhangtiff): Add logic to dynamically re-compute relative time
+    //   based on set intervals.
+    if (!date) return;
+    if (short) {
+      return relativeTime(date);
+    }
+    return standardTime(date);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('timestamp')) {
+      this._date = this._parseTimestamp(this.timestamp);
+      this.setAttribute('title', standardTime(this._date));
+    }
+    super.update(changedProperties);
+  }
+
+  /**
+   * Turns a timestamp string into a native JavaScript Date Object.
+   * @param {string} timestamp Timestamp string in either an ISO format or
+   *   Unix timestamp format. If Unix time, the function expects the time in
+   *   seconds, not milliseconds.
+   * @return {Date}
+   */
+  _parseTimestamp(timestamp) {
+    if (!timestamp) return;
+
+    let unixTimeMs = 0;
+    // Make sure to do Date.parse before Number.parseInt because parseInt
+    // will parse numbers within a string.
+    if (/^\d+$/.test(timestamp)) {
+      // Check if a string contains only digits before guessing it's
+      // unix time. This is necessary because Number.parseInt will parse
+      // number strings that contain non-numbers.
+      unixTimeMs = Number.parseInt(timestamp) * 1000;
+    } else {
+      // Date.parse will parse strings with only numbers as though those
+      // strings were truncated ISO formatted strings.
+      unixTimeMs = Date.parse(timestamp);
+      if (Number.isNaN(unixTimeMs)) {
+        throw new Error('Timestamp is in an invalid format.');
+      }
+    }
+    return new Date(unixTimeMs);
+  }
+}
+customElements.define('chops-timestamp', ChopsTimestamp);
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js b/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js
new file mode 100644
index 0000000..21c227d
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js
@@ -0,0 +1,88 @@
+// 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, expect} from 'chai';
+import {ChopsTimestamp} from './chops-timestamp.js';
+import {FORMATTER, SHORT_FORMATTER} from './chops-timestamp-helpers.js';
+import sinon from 'sinon';
+
+// The formatted date strings differ based on time zone and browser, so we can't
+// use static strings for testing. We can't stub out the format method because
+// it's native code and can't be modified. So just use the FORMATTER object.
+
+let element;
+let clock;
+
+describe('chops-timestamp', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-timestamp');
+    document.body.appendChild(element);
+
+    // Set clock to the Epoch.
+    clock = sinon.useFakeTimers({
+      now: new Date(0),
+      shouldAdvanceTime: false,
+    });
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    clock.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsTimestamp);
+  });
+
+  it('changing timestamp changes date', async () => {
+    const timestamp = 1548808276;
+    element.timestamp = String(timestamp);
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        FORMATTER.format(new Date(timestamp * 1000)));
+  });
+
+  it('parses ISO dates', async () => {
+    const timestamp = '2016-11-11';
+    element.timestamp = timestamp;
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        FORMATTER.format(new Date(timestamp)));
+  });
+
+  it('invalid timestamp format', () => {
+    expect(() => {
+      element._parseTimestamp('random string');
+    }).to.throw('Timestamp is in an invalid format.');
+  });
+
+  it('short time renders shorter time', async () => {
+    element.short = true;
+    element.timestamp = '5';
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        `just now`);
+
+    element.timestamp = '60';
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        `a minute from now`);
+
+    const timestamp = 1548808276;
+    element.timestamp = String(timestamp);
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        SHORT_FORMATTER.format(timestamp * 1000));
+  });
+});
diff --git a/static_src/elements/chops/chops-toggle/chops-toggle.js b/static_src/elements/chops/chops-toggle/chops-toggle.js
new file mode 100644
index 0000000..52868bd
--- /dev/null
+++ b/static_src/elements/chops/chops-toggle/chops-toggle.js
@@ -0,0 +1,124 @@
+// 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-toggle>`
+ *
+ * A toggle button component. This component is primarily a wrapper
+ * around a native checkbox to allow easy sharing of styles.
+ *
+ */
+export class ChopsToggle extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --chops-toggle-bg: none;
+        --chops-toggle-color: var(--chops-primary-font-color);
+        --chops-toggle-hover-bg: rgba(0, 0, 0, 0.3);
+        --chops-toggle-focus-border: hsl(193, 82%, 63%);
+        --chops-toggle-checked-bg: rgba(0, 0, 0, 0.6);
+        --chops-toggle-checked-color: var(--chops-white);
+      }
+      label {
+        background: var(--chops-toggle-bg);
+        color: var(--chops-toggle-color);
+        cursor: pointer;
+        align-items: center;
+        padding: 2px 4px;
+        border: var(--chops-normal-border);
+        border-radius: var(--chops-button-radius);
+      }
+      input[type="checkbox"] {
+        /* We need the checkbox to be hidden but still accessible. */
+        opacity: 0;
+        width: 0;
+        height: 0;
+        position: absolute;
+        top: -9999;
+        left: -9999;
+      }
+      input[type="checkbox"]:focus + label {
+        /* Make sure an outline shows around this element for
+        * accessibility.
+        */
+        box-shadow: 0 0 5px 1px var(--chops-toggle-focus-border);
+      }
+      input[type="checkbox"]:hover + label {
+        background: var(--chops-toggle-hover-bg);
+      }
+      input[type="checkbox"]:checked + label {
+        background: var(--chops-toggle-checked-bg);
+        color: var(--chops-toggle-checked-color);
+      }
+      input[type="checkbox"]:disabled + label {
+        opacity: 0.8;
+        cursor: default;
+        pointer-events: none;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <input id="checkbox"
+        type="checkbox"
+        ?checked=${this.checked}
+        ?disabled=${this.disabled}
+        @change=${this._checkedChangeHandler}
+      >
+      <label for="checkbox">
+        <slot></slot>
+      </label>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Note: At the moment, this component does not manage its own
+       * internal checked state. It expects its checked state to come
+       * from its parent, and its parent is expected to update the
+       * chops-checkbox's checked state on a change event.
+       *
+       * This can be generalized in the future to support multiple
+       * ways of managing checked state if needed.
+       **/
+      checked: {type: Boolean},
+      /**
+       * Whether the element currently allows checking or not.
+       */
+      disabled: {type: Boolean},
+    };
+  }
+
+  click() {
+    super.click();
+    this.shadowRoot.querySelector('#checkbox').click();
+  }
+
+  _checkedChangeHandler(evt) {
+    this._checkedChange(evt.target.checked);
+  }
+
+  /**
+   * @param {boolean} checked
+   * @fires CustomEvent#checked-change
+   * @private
+   */
+  _checkedChange(checked) {
+    if (checked === this.checked) return;
+    const customEvent = new CustomEvent('checked-change', {
+      detail: {
+        checked: checked,
+      },
+    });
+    this.dispatchEvent(customEvent);
+  }
+}
+customElements.define('chops-toggle', ChopsToggle);
diff --git a/static_src/elements/chops/chops-toggle/chops-toggle.test.js b/static_src/elements/chops/chops-toggle/chops-toggle.test.js
new file mode 100644
index 0000000..423c993
--- /dev/null
+++ b/static_src/elements/chops/chops-toggle/chops-toggle.test.js
@@ -0,0 +1,45 @@
+// 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 {ChopsToggle} from './chops-toggle.js';
+
+let element;
+
+describe('chops-toggle', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-toggle');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsToggle);
+  });
+
+  it('clicking toggle dispatches checked-change event', async () => {
+    element.checked = false;
+    sinon.stub(window, 'CustomEvent');
+    sinon.stub(element, 'dispatchEvent');
+
+    await element.updateComplete;
+
+    element.click();
+
+    assert.deepEqual(window.CustomEvent.args[0][0], 'checked-change');
+    assert.deepEqual(window.CustomEvent.args[0][1], {
+      detail: {checked: true},
+    });
+
+    assert.isTrue(window.CustomEvent.calledOnce);
+    assert.isTrue(element.dispatchEvent.calledOnce);
+
+    window.CustomEvent.restore();
+    element.dispatchEvent.restore();
+  });
+});
diff --git a/static_src/elements/ezt/ezt-app-base.js b/static_src/elements/ezt/ezt-app-base.js
new file mode 100644
index 0000000..0dc3eae
--- /dev/null
+++ b/static_src/elements/ezt/ezt-app-base.js
@@ -0,0 +1,62 @@
+// 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} from 'lit-element';
+import qs from 'qs';
+import {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+/**
+ * `<ezt-app-base>`
+ *
+ * Base component meant to simulate a subset of the work mr-app does on
+ * EZT pages in order to allow us to more easily glue web components
+ * on EZT pages to SPA web components.
+ *
+ */
+export class EztAppBase extends connectStore(LitElement) {
+  /** @override */
+  static get properties() {
+    return {
+      projectName: {type: String},
+      userDisplayName: {type: String},
+    };
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    this.mapUrlToQueryParams();
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('userDisplayName') && this.userDisplayName) {
+      this.fetchUserData(this.userDisplayName);
+    }
+
+    if (changedProperties.has('projectName') && this.projectName) {
+      this.fetchProjectData(this.projectName);
+    }
+  }
+
+  fetchUserData(displayName) {
+    store.dispatch(userV0.fetch(displayName));
+  }
+
+  fetchProjectData(projectName) {
+    store.dispatch(projectV0.select(projectName));
+    store.dispatch(projectV0.fetch(projectName));
+  }
+
+  mapUrlToQueryParams() {
+    const params = qs.parse((window.location.search || '').substr(1));
+
+    store.dispatch(sitewide.setQueryParams(params));
+  }
+}
+customElements.define('ezt-app-base', EztAppBase);
diff --git a/static_src/elements/ezt/ezt-app-base.test.js b/static_src/elements/ezt/ezt-app-base.test.js
new file mode 100644
index 0000000..86eb5b1
--- /dev/null
+++ b/static_src/elements/ezt/ezt-app-base.test.js
@@ -0,0 +1,65 @@
+// 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 {EztAppBase} from './ezt-app-base.js';
+
+
+let element;
+
+describe('ezt-app-base', () => {
+  beforeEach(() => {
+    element = document.createElement('ezt-app-base');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, EztAppBase);
+  });
+
+  it('fetches user data when userDisplayName set', async () => {
+    sinon.stub(element, 'fetchUserData');
+
+    element.userDisplayName = 'test@example.com';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetchUserData);
+    sinon.assert.calledWith(element.fetchUserData, 'test@example.com');
+  });
+
+  it('does not fetch data when userDisplayName is empty', async () => {
+    sinon.stub(element, 'fetchUserData');
+    element.userDisplayName = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.fetchUserData);
+  });
+
+  it('fetches project data when projectName set', async () => {
+    sinon.stub(element, 'fetchProjectData');
+
+    element.projectName = 'chromium';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetchProjectData);
+    sinon.assert.calledWith(element.fetchProjectData, 'chromium');
+  });
+
+  it('does not fetch data when projectName is empty', async () => {
+    sinon.stub(element, 'fetchProjectData');
+    element.projectName = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.fetchProjectData);
+  });
+});
diff --git a/static_src/elements/ezt/ezt-element-package.js b/static_src/elements/ezt/ezt-element-package.js
new file mode 100644
index 0000000..90ffadb
--- /dev/null
+++ b/static_src/elements/ezt/ezt-element-package.js
@@ -0,0 +1,29 @@
+// 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.
+
+// This file bundles together all web components elements used on the
+// legacy EZT pages. This is to avoid having issues with registering
+// duplicate versions of dependencies.
+
+import page from 'page';
+
+import 'elements/framework/mr-dropdown/mr-account-dropdown.js';
+import 'elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+
+import 'elements/framework/mr-header/mr-header.js';
+import 'elements/issue-list/mr-chart/mr-chart.js';
+import 'elements/issue-detail/mr-flipper/mr-flipper.js';
+import 'elements/framework/mr-pref-toggle/mr-pref-toggle.js';
+import 'elements/ezt/ezt-show-columns-connector.js';
+import 'elements/ezt/ezt-app-base.js';
+
+// Register an empty set of page.js routes to allow the page() navigation
+// function to work.
+// Note: The EZT pages should NOT register the routes used by the SPA pages
+// without significant refactoring because doing so will lead to unexpected
+// routing behavior where the SPA is loaded on top of a server-rendered page
+// rather than instead of.
+page();
diff --git a/static_src/elements/ezt/ezt-footer-scripts-package.js b/static_src/elements/ezt/ezt-footer-scripts-package.js
new file mode 100644
index 0000000..85eeaa0
--- /dev/null
+++ b/static_src/elements/ezt/ezt-footer-scripts-package.js
@@ -0,0 +1,14 @@
+// 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.
+
+// This file bundles together scripts to be loaded through the legacy
+// EZT footer.
+
+import 'monitoring/client-logger.js';
+import 'monitoring/track-copy.js';
+
+// Allow EZT pages to import AutoRefreshPrpcClient.
+import AutoRefreshPrpcClient from 'prpc.js';
+
+window.AutoRefreshPrpcClient = AutoRefreshPrpcClient;
diff --git a/static_src/elements/ezt/ezt-show-columns-connector.js b/static_src/elements/ezt/ezt-show-columns-connector.js
new file mode 100644
index 0000000..c6b3347
--- /dev/null
+++ b/static_src/elements/ezt/ezt-show-columns-connector.js
@@ -0,0 +1,117 @@
+/**
+ * @fileoverview Description of this file.
+ */
+// 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} from 'lit-element';
+import {store, connectStore} from 'reducers/base.js';
+import qs from 'qs';
+import * as sitewide from 'reducers/sitewide.js';
+import 'elements/framework/mr-issue-list/mr-show-columns-dropdown.js';
+import {parseColSpec} from 'shared/issue-fields.js';
+import {equalsIgnoreCase} from 'shared/helpers.js';
+import {DEFAULT_ISSUE_FIELD_LIST} from 'shared/issue-fields.js';
+
+/**
+ * `<ezt-show-columns-connector>`
+ *
+ * Glue component to make "Show columns" dropdown work on EZT.
+ *
+ */
+export class EztShowColumnsConnector extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <mr-show-columns-dropdown
+        .defaultFields=${DEFAULT_ISSUE_FIELD_LIST}
+        .columns=${this.columns}
+        .phaseNames=${this.phaseNames}
+        .onHideColumn=${(name) => this.onHideColumn(name)}
+        .onShowColumn=${(name) => this.onShowColumn(name)}
+      ></mr-show-columns-dropdown>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      initialColumns: {type: Array},
+      hiddenColumns: {type: Object},
+      queryParams: {type: Object},
+      colspec: {type: String},
+      phasespec: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.hiddenColumns = new Set();
+    this.queryParams = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.queryParams = sitewide.queryParams(state);
+  }
+
+  get columns() {
+    return this.initialColumns.filter((_, i) =>
+      !this.hiddenColumns.has(i));
+  }
+
+  get initialColumns() {
+    // EZT will always pass in a colspec.
+    return parseColSpec(this.colspec);
+  }
+
+  get phaseNames() {
+    return parseColSpec(this.phasespec);
+  }
+
+  onHideColumn(colName) {
+    // Custom column hiding logic to avoid reloading the
+    // EZT list page when a user hides a column.
+    const colIndex = this.initialColumns.findIndex(
+        (col) => equalsIgnoreCase(col, colName));
+
+    // Legacy code integration.
+    TKR_toggleColumn('hide_col_' + colIndex);
+
+    this.hiddenColumns.add(colIndex);
+
+    this.reflectColumnsToQueryParams();
+    this.requestUpdate();
+
+    // Don't continue navigation.
+    return false;
+  }
+
+  onShowColumn(colName) {
+    const colIndex = this.initialColumns.findIndex(
+        (col) => equalsIgnoreCase(col, colName));
+    if (colIndex >= 0) {
+      this.hiddenColumns.delete(colIndex);
+      TKR_toggleColumn('hide_col_' + colIndex);
+
+      this.reflectColumnsToQueryParams();
+      this.requestUpdate();
+      return false;
+    }
+    // Reload the page if this column is not part of the initial
+    // table render.
+    return true;
+  }
+
+  reflectColumnsToQueryParams() {
+    this.queryParams.colspec = this.columns.join(' ');
+
+    // Make sure the column changes in the URL.
+    window.history.replaceState({}, '', '?' + qs.stringify(this.queryParams));
+
+    store.dispatch(sitewide.setQueryParams(this.queryParams));
+  }
+}
+customElements.define('ezt-show-columns-connector', EztShowColumnsConnector);
diff --git a/static_src/elements/ezt/ezt-show-columns-connector.test.js b/static_src/elements/ezt/ezt-show-columns-connector.test.js
new file mode 100644
index 0000000..62bd13b
--- /dev/null
+++ b/static_src/elements/ezt/ezt-show-columns-connector.test.js
@@ -0,0 +1,41 @@
+// 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 {EztShowColumnsConnector} from './ezt-show-columns-connector.js';
+
+
+let element;
+
+describe('ezt-show-columns-connector', () => {
+  beforeEach(() => {
+    element = document.createElement('ezt-show-columns-connector');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, EztShowColumnsConnector);
+  });
+
+  it('initialColumns parses colspec', () => {
+    element.colspec = 'Summary ID Owner';
+    assert.deepEqual(element.initialColumns, ['Summary', 'ID', 'Owner']);
+  });
+
+  it('filters columns based on column mask', () => {
+    sinon.stub(element, 'initialColumns').get(() => ['ID', 'Summary']);
+    element.hiddenColumns = new Set([1]);
+
+    assert.deepEqual(element.columns, ['ID']);
+  });
+
+  it('phaseNames parses phasespec', () => {
+    element.phasespec = 'stable beta stable-exp';
+    assert.deepEqual(element.phaseNames, ['stable', 'beta', 'stable-exp']);
+  });
+});
diff --git a/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js
new file mode 100644
index 0000000..d9318fc
--- /dev/null
+++ b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js
@@ -0,0 +1,283 @@
+// 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} from 'lit-element';
+import 'elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js';
+import 'elements/framework/mr-error/mr-error.js';
+import 'react/mr-react-autocomplete.tsx';
+import {prpcClient} from 'prpc-client-instance.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {TEXT_TO_STATUS_ENUM} from 'shared/consts/approval.js';
+
+
+export const NO_UPDATES_MESSAGE =
+  'User lacks approver perms for approval in all issues.';
+export const NO_APPROVALS_MESSAGE = 'These issues don\'t have any approvals.';
+
+export class MrBulkApprovalUpdate extends LitElement {
+  /** @override */
+  render() {
+    return html`
+      <style>
+        mr-bulk-approval-update {
+          display: block;
+          margin-top: 30px;
+          position: relative;
+        }
+        button.clickable-text {
+          background: none;
+          border: 0;
+          color: hsl(0, 0%, 39%);
+          cursor: pointer;
+          text-decoration: underline;
+        }
+        .hidden {
+          display: none; !important;
+        }
+        .message {
+          background-color: beige;
+          width: 500px;
+        }
+        .note {
+          color: hsl(0, 0%, 25%);
+          font-size: 0.85em;
+          font-style: italic;
+        }
+        mr-bulk-approval-update table {
+          border: 1px dotted black;
+          cellspacing: 0;
+          cellpadding: 3;
+        }
+        #approversInput {
+          border-style: none;
+        }
+      </style>
+      <button
+        class="js-showApprovals clickable-text"
+        ?hidden=${this.approvalsFetched}
+        @click=${this.fetchApprovals}
+      >Show Approvals</button>
+      ${this.approvals.length ? html`
+        <form>
+          <table>
+            <tbody><tr>
+              <th><label for="approvalSelect">Approval:</label></th>
+              <td>
+                <select
+                  id="approvalSelect"
+                  @change=${this._changeHandlers.approval}
+                >
+                  ${this.approvals.map(({fieldRef}) => html`
+                    <option
+                      value=${fieldRef.fieldName}
+                      .selected=${fieldRef.fieldName === this._values.approval}
+                    >
+                      ${fieldRef.fieldName}
+                    </option>
+                  `)}
+                </select>
+              </td>
+            </tr>
+            <tr>
+              <th><label for="approversInput">Approvers:</label></th>
+              <td>
+                <mr-react-autocomplete
+                  label="approversInput"
+                  vocabularyName="member"
+                  .multiple=${true}
+                  .value=${this._values.approvers}
+                  .onChange=${this._changeHandlers.approvers}
+                ></mr-react-autocomplete>
+              </td>
+            </tr>
+            <tr><th><label for="statusInput">Status:</label></th>
+              <td>
+                <select
+                  id="statusInput"
+                  @change=${this._changeHandlers.status}
+                >
+                  <option .selected=${!this._values.status}>
+                    ${EMPTY_FIELD_VALUE}
+                  </option>
+                  ${this.statusOptions.map((status) => html`
+                    <option
+                      value=${status}
+                      .selected=${status === this._values.status}
+                    >${status}</option>
+                  `)}
+                </select>
+              </td>
+            </tr>
+            <tr>
+              <th><label for="commentText">Comment:</label></th>
+              <td colspan="4">
+                <textarea
+                  cols="30"
+                  rows="3"
+                  id="commentText"
+                  placeholder="Add an approval comment"
+                  .value=${this._values.comment || ''}
+                  @change=${this._changeHandlers.comment}
+                ></textarea>
+              </td>
+            </tr>
+            <tr>
+              <td>
+                <button
+                  class="js-save"
+                  @click=${this.save}
+                >Update Approvals only</button>
+              </td>
+              <td>
+                <span class="note">
+                 Note: Some approvals may not be updated if you lack
+                 approver perms.
+                </span>
+              </td>
+            </tr>
+          </tbody></table>
+        </form>
+      `: ''}
+      <div class="message">
+        ${this.responseMessage}
+        ${this.errorMessage ? html`
+          <mr-error>${this.errorMessage}</mr-error>
+        ` : ''}
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      approvals: {type: Array},
+      approvalsFetched: {type: Boolean},
+      statusOptions: {type: Array},
+      localIdsStr: {type: String},
+      projectName: {type: String},
+      responseMessage: {type: String},
+      _values: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.approvals = [];
+    this.statusOptions = Object.keys(TEXT_TO_STATUS_ENUM);
+    this.responseMessage = '';
+
+    this._values = {};
+    this._changeHandlers = {
+      approval: this._onChange.bind(this, 'approval'),
+      approvers: this._onChange.bind(this, 'approvers'),
+      status: this._onChange.bind(this, 'status'),
+      comment: this._onChange.bind(this, 'comment'),
+    };
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  get issueRefs() {
+    const {projectName, localIdsStr} = this;
+    if (!projectName || !localIdsStr) return [];
+    const issueRefs = [];
+    const localIds = localIdsStr.split(',');
+    localIds.forEach((localId) => {
+      issueRefs.push({projectName: projectName, localId: localId});
+    });
+    return issueRefs;
+  }
+
+  fetchApprovals(evt) {
+    const message = {issueRefs: this.issueRefs};
+    prpcClient.call('monorail.Issues', 'ListApplicableFieldDefs', message).then(
+        (resp) => {
+          if (resp.fieldDefs) {
+            this.approvals = resp.fieldDefs.filter((fieldDef) => {
+              return fieldDef.fieldRef.type == 'APPROVAL_TYPE';
+            });
+          }
+          if (!this.approvals.length) {
+            this.errorMessage = NO_APPROVALS_MESSAGE;
+          }
+          this.approvalsFetched = true;
+        }, (error) => {
+          this.approvalsFetched = true;
+          this.errorMessage = error;
+        });
+  }
+
+  save(evt) {
+    this.responseMessage = '';
+    this.errorMessage = '';
+    this.toggleDisableForm();
+    const selectedFieldDef = this.approvals.find(
+        (approval) => approval.fieldRef.fieldName === this._values.approval
+    ) || this.approvals[0];
+    const message = {
+      issueRefs: this.issueRefs,
+      fieldRef: selectedFieldDef.fieldRef,
+      send_email: true,
+    };
+    message.commentContent = this._values.comment;
+    const delta = {};
+    if (this._values.status !== EMPTY_FIELD_VALUE) {
+      delta.status = TEXT_TO_STATUS_ENUM[this._values.status];
+    }
+    const approversAdded = this._values.approvers;
+    if (approversAdded) {
+      delta.approverRefsAdd = approversAdded.map(
+          (name) => ({'displayName': name}));
+    }
+    if (Object.keys(delta).length) {
+      message.approvalDelta = delta;
+    }
+    prpcClient.call('monorail.Issues', 'BulkUpdateApprovals', message).then(
+        (resp) => {
+          if (resp.issueRefs && resp.issueRefs.length) {
+            const idsStr = Array.from(resp.issueRefs,
+                (ref) => ref.localId).join(', ');
+            this.responseMessage = `${this.getTimeStamp()}: Updated ${
+              selectedFieldDef.fieldRef.fieldName} in issues: ${idsStr} (${
+              resp.issueRefs.length} of ${this.issueRefs.length}).`;
+            this._values = {};
+          } else {
+            this.errorMessage = NO_UPDATES_MESSAGE;
+          };
+          this.toggleDisableForm();
+        }, (error) => {
+          this.errorMessage = error;
+          this.toggleDisableForm();
+        });
+  }
+
+  getTimeStamp() {
+    const date = new Date();
+    return `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
+  }
+
+  toggleDisableForm() {
+    this.querySelectorAll('input, textarea, select, button').forEach(
+        (input) => {
+          input.disabled = !input.disabled;
+        });
+  }
+
+  /**
+   * Generic onChange handler to be bound to each form field.
+   * @param {string} key Unique name for the form field we're binding this
+   *   handler to. For example, 'owner', 'cc', or the name of a custom field.
+   * @param {Event | React.SyntheticEvent} event
+   * @param {string} value The new form value.
+   */
+  _onChange(key, event, value) {
+    this._values = {...this._values, [key]: value || event.target.value};
+  }
+}
+
+customElements.define('mr-bulk-approval-update', MrBulkApprovalUpdate);
diff --git a/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js
new file mode 100644
index 0000000..a0689e1
--- /dev/null
+++ b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js
@@ -0,0 +1,185 @@
+// 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 {fireEvent} from '@testing-library/react';
+
+import {MrBulkApprovalUpdate, NO_APPROVALS_MESSAGE,
+  NO_UPDATES_MESSAGE} from './mr-bulk-approval-update.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+
+describe('mr-bulk-approval-update', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-bulk-approval-update');
+    document.body.appendChild(element);
+
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrBulkApprovalUpdate);
+  });
+
+  it('_computeIssueRefs: missing information', () => {
+    element.projectName = 'chromium';
+    assert.equal(element.issueRefs.length, 0);
+
+    element.projectName = null;
+    element.localIdsStr = '1,2,3,5';
+    assert.equal(element.issueRefs.length, 0);
+
+    element.localIdsStr = null;
+    assert.equal(element.issueRefs.length, 0);
+  });
+
+  it('_computeIssueRefs: normal', () => {
+    const project = 'chromium';
+    element.projectName = project;
+    element.localIdsStr = '1,2,3';
+    assert.deepEqual(element.issueRefs, [
+      {projectName: project, localId: '1'},
+      {projectName: project, localId: '2'},
+      {projectName: project, localId: '3'},
+    ]);
+  });
+
+  it('fetchApprovals: applicable fields exist', async () => {
+    const responseFieldDefs = [
+      {fieldRef: {type: 'INT_TYPE'}},
+      {fieldRef: {type: 'APPROVAL_TYPE'}},
+      {fieldRef: {type: 'APPROVAL_TYPE'}},
+    ];
+    const promise = Promise.resolve({fieldDefs: responseFieldDefs});
+    prpcClient.call.returns(promise);
+
+    sinon.spy(element, 'fetchApprovals');
+
+    await element.updateComplete;
+
+    element.querySelector('.js-showApprovals').click();
+    assert.isTrue(element.fetchApprovals.calledOnce);
+
+    // Wait for promise in fetchApprovals to resolve.
+    await promise;
+
+    assert.deepEqual([
+      {fieldRef: {type: 'APPROVAL_TYPE'}},
+      {fieldRef: {type: 'APPROVAL_TYPE'}},
+    ], element.approvals);
+    assert.equal(null, element.errorMessage);
+  });
+
+  it('fetchApprovals: applicable fields dont exist', async () => {
+    const promise = Promise.resolve({fieldDefs: []});
+    prpcClient.call.returns(promise);
+
+    await element.updateComplete;
+
+    element.querySelector('.js-showApprovals').click();
+
+    await promise;
+
+    assert.equal(element.approvals.length, 0);
+    assert.equal(NO_APPROVALS_MESSAGE, element.errorMessage);
+  });
+
+  it('save: normal', async () => {
+    const promise =
+      Promise.resolve({issueRefs: [{localId: '1'}, {localId: '3'}]});
+    prpcClient.call.returns(promise);
+    const fieldDefs = [
+      {fieldRef: {fieldName: 'Approval-One', type: 'APPROVAL_TYPE'}},
+      {fieldRef: {fieldName: 'Approval-Two', type: 'APPROVAL_TYPE'}},
+    ];
+    element.approvals = fieldDefs;
+    element.projectName = 'chromium';
+    element.localIdsStr = '1,2,3';
+
+    await element.updateComplete;
+
+    fireEvent.change(element.querySelector('#commentText'), {target: {value: 'comment'}});
+    fireEvent.change(element.querySelector('#statusInput'), {target: {value: 'NotApproved'}});
+    element.querySelector('.js-save').click();
+
+    // Wait for promise in save() to resolve.
+    await promise;
+    await element.updateComplete;
+
+    // Assert messages correct
+    assert.equal(
+        true,
+        element.responseMessage.includes(
+            'Updated Approval-One in issues: 1, 3 (2 of 3).'));
+    assert.equal('', element.errorMessage);
+
+    // Assert all inputs not disabled.
+    element.querySelectorAll('input, textarea, select').forEach((input) => {
+      assert.equal(input.disabled, false);
+    });
+
+    // Assert all inputs cleared.
+    element.querySelectorAll('input, textarea').forEach((input) => {
+      assert.equal(input.value, '');
+    });
+    element.querySelectorAll('select').forEach((select) => {
+      assert.equal(select.selectedIndex, 0);
+    });
+
+    // Assert BulkUpdateApprovals correctly called.
+    const expectedMessage = {
+      approvalDelta: {status: 'NOT_APPROVED'},
+      commentContent: 'comment',
+      fieldRef: fieldDefs[0].fieldRef,
+      issueRefs: element.issueRefs,
+      send_email: true,
+    };
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Issues',
+        'BulkUpdateApprovals',
+        expectedMessage);
+  });
+
+  it('save: no updates', async () => {
+    const promise = Promise.resolve({issueRefs: []});
+    prpcClient.call.returns(promise);
+    const fieldDefs = [
+      {fieldRef: {fieldName: 'Approval-One', type: 'APPROVAL_TYPE'}},
+      {fieldRef: {fieldName: 'Approval-Two', type: 'APPROVAL_TYPE'}},
+    ];
+    element.approvals = fieldDefs;
+    element.projectName = 'chromium';
+    element.localIdsStr = '1,2,3';
+
+    await element.updateComplete;
+
+    element.querySelector('#commentText').value = 'comment';
+    element.querySelector('#statusInput').value = 'NotApproved';
+    element.querySelector('.js-save').click();
+
+    // Wait for promise in save() to resolve
+    await promise;
+
+    // Assert messages correct.
+    assert.equal('', element.responseMessage);
+    assert.equal(NO_UPDATES_MESSAGE, element.errorMessage);
+
+    // Assert inputs not cleared.
+    assert.equal(element.querySelector('#commentText').value, 'comment');
+    assert.equal(element.querySelector('#statusInput').value, 'NotApproved');
+
+    // Assert inputs not disabled.
+    element.querySelectorAll('input, textarea, select').forEach((input) => {
+      assert.equal(input.disabled, false);
+    });
+  });
+});
diff --git a/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js
new file mode 100644
index 0000000..a7870f6
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js
@@ -0,0 +1,151 @@
+// 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 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {parseColSpec} from 'shared/issue-fields.js';
+
+/**
+ * `<mr-change-columns>`
+ *
+ * Dialog where the user can change columns on the list view.
+ *
+ */
+export class MrChangeColumns extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        .edit-actions {
+          margin: 0.5em 0;
+          text-align: right;
+        }
+        .input-grid {
+          align-items: center;
+          width: 800px;
+          max-width: 100%;
+        }
+        input {
+          box-sizing: border-box;
+          padding: 0.25em 4px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <chops-dialog closeOnOutsideClick>
+        <h3 class="medium-heading">Change list columns</h3>
+        <form id="changeColumns" @submit=${this._save}>
+          <div class="input-grid">
+            <label for="columnsInput">Columns: </label>
+            <input
+              id="columnsInput"
+              placeholder="Edit columns..."
+              value=${this.columns.join(' ')}
+            />
+          </div>
+          <div class="edit-actions">
+            <chops-button
+              @click=${this.close}
+              class="de-emphasized discard-button"
+            >
+              Discard
+            </chops-button>
+            <chops-button
+              @click=${this._save}
+              class="emphasized"
+            >
+              Update columns
+            </chops-button>
+          </div>
+        </form>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Array of the currently configured issue columns, used to set
+       * the default value.
+       */
+      columns: {type: Array},
+      /**
+       * Parsed query params for the current page, to be used in
+       * navigation.
+       */
+      queryParams: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.columns = [];
+    this.queryParams = {};
+
+    this._page = page;
+  }
+
+  /**
+   * Abstract out the computation of the current page. Useful for testing.
+   */
+  get _currentPage() {
+    return window.location.pathname;
+  }
+
+  /** Updates the URL query params with the new columns. */
+  save() {
+    const input = this.shadowRoot.querySelector('#columnsInput');
+    const newColumns = parseColSpec(input.value);
+
+    const params = {...this.queryParams};
+    params.colspec = newColumns.join('+');
+
+    // TODO(zhangtiff): Create a shared function to change only
+    // query params in a URL.
+    this._page(`${this._currentPage}?${qs.stringify(params)}`);
+
+    this.close();
+  }
+
+  /**
+   * Handles form submit events.
+   * @param {Event} e A click or submit event.
+   */
+  _save(e) {
+    e.preventDefault();
+    this.save();
+  }
+
+  /** Opens and resets this dialog. */
+  open() {
+    this.reset();
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.open();
+  }
+
+  /** Closes this dialog. */
+  close() {
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.close();
+  }
+
+  /** Resets the form in this dialog. */
+  reset() {
+    this.shadowRoot.querySelector('form').reset();
+  }
+}
+
+customElements.define('mr-change-columns', MrChangeColumns);
diff --git a/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js
new file mode 100644
index 0000000..82e529d
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js
@@ -0,0 +1,75 @@
+// 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 {MrChangeColumns} from './mr-change-columns.js';
+
+
+let element;
+
+describe('mr-change-columns', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-change-columns');
+    document.body.appendChild(element);
+
+    element._page = sinon.stub();
+    sinon.stub(element, '_currentPage').get(() => '/test');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrChangeColumns);
+  });
+
+  it('input initializes with currently set columns', async () => {
+    element.columns = ['ID', 'Summary'];
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+
+    assert.equal(input.value, 'ID Summary');
+  });
+
+  it('editing input and saving updates columns in URL', async () => {
+    element.columns = ['ID', 'Summary'];
+    element.queryParams = {};
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+    input.value = 'ID Summary Owner';
+
+    element.save();
+
+    sinon.assert.calledWith(element._page,
+        '/test?colspec=ID%2BSummary%2BOwner');
+  });
+
+  it('submitting form updates colspec', async () => {
+    element.columns = ['ID', 'Summary'];
+    element.queryParams = {};
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+    input.value = 'ID Summary Component';
+
+    // Note: HTMLFormElement.submit() does not fire event listeners.
+    const submitEvent = new Event('submit');
+    sinon.spy(submitEvent, 'preventDefault');
+    const form = element.shadowRoot.querySelector('form');
+    form.dispatchEvent(submitEvent);
+
+    // Preventing default is important to prevent native browser form submit
+    // from causing an additional navigation.
+    sinon.assert.calledOnce(submitEvent.preventDefault);
+
+    sinon.assert.calledWith(element._page,
+        '/test?colspec=ID%2BSummary%2BComponent');
+  });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js
new file mode 100644
index 0000000..54565cf
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js
@@ -0,0 +1,233 @@
+// Copyright 2020 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 'elements/chops/chops-dialog/chops-dialog.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {connectStore} from 'reducers/base.js';
+
+/**
+ * `<mr-issue-hotlists-dialog>`
+ *
+ * The base dialog that <mr-move-issue-hotlists-dialog> and
+ * <mr-update-issue-hotlists-dialog> inherits common methods and behaviors from.
+ * <mr-update-issue-hotlists-dialog> is used across multiple pages where as
+ * <mr-move-issue-hotlists-dialog> is largely used within Hotlists.
+ *
+ * Important: The `render` method should be overridden by child classes.
+ */
+export class MrIssueHotlistsDialog extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          font-size: var(--chops-main-font-size);
+          --chops-dialog-max-width: 500px;
+        }
+        .error {
+          max-width: 100%;
+          color: red;
+          margin-bottom: 1px;
+        }
+        select,
+        input {
+          box-sizing: border-box;
+          width: var(--mr-edit-field-width);
+          padding: var(--mr-edit-field-padding);
+          font-size: var(--chops-main-font-size);
+        }
+        input#filter {
+          margin-top: 4px;
+          width: 85%;
+          max-width: 240px;
+        }
+        .user-hotlists {
+          max-height: 240px;
+          overflow: auto;
+        }
+        .hotlist.filter-fail {
+          display: none;
+        }
+        i.material-icons {
+          font-size: 20px;
+          margin-right: 4px;
+          vertical-align: bottom;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <chops-dialog closeOnOutsideClick>
+      ${this.renderHeader()}
+      ${this.renderContent()}
+    </chops-dialog>
+    `;
+  }
+
+  /**
+   * Renders the dialog header.
+   * @return {TemplateResult}
+   */
+  renderHeader() {
+    return html`
+      <h3 class="medium-heading">Dialog elements below:</h3>
+    `;
+  }
+
+  /**
+   * Renders the dialog content.
+   * @return {TemplateResult}
+   */
+  renderContent() {
+    return html`
+      ${this.renderFilter()}
+      ${this.renderHotlists()}
+      ${this.renderError()}
+    `;
+  }
+
+  /**
+   * Renders the Hotlist filter.
+   * @return {TemplateResult}
+   */
+  renderFilter() {
+    return html`
+      <input id="filter" type="text" @keyup=${this.filterHotlists}>
+      <i class="material-icons">search</i>
+    `;
+  }
+
+  /**
+   * Renders the user's Hotlists.
+   * @return {TemplateResult}
+   */
+  renderHotlists() {
+    return html`
+      <div class="user-hotlists">
+        ${this.filteredHotlists.length ?
+          this.filteredHotlists.map(this.renderFilteredHotlist, this) : ''}
+      </div>
+    `;
+  }
+
+  /**
+   * Renders a user's filtered Hotlist.
+   * @param {HotlistV0} hotlist The user Hotlist to render.
+   * @return {TemplateResult}
+   */
+  renderFilteredHotlist(hotlist) {
+    return html`
+      <div
+        class="hotlist"
+        data-hotlist-name="${hotlist.name}"
+      >
+        ${hotlist.name}
+      </div>`;
+  }
+
+  /**
+   * Renders dialog error.
+   * @return {TemplateResult}
+   */
+  renderError() {
+    return html`
+      <br>
+      ${this.error ? html`
+        <div class="error">${this.error}</div>
+      `: ''}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // Populated from Redux.
+      userHotlists: {type: Array},
+      filteredHotlists: {type: Array},
+      issueRefs: {type: Array},
+      error: {type: String},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.userHotlists = userV0.currentUser(state).hotlists;
+    // TODO(https://crbug.com/monorail/7778): Switch to users.js and use V3 API
+    // to make a call to GatherHotlistsForUser.
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {Array} */
+    this.userHotlists = [];
+
+    /** @type {Array} */
+    this.filteredHotlists = this.userHotlists;
+
+    /** @type {Array<IssueRef>} */
+    this.issueRefs = [];
+
+    /** @type {string} */
+    this.error = '';
+  }
+
+  /**
+   * Opens the dialog.
+   */
+  open() {
+    this.reset();
+    this.shadowRoot.querySelector('chops-dialog').open();
+  }
+
+  /**
+   * Resets any changes to the form and error.
+   */
+  reset() {
+    this.error = '';
+    const filter = this.shadowRoot.querySelector('#filter');
+    filter.value = '';
+    this.filterHotlists();
+  }
+
+  /**
+   * Closes the dialog.
+   */
+  close() {
+    this.shadowRoot.querySelector('chops-dialog').close();
+  }
+
+  /**
+   * Filters the visible Hotlists with the given user input.
+   * Requires filter to be an input element with its id as "filter".
+   */
+  filterHotlists() {
+    const input = this.shadowRoot.querySelector('#filter');
+    if (!input) {
+      // Short circuit because there's no filter.
+      this.filteredHotlists = this.userHotlists;
+    } else {
+      const filter = input.value.toLowerCase();
+      const visibleHotlists = [];
+      this.userHotlists.forEach((hotlist) => {
+        const hotlistName = hotlist.name.toLowerCase();
+        if (hotlistName.includes(filter)) {
+          visibleHotlists.push(hotlist);
+        }
+      });
+      this.filteredHotlists = visibleHotlists;
+    }
+  }
+}
+
+customElements.define('mr-issue-hotlists-dialog', MrIssueHotlistsDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..911c1a0
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js
@@ -0,0 +1,78 @@
+// Copyright 2020 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 {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog.js';
+
+let element;
+const EXAMPLE_USER_HOTLISTS = [
+  {name: 'Hotlist-1'},
+  {name: 'Hotlist-2'},
+  {name: 'ac-apple-1'},
+  {name: 'ac-frita-1'},
+];
+
+describe('mr-issue-hotlists-dialog', () => {
+  beforeEach(async () => {
+    element = document.createElement('mr-issue-hotlists-dialog');
+    document.body.appendChild(element);
+
+    await element.updateComplete;
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueHotlistsDialog);
+    assert.include(element.shadowRoot.innerHTML, 'Dialog elements below');
+  });
+
+  it('filters hotlists', async () => {
+    element.userHotlists = EXAMPLE_USER_HOTLISTS;
+    element.open();
+    await element.updateComplete;
+
+    const initialHotlists = element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(initialHotlists.length, 4);
+    const filterInput = element.shadowRoot.querySelector('#filter');
+    filterInput.value = 'list';
+    element.filterHotlists();
+    await element.updateComplete;
+    let visibleHotlists =
+        element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(visibleHotlists.length, 2);
+
+    filterInput.value = '2';
+    element.filterHotlists();
+    await element.updateComplete;
+    visibleHotlists =
+        element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(visibleHotlists.length, 1);
+  });
+
+  it('resets filter on open', async () => {
+    element.userHotlists = EXAMPLE_USER_HOTLISTS;
+    element.open();
+    await element.updateComplete;
+
+    const filterInput = element.shadowRoot.querySelector('#filter');
+    filterInput.value = 'ac';
+    element.filterHotlists();
+    await element.updateComplete;
+    let visibleHotlists =
+        element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(visibleHotlists.length, 2);
+
+    element.close();
+    element.open();
+    await element.updateComplete;
+
+    assert.equal(filterInput.value, '');
+    visibleHotlists =
+        element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(visibleHotlists.length, 4);
+  });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js
new file mode 100644
index 0000000..e7c1cd3
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js
@@ -0,0 +1,141 @@
+// Copyright 2020 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 {html, css} from 'lit-element';
+
+import 'elements/framework/mr-warning/mr-warning.js';
+import {hotlists} from 'reducers/hotlists.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
+
+/**
+ * `<mr-move-issue-hotlists-dialog>`
+ *
+ * Displays a dialog to select the Hotlist to move the provided Issues.
+ */
+export class MrMoveIssueDialog extends MrIssueHotlistsDialog {
+  /** @override */
+  static get styles() {
+    return [
+      super.styles,
+      css`
+        .hotlist {
+          padding: 4px;
+        }
+        .hotlist:hover {
+          background: var(--chops-active-choice-bg);
+          cursor: pointer;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  renderHeader() {
+    const warningText =
+        `Moving issues will remove them from ${this._viewedHotlist ?
+          this._viewedHotlist.displayName : 'this hotlist'}.`;
+    return html`
+      <h3 class="medium-heading">Move issues to hotlist</h3>
+      <mr-warning title=${warningText}>${warningText}</mr-warning>
+    `;
+  }
+
+  /** @override */
+  renderFilteredHotlist(hotlist) {
+    if (this._viewedHotlist &&
+      hotlist.name === this._viewedHotlist.displayName) return;
+    return html`
+      <div
+        class="hotlist"
+        data-hotlist-name="${hotlist.name}"
+        @click=${this._targetHotlistPicked}>
+        ${hotlist.name}
+      </div>`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      ...MrIssueHotlistsDialog.properties,
+      // Populated from Redux.
+      _viewedHotlist: {type: Object},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    super.stateChanged(state);
+    this._viewedHotlist = hotlists.viewedHotlist(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /**
+     * The currently viewed Hotlist.
+     * @type {?Hotlist}
+     **/
+    this._viewedHotlist = null;
+  }
+
+  /**
+   * Handles picking a Hotlist to move to.
+   * @param {Event} e
+   */
+  async _targetHotlistPicked(e) {
+    const targetHotlistName = e.target.dataset.hotlistName;
+    const changes = {
+      added: [],
+      removed: [],
+    };
+
+    for (const hotlist of this.userHotlists) {
+      // We move from the current Hotlist to the target Hotlist.
+      if (changes.added.length === 1 && changes.removed.length === 1) break;
+      const change = {
+        name: hotlist.name,
+        owner: hotlist.ownerRef,
+      };
+      if (hotlist.name === targetHotlistName) {
+        changes.added.push(change);
+      } else if (hotlist.name === this._viewedHotlist.displayName) {
+        changes.removed.push(change);
+      }
+    }
+
+    const issueRefs = this.issueRefs;
+    if (!issueRefs) return;
+
+    // TODO(https://crbug.com/monorail/7778): Use action creators.
+    const promises = [];
+    if (changes.added && changes.added.length) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'AddIssuesToHotlists', {
+            hotlistRefs: changes.added,
+            issueRefs,
+          },
+      ));
+    }
+    if (changes.removed && changes.removed.length) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'RemoveIssuesFromHotlists', {
+            hotlistRefs: changes.removed,
+            issueRefs,
+          },
+      ));
+    }
+
+    try {
+      await Promise.all(promises);
+      this.dispatchEvent(new Event('saveSuccess'));
+      this.close();
+    } catch (error) {
+      this.error = error.message || error.description;
+    }
+  }
+}
+
+customElements.define('mr-move-issue-hotlists-dialog', MrMoveIssueDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..7a2dd5c
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js
@@ -0,0 +1,105 @@
+// 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 {MrMoveIssueDialog} from './mr-move-issue-hotlists-dialog.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import * as example from 'shared/test/constants-hotlists.js';
+
+let element;
+let waitForPromises;
+
+describe('mr-move-issue-hotlists-dialog', () => {
+  beforeEach(async () => {
+    element = document.createElement('mr-move-issue-hotlists-dialog');
+    document.body.appendChild(element);
+
+    // We need to wait for promisees to resolve. Alone, the updateComplete
+    // returns without allowing our Promise.all to resolve.
+    waitForPromises = async () => element.updateComplete;
+
+    element.userHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+      {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+      {name: 'Hotlist-3', ownerRef: {userId: 67890}},
+      {name: example.HOTLIST.displayName, ownerRef: {userId: 67890}},
+    ];
+    element.user = {userId: 67890};
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+    element._viewedHotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrMoveIssueDialog);
+  });
+
+  it('clicking a hotlist moves the issue', async () => {
+    element.open();
+    await element.updateComplete;
+
+    const targetHotlist =element.shadowRoot.querySelector(
+        '.hotlist[data-hotlist-name="Hotlist-2"]');
+    assert.isNotNull(targetHotlist);
+    targetHotlist.click();
+    await element.updateComplete;
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'AddIssuesToHotlists', {
+          hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'RemoveIssuesFromHotlists', {
+          hotlistRefs: [{
+            name: example.HOTLIST.displayName,
+            owner: {userId: 67890},
+          }],
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+  });
+
+  it('dispatches event upon successfully moving', async () => {
+    element.open();
+    const savedStub = sinon.stub();
+    element.addEventListener('saveSuccess', savedStub);
+    sinon.stub(element, 'close');
+    await element.updateComplete;
+
+    const targetHotlist =element.shadowRoot.querySelector(
+        '.hotlist[data-hotlist-name="Hotlist-2"]');
+    targetHotlist.click();
+
+    await waitForPromises();
+    sinon.assert.calledOnce(savedStub);
+    sinon.assert.calledOnce(element.close);
+  });
+
+  it('dispatches no event upon error saving', async () => {
+    const mistakes = 'Mistakes were made';
+    const error = new Error(mistakes);
+    prpcClient.call.returns(Promise.reject(error));
+    const savedStub = sinon.stub();
+    element.addEventListener('saveSuccess', savedStub);
+    element.open();
+    await element.updateComplete;
+
+    const targetHotlist =element.shadowRoot.querySelector(
+        '.hotlist[data-hotlist-name="Hotlist-2"]');
+    targetHotlist.click();
+
+    await waitForPromises();
+    sinon.assert.notCalled(savedStub);
+    assert.include(element.shadowRoot.innerHTML, mistakes);
+  });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js
new file mode 100644
index 0000000..08a8b25
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js
@@ -0,0 +1,340 @@
+// 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 {html, css} from 'lit-element';
+import deepEqual from 'deep-equal';
+
+import 'elements/chops/chops-checkbox/chops-checkbox.js';
+import {store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
+
+/**
+ * `<mr-update-issue-hotlists-dialog>`
+ *
+ * Displays a dialog with the current hotlists's issues allowing the user to
+ * update which hotlists the issues are a member of.
+ */
+export class MrUpdateIssueDialog extends MrIssueHotlistsDialog {
+  /** @override */
+  static get styles() {
+    return [
+      ...super.styles,
+      css`
+        input[type="checkbox"] {
+          width: auto;
+          height: auto;
+        }
+        button.toggle {
+          background: none;
+          color: hsl(240, 100%, 40%);
+          border: 0;
+          width: 100%;
+          padding: 0.25em 0;
+          text-align: left;
+        }
+        button.toggle:hover {
+          cursor: pointer;
+          text-decoration: underline;
+        }
+        label, chops-checkbox {
+          display: flex;
+          line-height: 200%;
+          align-items: center;
+          width: 100%;
+          text-align: left;
+          font-weight: normal;
+          padding: 0.25em 8px;
+          box-sizing: border-box;
+        }
+        label input[type="checkbox"] {
+          margin-right: 8px;
+        }
+        .discard-button {
+          margin-right: 16px;
+        }
+        .edit-actions {
+          width: 100%;
+          margin: 0.5em 0;
+          text-align: right;
+        }
+        .input-grid {
+          align-items: center;
+        }
+        .input-grid > input {
+          width: 200px;
+          max-width: 100%;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  renderHeader() {
+    return html`
+      <h3 class="medium-heading">Add issue to hotlists</h3>
+    `;
+  }
+
+  /** @override */
+  renderContent() {
+    return html`
+      ${this.renderFilter()}
+      <form id="issueHotlistsForm">
+        ${this.renderHotlists()}
+        <h3 class="medium-heading">Create new hotlist</h3>
+        <div class="input-grid">
+          <label for="newHotlistName">New hotlist name:</label>
+          <input type="text" name="newHotlistName">
+        </div>
+        ${this.renderError()}
+        <div class="edit-actions">
+          <chops-button
+            class="de-emphasized discard-button"
+            ?disabled=${this.disabled}
+            @click=${this.discard}
+          >
+            Discard
+          </chops-button>
+          <chops-button
+            class="emphasized"
+            ?disabled=${this.disabled}
+            @click=${this.save}
+          >
+            Save changes
+          </chops-button>
+        </div>
+      </form>
+    `;
+  }
+
+  /** @override */
+  renderFilteredHotlist(hotlist) {
+    return html`
+      <chops-checkbox
+        class="hotlist"
+        title=${this._checkboxTitle(hotlist, this.issueHotlists)}
+        data-hotlist-name="${hotlist.name}"
+        ?checked=${this.hotlistsToAdd.has(hotlist.name)}
+        @checked-change=${this._targetHotlistChecked}
+      >
+        ${hotlist.name}
+      </chops-checkbox>`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      ...super.properties,
+      viewedIssueRef: {type: Object},
+      issueHotlists: {type: Array},
+      user: {type: Object},
+      hotlistsToAdd: {
+        type: Object,
+        hasChanged(newVal, oldVal) {
+          return !deepEqual(newVal, oldVal);
+        },
+      },
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    super.stateChanged(state);
+    this.viewedIssueRef = issueV0.viewedIssueRef(state);
+    this.user = userV0.currentUser(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** The list of Hotlists attached to the issueRefs. */
+    this.issueHotlists = [];
+
+    /** The Set of Hotlist names that the Issues will be added to. */
+    this.hotlistsToAdd = this._initializeHotlistsToAdd();
+  }
+
+  /** @override */
+  reset() {
+    const form = this.shadowRoot.querySelector('#issueHotlistsForm');
+    form.reset();
+    // LitElement's hasChanged needs an assignment to verify Set objects.
+    // https://lit-element.polymer-project.org/guide/properties#haschanged
+    this.hotlistsToAdd = this._initializeHotlistsToAdd();
+    super.reset();
+  }
+
+  /**
+   * An alias to the close method.
+   */
+  discard() {
+    this.close();
+  }
+
+  /**
+   * Saves all changes that were found in the dialog and issues async requests
+   * to update the issues.
+   * @fires Event#saveSuccess
+   */
+  async save() {
+    const changes = this.changes;
+    const issueRefs = this.issueRefs;
+    const viewedRef = this.viewedIssueRef;
+
+    if (!issueRefs || !changes) return;
+
+    // TODO(https://crbug.com/monorail/7778): Use action creators.
+    const promises = [];
+    if (changes.added && changes.added.length) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'AddIssuesToHotlists', {
+            hotlistRefs: changes.added,
+            issueRefs,
+          },
+      ));
+    }
+    if (changes.removed && changes.removed.length) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'RemoveIssuesFromHotlists', {
+            hotlistRefs: changes.removed,
+            issueRefs,
+          },
+      ));
+    }
+    if (changes.created) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'CreateHotlist', {
+            name: changes.created.name,
+            summary: changes.created.summary,
+            issueRefs,
+          },
+      ));
+    }
+
+    try {
+      await Promise.all(promises);
+
+      // Refresh the viewed issue's hotlists only if there is a viewed issue.
+      if (viewedRef) {
+        const viewedIssueWasUpdated = issueRefs.find((ref) =>
+          ref.projectName === viewedRef.projectName &&
+          ref.localId === viewedRef.localId);
+        if (viewedIssueWasUpdated) {
+          store.dispatch(issueV0.fetchHotlists(viewedRef));
+        }
+      }
+      store.dispatch(userV0.fetchHotlists({userId: this.user.userId}));
+      this.dispatchEvent(new Event('saveSuccess'));
+      this.close();
+    } catch (error) {
+      this.error = error.description;
+    }
+  }
+
+  /**
+   * Returns whether a given hotlist matches any of the given issue's hotlists.
+   * @param {Hotlist} hotlist Hotlist to look for.
+   * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
+   * @return {boolean}
+   */
+  _issueInHotlist(hotlist, issueHotlists) {
+    return issueHotlists.some((issueHotlist) => {
+      // TODO(https://crbug.com/monorail/7451): use `===`.
+      return (hotlist.ownerRef.userId == issueHotlist.ownerRef.userId &&
+        hotlist.name === issueHotlist.name);
+    });
+  }
+
+  /**
+   * Get a Set of Hotlists to add the Issues to based on the
+   * Get the initial Set of Hotlists that Issues will be added to. Calculated
+   * using userHotlists and issueHotlists.
+   * @return {!Set<string>}
+   */
+  _initializeHotlistsToAdd() {
+    const userHotlistsInIssueHotlists = this.userHotlists.reduce(
+        (acc, hotlist) => {
+          if (this._issueInHotlist(hotlist, this.issueHotlists)) {
+            acc.push(hotlist.name);
+          }
+          return acc;
+        }, []);
+    return new Set(userHotlistsInIssueHotlists);
+  }
+
+  /**
+   * Gets the checkbox title, depending on the checked state.
+   * @param {boolean} isChecked Whether the input is checked.
+   * @return {string}
+   */
+  _getCheckboxTitle(isChecked) {
+    return (isChecked ? 'Remove issue from' : 'Add issue to') + ' this hotlist';
+  }
+
+  /**
+   * The checkbox title for the issue, shown on hover and for a11y.
+   * @param {Hotlist} hotlist Hotlist to look for.
+   * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
+   * @return {string}
+   */
+  _checkboxTitle(hotlist, issueHotlists) {
+    return this._getCheckboxTitle(this._issueInHotlist(hotlist, issueHotlists));
+  }
+
+  /**
+   * Handles when the target Hotlist chops-checkbox has been checked.
+   * @param {Event} e
+   */
+  _targetHotlistChecked(e) {
+    const hotlistName = e.target.dataset.hotlistName;
+    const currentHotlistsToAdd = new Set(this.hotlistsToAdd);
+    if (hotlistName && e.detail.checked) {
+      currentHotlistsToAdd.add(hotlistName);
+    } else {
+      currentHotlistsToAdd.delete(hotlistName);
+    }
+    // LitElement's hasChanged needs an assignment to verify Set objects.
+    // https://lit-element.polymer-project.org/guide/properties#haschanged
+    this.hotlistsToAdd = currentHotlistsToAdd;
+    e.target.title = this._getCheckboxTitle(e.target.checked);
+  }
+
+  /**
+   * Gets the changes between the added, removed, and created hotlists .
+   */
+  get changes() {
+    const changes = {
+      added: [],
+      removed: [],
+    };
+    const form = this.shadowRoot.querySelector('#issueHotlistsForm');
+    this.userHotlists.forEach((hotlist) => {
+      const issueInHotlist = this._issueInHotlist(hotlist, this.issueHotlists);
+      if (issueInHotlist && !this.hotlistsToAdd.has(hotlist.name)) {
+        changes.removed.push({
+          name: hotlist.name,
+          owner: hotlist.ownerRef,
+        });
+      } else if (!issueInHotlist && this.hotlistsToAdd.has(hotlist.name)) {
+        changes.added.push({
+          name: hotlist.name,
+          owner: hotlist.ownerRef,
+        });
+      }
+    });
+    if (form.newHotlistName.value) {
+      changes.created = {
+        name: form.newHotlistName.value,
+        summary: 'Hotlist created from issue.',
+      };
+    }
+    return changes;
+  }
+}
+
+customElements.define('mr-update-issue-hotlists-dialog', MrUpdateIssueDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..954b8b9
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js
@@ -0,0 +1,193 @@
+// 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 {MrUpdateIssueDialog} from './mr-update-issue-hotlists-dialog.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let form;
+
+describe('mr-update-issue-hotlists-dialog', () => {
+  beforeEach(async () => {
+    element = document.createElement('mr-update-issue-hotlists-dialog');
+    document.body.appendChild(element);
+
+    await element.updateComplete;
+    form = element.shadowRoot.querySelector('#issueHotlistsForm');
+
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrUpdateIssueDialog);
+  });
+
+  it('no changes', () => {
+    assert.deepEqual(element.changes, {added: [], removed: []});
+  });
+
+  it('clicking on issues produces changes', async () => {
+    element.issueHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 12345}},
+      {name: 'Hotlist-2', ownerRef: {userId: 12345}},
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+    ];
+    element.userHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+      {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+    ];
+    element.user = {userId: 67890};
+
+    element.open();
+    await element.updateComplete;
+
+    const chopsCheckboxes = form.querySelectorAll('chops-checkbox');
+    chopsCheckboxes[0].click();
+    chopsCheckboxes[1].click();
+    assert.deepEqual(element.changes, {
+      added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+      removed: [{name: 'Hotlist-1', owner: {userId: 67890}}],
+    });
+  });
+
+  it('adding new hotlist produces changes', async () => {
+    await element.updateComplete;
+    form.newHotlistName.value = 'New-Hotlist';
+    assert.deepEqual(element.changes, {
+      added: [],
+      removed: [],
+      created: {
+        name: 'New-Hotlist',
+        summary: 'Hotlist created from issue.',
+      },
+    });
+  });
+
+  it('reset changes', async () => {
+    element.issueHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 12345}},
+      {name: 'Hotlist-2', ownerRef: {userId: 12345}},
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+    ];
+    element.userHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+      {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+    ];
+    element.user = {userId: 67890};
+
+    element.open();
+    await element.updateComplete;
+
+    const chopsCheckboxes = form.querySelectorAll('chops-checkbox');
+    const checkbox1 = chopsCheckboxes[0];
+    const checkbox2 = chopsCheckboxes[1];
+    checkbox1.click();
+    checkbox2.click();
+    form.newHotlisName = 'New-Hotlist';
+    await element.reset();
+    assert.isTrue(checkbox1.checked);
+    assert.isNotTrue(checkbox2.checked); // Falsey property.
+    assert.equal(form.newHotlistName.value, '');
+  });
+
+  it('saving adds issues to hotlist', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    await element.save();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'AddIssuesToHotlists', {
+          hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+  });
+
+  it('saving removes issues from hotlist', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      removed: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    await element.save();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'RemoveIssuesFromHotlists', {
+          hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+  });
+
+  it('saving creates new hotlist with issues', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      created: {name: 'MyHotlist', summary: 'the best hotlist'},
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    await element.save();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'CreateHotlist', {
+          name: 'MyHotlist',
+          summary: 'the best hotlist',
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+  });
+
+  it('saving refreshes issue hotlises if viewed issue is updated', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      created: {name: 'MyHotlist', summary: 'the best hotlist'},
+    }));
+    element.issueRefs = [
+      {localId: 22, projectName: 'test'},
+      {localId: 32, projectName: 'test'},
+    ];
+    element.viewedIssueRef = {localId: 32, projectName: 'test'};
+
+    await element.save();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'ListHotlistsByIssue', {issue: {localId: 32, projectName: 'test'}});
+  });
+
+  it('dispatches event upon successfully saving', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    const savedStub = sinon.stub();
+    element.addEventListener('saveSuccess', savedStub);
+
+    await element.save();
+
+    sinon.assert.calledOnce(savedStub);
+  });
+
+  it('dispatches no event upon error saving', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    const error = new Error('Mistakes were made');
+    prpcClient.call.returns(Promise.reject(error));
+
+    const savedStub = sinon.stub();
+    element.addEventListener('saveSuccess', savedStub);
+
+    await element.save();
+
+    sinon.assert.notCalled(savedStub);
+  });
+});
diff --git a/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js
new file mode 100644
index 0000000..690bd6a
--- /dev/null
+++ b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js
@@ -0,0 +1,87 @@
+// 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';
+
+/**
+ * `<mr-crbug-link>`
+ *
+ * Displays a crbug short-link to an issue.
+ *
+ */
+export class MrCrbugLink extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+         /**
+         * CSS variables provided to allow conditionally hiding <mr-crbug-link>
+         * in a way that's screenreader friendly.
+         */
+        --mr-crbug-link-opacity: 1;
+        --mr-crbug-link-opacity-focused: 1;
+      }
+      a.material-icons {
+        font-size: var(--chops-icon-font-size);
+        display: inline-block;
+        color: var(--chops-primary-icon-color);
+        padding: 0 2px;
+        box-sizing: border-box;
+        text-decoration: none;
+        vertical-align: middle;
+      }
+      a {
+        opacity: var(--mr-crbug-link-opacity);
+      }
+      a:focus {
+        opacity: var(--mr-crbug-link-opacity-focused);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <a
+        id="bugLink"
+        class="material-icons"
+        href=${this._issueUrl}
+        title="crbug link"
+      >link</a>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * The issue being viewed. Falls back gracefully if this is only a ref.
+       */
+      issue: {type: Object},
+    };
+  }
+
+  /**
+   * Computes the URL to render in the shortlink.
+   * @return {string}
+   */
+  get _issueUrl() {
+    const issue = this.issue;
+    if (!issue) return '';
+    if (this._getHost() === 'bugs.chromium.org') {
+      const projectPart = (
+        issue.projectName == 'chromium' ? '' : issue.projectName + '/');
+      return `https://crbug.com/${projectPart}${issue.localId}`;
+    }
+    const issueType = issue.approvalValues ? 'approval' : 'detail';
+    return `/p/${issue.projectName}/issues/${issueType}?id=${issue.localId}`;
+  }
+
+  _getHost() {
+    // This function allows us to mock the host in unit testing.
+    return document.location.host;
+  }
+}
+customElements.define('mr-crbug-link', MrCrbugLink);
diff --git a/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js
new file mode 100644
index 0000000..aa7f21f
--- /dev/null
+++ b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js
@@ -0,0 +1,62 @@
+// 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 {MrCrbugLink} from './mr-crbug-link.js';
+
+
+let element;
+
+describe('mr-crbug-link', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-crbug-link');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCrbugLink);
+  });
+
+  it('In prod, link to crbug.com with project name specified', async () => {
+    element._getHost = () => 'bugs.chromium.org';
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.equal(link.href, 'https://crbug.com/test/11');
+  });
+
+  it('In prod, link to crbug.com with implicit project name', async () => {
+    element._getHost = () => 'bugs.chromium.org';
+    element.issue = {
+      projectName: 'chromium',
+      localId: 11,
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.equal(link.href, 'https://crbug.com/11');
+  });
+
+  it('does not redirects to approval page for regular issues', async () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.include(link.href.trim(), '/p/test/issues/detail?id=11');
+  });
+});
diff --git a/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js
new file mode 100644
index 0000000..1f8b01a
--- /dev/null
+++ b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js
@@ -0,0 +1,39 @@
+// 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} from 'lit-element';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-hotlist-link>`
+ *
+ * Displays a link to a hotlist.
+ *
+ */
+export class MrHotlistLink extends LitElement {
+  /** @override */
+  static get styles() {
+    return SHARED_STYLES;
+  }
+
+  /** @override */
+  render() {
+    if (!this.hotlist) return html``;
+    return html`
+      <a
+        href="/u/${this.hotlist.ownerRef && this.hotlist.ownerRef.userId}/hotlists/${this.hotlist.name}"
+        title="${this.hotlist.name} - ${this.hotlist.summary}"
+      >
+        ${this.hotlist.name}</a>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      hotlist: {type: Object},
+    };
+  }
+}
+customElements.define('mr-hotlist-link', MrHotlistLink);
diff --git a/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js
new file mode 100644
index 0000000..7071b77
--- /dev/null
+++ b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js
@@ -0,0 +1,23 @@
+// 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 {MrHotlistLink} from './mr-hotlist-link.js';
+
+let element;
+
+describe('mr-hotlist-link', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-hotlist-link');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrHotlistLink);
+  });
+});
diff --git a/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js
new file mode 100644
index 0000000..029de6c
--- /dev/null
+++ b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js
@@ -0,0 +1,119 @@
+// 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 {ifDefined} from 'lit-html/directives/if-defined';
+import {issueRefToString, issueRefToUrl} from 'shared/convertersV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import '../../mr-dropdown/mr-dropdown.js';
+import '../../../help/mr-cue/mr-fed-ref-cue.js';
+
+/**
+ * `<mr-issue-link>`
+ *
+ * Displays a link to an issue.
+ *
+ */
+export class MrIssueLink extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        a[is-closed] {
+          text-decoration: line-through;
+        }
+        mr-dropdown {
+          width: var(--chops-main-font-size);
+          --mr-dropdown-icon-font-size: var(--chops-main-font-size);
+          --mr-dropdown-menu-min-width: 100px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    let fedRefInfo;
+    if (this.issue && this.issue.extIdentifier) {
+      fedRefInfo = html`
+        <!-- TODO(jeffcarp): Figure out CSS to enable menuAlignment=left -->
+        <mr-dropdown
+          label="Federated Reference Info"
+          icon="info_outline"
+          menuAlignment="right"
+        >
+          <mr-fed-ref-cue
+            cuePrefName="federated_reference"
+            fedRefShortlink=${this.issue.extIdentifier}
+            nondismissible>
+          </mr-fed-ref-cue>
+        </mr-dropdown>
+      `;
+    }
+    return html`
+      <a
+        id="bugLink"
+        href=${this.href}
+        title=${ifDefined(this.issue && this.issue.summary)}
+        ?is-closed=${this.isClosed}
+      >${this._linkText}</a>${fedRefInfo}`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // The issue being viewed. Falls back gracefully if this is only a ref.
+      issue: {type: Object},
+      text: {type: String},
+      // The global current project name. NOT the issue's project name.
+      projectName: {type: String},
+      queryParams: {type: Object},
+      short: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.issue = {};
+    this.queryParams = {};
+    this.short = false;
+  }
+
+  click() {
+    const link = this.shadowRoot.querySelector('a');
+    if (!link) return;
+    link.click();
+  }
+
+  /**
+   * @return {string} Where this issue links to.
+   */
+  get href() {
+    return issueRefToUrl(this.issue, this.queryParams);
+  }
+
+  get isClosed() {
+    if (!this.issue || !this.issue.statusRef) return false;
+
+    return this.issue.statusRef.meansOpen === false;
+  }
+
+  get _linkText() {
+    const {projectName, issue, text, short} = this;
+    if (text) return text;
+
+    if (issue && issue.extIdentifier) {
+      return issue.extIdentifier;
+    }
+
+    const prefix = short ? '' : 'Issue ';
+
+    return prefix + issueRefToString(issue, projectName);
+  }
+}
+
+customElements.define('mr-issue-link', MrIssueLink);
diff --git a/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js
new file mode 100644
index 0000000..1bd3ae9
--- /dev/null
+++ b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js
@@ -0,0 +1,147 @@
+// 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 {MrIssueLink} from './mr-issue-link.js';
+
+let element;
+
+describe('mr-issue-link', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-link');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueLink);
+  });
+
+  it('strikethrough when closed', async () => {
+    await element.updateComplete;
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.isFalse(
+        window.getComputedStyle(link).getPropertyValue(
+            'text-decoration').includes('line-through'));
+    element.issue = {statusRef: {meansOpen: false}};
+
+    await element.updateComplete;
+
+    assert.isTrue(
+        window.getComputedStyle(link).getPropertyValue(
+            'text-decoration').includes('line-through'));
+  });
+
+  it('shortens link text when short is true', () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 13,
+    };
+
+    assert.equal(element._linkText, 'Issue test:13');
+
+    element.short = true;
+
+    assert.equal(element._linkText, 'test:13');
+  });
+
+  it('shows projectName only when different from global', async () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+    };
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.equal(link.textContent.trim(), 'Issue test:11');
+
+    element.projectName = 'test';
+    await element.updateComplete;
+
+    assert.equal(link.textContent.trim(), 'Issue 11');
+
+    element.projectName = 'other';
+    await element.updateComplete;
+
+    await element.updateComplete;
+
+    assert.equal(link.textContent.trim(), 'Issue test:11');
+  });
+
+  it('shows links for issues', async () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.include(link.href.trim(), '/p/test/issues/detail?id=11');
+    assert.equal(link.title, '');
+  });
+
+  it('shows links for federated issues', async () => {
+    element.issue = {
+      extIdentifier: 'b/5678',
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.include(link.href.trim(), 'https://issuetracker.google.com/issues/5678');
+    assert.equal(link.title, '');
+  });
+
+  it('displays an icon for federated references', async () => {
+    element.issue = {
+      extIdentifier: 'b/5678',
+    };
+
+    await element.updateComplete;
+
+    const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+    assert.isNotNull(dropdown);
+    const anchor = dropdown.shadowRoot.querySelector('.anchor');
+    assert.isNotNull(anchor);
+    assert.include(anchor.innerText, 'info_outline');
+  });
+
+  it('displays an info popup for federated references', async () => {
+    element.issue = {
+      extIdentifier: 'b/5678',
+    };
+
+    await element.updateComplete;
+
+    const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+    const anchor = dropdown.shadowRoot.querySelector('.anchor');
+    anchor.click();
+
+    await dropdown.updateComplete;
+
+    assert.isTrue(dropdown.opened);
+
+    const cue = dropdown.querySelector('mr-fed-ref-cue');
+    assert.isNotNull(cue);
+    const message = cue.shadowRoot.querySelector('#message');
+    assert.isNotNull(message);
+    assert.include(message.innerText, 'Buganizer issue tracker');
+  });
+
+  it('shows title when summary is defined', async () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+      summary: 'Summary',
+    };
+
+    await element.updateComplete;
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.equal(link.title, 'Summary');
+  });
+});
diff --git a/static_src/elements/framework/links/mr-user-link/mr-user-link.js b/static_src/elements/framework/links/mr-user-link/mr-user-link.js
new file mode 100644
index 0000000..c009f89
--- /dev/null
+++ b/static_src/elements/framework/links/mr-user-link/mr-user-link.js
@@ -0,0 +1,129 @@
+// 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 issueV0 from 'reducers/issueV0.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+
+const NULL_DISPLAY_NAME_VALUES = [EMPTY_FIELD_VALUE, 'a_deleted_user'];
+
+/**
+ * `<mr-user-link>`
+ *
+ * Displays a link to a user profile.
+ *
+ */
+export class MrUserLink extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: inline-block;
+          white-space: nowrap;
+        }
+        i.inline-icon {
+          font-size: var(--chops-icon-font-size);
+          color: #B71C1C;
+          vertical-align: bottom;
+          cursor: pointer;
+        }
+        i.inline-icon-unseen {
+          color: var(--chops-purple-700);
+        }
+        i.material-icons[hidden] {
+          display: none;
+        }
+        .availability-notice {
+          color: #B71C1C;
+          font-weight: bold;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      referencedUsers: {
+        type: Object,
+      },
+      showAvailabilityIcon: {
+        type: Boolean,
+      },
+      showAvailabilityText: {
+        type: Boolean,
+      },
+      userRef: {
+        type: Object,
+        attribute: 'userref',
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.userRef = {};
+    this.referencedUsers = new Map();
+    this.showAvailabilityIcon = false;
+    this.showAvailabilityText = false;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.referencedUsers = issueV0.referencedUsers(state);
+  }
+
+  /** @override */
+  render() {
+    const availability = this._getAvailability();
+    const userLink = this._getUserLink();
+    const user = this.referencedUsers.get(this.userRef.displayName) || {};
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <i
+        id="availability-icon"
+        class="material-icons inline-icon ${user.last_visit_timestamp ? "" : "inline-icon-unseen"}"
+        title="${availability}"
+        ?hidden="${!(this.showAvailabilityIcon && availability)}"
+      >schedule</i>
+      <a
+        id="user-link"
+        href="${userLink}"
+        title="${this.userRef.displayName}"
+        ?hidden="${!userLink}"
+      >${this.userRef.displayName}</a>
+      <span
+        id="user-text"
+        ?hidden="${userLink}"
+      >${this.userRef.displayName}</span>
+      <div
+        id="availability-text"
+        class="availability-notice"
+        title="${availability}"
+        ?hidden="${!(this.showAvailabilityText && availability)}"
+      >${availability}</div>
+    `;
+  }
+
+  _getAvailability() {
+    if (!this.userRef || !this.referencedUsers) return '';
+    const user = this.referencedUsers.get(this.userRef.displayName) || {};
+    return user.availability;
+  }
+
+  _getUserLink() {
+    if (!this.userRef || !this.userRef.displayName ||
+        NULL_DISPLAY_NAME_VALUES.includes(this.userRef.displayName)) return '';
+    return `/u/${this.userRef.userId || this.userRef.displayName}`;
+  }
+}
+customElements.define('mr-user-link', MrUserLink);
diff --git a/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js b/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js
new file mode 100644
index 0000000..77af246
--- /dev/null
+++ b/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js
@@ -0,0 +1,156 @@
+// 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 {MrUserLink} from './mr-user-link.js';
+
+
+let element;
+let availabilityIcon;
+let userLink;
+let userText;
+let availabilityText;
+
+function getElements() {
+  availabilityIcon = element.shadowRoot.querySelector(
+      '#availability-icon');
+  userLink = element.shadowRoot.querySelector(
+      '#user-link');
+  userText = element.shadowRoot.querySelector(
+      '#user-text');
+  availabilityText = element.shadowRoot.querySelector(
+      '#availability-text');
+}
+
+describe('mr-user-link', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-user-link');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrUserLink);
+  });
+
+  it('no link when no userId and displayName is null value', async () => {
+    element.userRef = {displayName: '----'};
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(userText.hidden);
+    assert.equal(userText.textContent, '----');
+
+    assert.isTrue(availabilityIcon.hidden);
+    assert.isTrue(userLink.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('link when displayName', async () => {
+    element.userRef = {displayName: 'test@example.com'};
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(userLink.hidden);
+    assert.equal(userLink.textContent.trim(), 'test@example.com');
+    assert.isTrue(userLink.href.endsWith('/u/test@example.com'));
+
+    assert.isTrue(availabilityIcon.hidden);
+    assert.isTrue(userText.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('link when userId', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(userLink.hidden);
+    assert.equal(userLink.textContent.trim(), 'test@example.com');
+    assert.isTrue(userLink.href.endsWith('/u/1234'));
+
+    assert.isTrue(availabilityIcon.hidden);
+    assert.isTrue(userText.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('show availability', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {availability: 'foo'}]]);
+    element.showAvailabilityIcon = true;
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(availabilityIcon.hidden);
+    assert.equal(availabilityIcon.title, 'foo');
+
+    assert.isFalse(userLink.hidden);
+    assert.isTrue(userText.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('dont show availability', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {availability: 'foo'}]]);
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isTrue(availabilityIcon.hidden);
+
+    assert.isFalse(userLink.hidden);
+    assert.isTrue(userText.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('show availability text', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {availability: 'foo'}]]);
+    element.showAvailabilityText = true;
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(availabilityText.hidden);
+    assert.equal(availabilityText.title, 'foo');
+    assert.equal(availabilityText.textContent, 'foo');
+
+    assert.isTrue(availabilityIcon.hidden);
+    assert.isFalse(userLink.hidden);
+    assert.isTrue(userText.hidden);
+  });
+
+  it('show availability user never visited', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {last_visit_timestamp: undefined}]]);
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isTrue(availabilityIcon.classList.contains("inline-icon-unseen"));
+  });
+
+  it('show availability user visited', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {last_visit_timestamp: "35"}]]);
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isTrue(availabilityIcon.classList.contains("inline-icon"));
+    assert.isFalse(availabilityIcon.classList.contains("inline-icon-unseen"));
+  });
+});
diff --git a/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js
new file mode 100644
index 0000000..c37eb42
--- /dev/null
+++ b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js
@@ -0,0 +1,105 @@
+// 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 {ChopsAutocomplete} from
+  'elements/chops/chops-autocomplete/chops-autocomplete';
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {arrayDifference} from 'shared/helpers.js';
+import {userRefsToDisplayNames} from 'shared/convertersV0.js';
+
+
+/**
+ * `<mr-autocomplete>` displays an autocomplete input.
+ *
+ */
+export class MrAutocomplete extends connectStore(ChopsAutocomplete) {
+  /** @override */
+  static get properties() {
+    return {
+      ...ChopsAutocomplete.properties,
+      /**
+       * String for the name of autocomplete vocabulary used.
+       * Valid values:
+       *  - 'project': Names of projects available to the current user.
+       *  - 'member': All members in the current project a user is viewing.
+       *  - 'owner': Similar to member, except with groups excluded.
+       *
+       * TODO(zhangtiff): Implement the following stores.
+       *  - 'component': All components in the current project.
+       *  - 'label': Well-known labels in the current project.
+       */
+      vocabularyName: {type: String},
+      /**
+       * Object where the keys are 'type' values and each value is an object
+       * with the format {strings, docDict, replacer}.
+       */
+      vocabularies: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.vocabularyName = '';
+    this.vocabularies = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    const visibleMembers = projectV0.viewedVisibleMembers(state);
+    const userProjects = userV0.projects(state);
+    this.vocabularies = {
+      'project': this._setupProjectVocabulary(userProjects),
+      'member': this._setupMemberVocabulary(visibleMembers),
+      'owner': this._setupOwnerVocabulary(visibleMembers),
+    };
+  }
+
+  // TODO(zhangtiff): Move this logic into selectors to prevent computing
+  // vocabularies for every single instance of autocomplete.
+  _setupProjectVocabulary(userProjects) {
+    const {ownerOf = [], memberOf = [], contributorTo = []} = userProjects;
+    const strings = [...ownerOf, ...memberOf, ...contributorTo];
+    return {strings};
+  }
+
+  _setupMemberVocabulary(visibleMembers) {
+    const {userRefs = []} = visibleMembers;
+    return {strings: userRefsToDisplayNames(userRefs)};
+  }
+
+  _setupOwnerVocabulary(visibleMembers) {
+    const {userRefs = [], groupRefs = []} = visibleMembers;
+    const groups = userRefsToDisplayNames(groupRefs);
+    const users = userRefsToDisplayNames(userRefs);
+
+    // Remove groups from the list of all members.
+    const owners = arrayDifference(users, groups);
+    return {strings: owners};
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('vocabularyName') ||
+        changedProperties.has('vocabularies')) {
+      if (this.vocabularyName in this.vocabularies) {
+        const props = this.vocabularies[this.vocabularyName];
+
+        this.strings = props.strings || [];
+        this.docDict = props.docDict || {};
+        this.replacer = props.replacer;
+      } else {
+        // Clear autocomplete if there's no data for it.
+        this.strings = [];
+        this.docDict = {};
+        this.replacer = null;
+      }
+    }
+
+    super.update(changedProperties);
+  }
+}
+customElements.define('mr-autocomplete', MrAutocomplete);
diff --git a/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js
new file mode 100644
index 0000000..0c4e3ae
--- /dev/null
+++ b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js
@@ -0,0 +1,86 @@
+// 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 {MrAutocomplete} from './mr-autocomplete.js';
+
+let element;
+
+describe('mr-autocomplete', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-autocomplete');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrAutocomplete);
+  });
+
+  it('sets properties based on vocabularies', async () => {
+    assert.deepEqual(element.strings, []);
+    assert.deepEqual(element.docDict, {});
+
+    element.vocabularies = {
+      'project': {
+        'strings': ['chromium', 'v8'],
+        'docDict': {'chromium': 'move the web forward'},
+      },
+    };
+
+    element.vocabularyName = 'project';
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.strings, ['chromium', 'v8']);
+    assert.deepEqual(element.docDict, {'chromium': 'move the web forward'});
+  });
+
+  it('_setupProjectVocabulary', () => {
+    assert.deepEqual(element._setupProjectVocabulary({}), {strings: []});
+
+    assert.deepEqual(element._setupProjectVocabulary({
+      ownerOf: ['chromium'],
+      memberOf: ['skia'],
+      contributorTo: ['v8'],
+    }), {strings: ['chromium', 'skia', 'v8']});
+  });
+
+  it('_setupMemberVocabulary', () => {
+    assert.deepEqual(element._setupMemberVocabulary({}), {strings: []});
+
+    assert.deepEqual(element._setupMemberVocabulary({
+      userRefs: [
+        {displayName: 'group@example.com', userId: '100'},
+        {displayName: 'test@example.com', userId: '123'},
+        {displayName: 'test2@example.com', userId: '543'},
+      ],
+      groupRefs: [
+        {displayName: 'group@example.com', userId: '100'},
+      ],
+    }), {strings:
+      ['group@example.com', 'test@example.com', 'test2@example.com'],
+    });
+  });
+
+  it('_setupOwnerVocabulary', () => {
+    assert.deepEqual(element._setupOwnerVocabulary({}), {strings: []});
+
+    assert.deepEqual(element._setupOwnerVocabulary({
+      userRefs: [
+        {displayName: 'group@example.com', userId: '100'},
+        {displayName: 'test@example.com', userId: '123'},
+        {displayName: 'test2@example.com', userId: '543'},
+      ],
+      groupRefs: [
+        {displayName: 'group@example.com', userId: '100'},
+      ],
+    }), {strings:
+      ['test@example.com', 'test2@example.com'],
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-button-bar/mr-button-bar.js b/static_src/elements/framework/mr-button-bar/mr-button-bar.js
new file mode 100644
index 0000000..8cff503
--- /dev/null
+++ b/static_src/elements/framework/mr-button-bar/mr-button-bar.js
@@ -0,0 +1,100 @@
+// Copyright 2020 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 'elements/framework/mr-dropdown/mr-dropdown.js';
+
+import 'shared/typedef.js';
+
+/** Button bar containing table controls. */
+export class MrButtonBar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: flex;
+      }
+      button {
+        background: none;
+        color: var(--chops-link-color);
+        cursor: pointer;
+        font-size: var(--chops-normal-font-size);
+        font-weight: var(--chops-link-font-weight);
+
+        line-height: 24px;
+        padding: 4px 16px;
+
+        border: none;
+
+        align-items: center;
+        display: inline-flex;
+      }
+      button:hover {
+        background: var(--chops-active-choice-bg);
+      }
+      i.material-icons {
+        font-size: 20px;
+        margin-right: 4px;
+        vertical-align: middle;
+      }
+      mr-dropdown {
+        --mr-dropdown-anchor-padding: 6px 4px;
+        --mr-dropdown-icon-color: var(--chops-link-color);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      ${this.items.map(_renderItem)}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      items: {type: Array},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {Array<MenuItem>} */
+    this.items = [];
+  }
+};
+
+/**
+ * Renders one item.
+ * @param {MenuItem} item
+ * @return {TemplateResult}
+ */
+function _renderItem(item) {
+  if (item.items) {
+    return html`
+      <mr-dropdown
+        icon=${item.icon}
+        menuAlignment="left"
+        label=${item.text}
+        .items=${item.items}
+      ></mr-dropdown>
+    `;
+  } else {
+    return html`
+      <button @click=${item.handler}>
+        <i class="material-icons" ?hidden=${!item.icon}>
+          ${item.icon}
+        </i>
+        ${item.text}
+      </button>
+    `;
+  }
+}
+
+customElements.define('mr-button-bar', MrButtonBar);
diff --git a/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js b/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js
new file mode 100644
index 0000000..349a8df
--- /dev/null
+++ b/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js
@@ -0,0 +1,53 @@
+// Copyright 2020 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 {MrButtonBar} from './mr-button-bar.js';
+
+/** @type {MrButtonBar} */
+let element;
+
+describe('mr-button-bar', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-button-bar');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrButtonBar);
+  });
+
+  it('renders button items', async () => {
+    const handler = sinon.stub();
+
+    element.items = [{icon: 'emoji_nature', text: 'Pollinate', handler}];
+    await element.updateComplete;
+
+    const button = element.shadowRoot.querySelector('button');
+    button.click();
+
+    assert.include(button.innerHTML, 'emoji_nature');
+    assert.include(button.innerHTML, 'Pollinate');
+    sinon.assert.calledOnce(handler);
+  });
+
+  it('renders dropdown items', async () => {
+    const items = [{icon: 'emoji_nature', text: 'Pollinate'}];
+    element.items = [{icon: 'more_vert', text: 'More actions...', items}];
+    await element.updateComplete;
+
+    /** @type {MrDropdown} */
+    const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+    assert.strictEqual(dropdown.icon, 'more_vert');
+    assert.strictEqual(dropdown.label, 'More actions...');
+    assert.strictEqual(dropdown.items, items);
+  });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-attachment.js b/static_src/elements/framework/mr-comment-content/mr-attachment.js
new file mode 100644
index 0000000..c435dfd
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-attachment.js
@@ -0,0 +1,206 @@
+// 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 {SHARED_STYLES} from 'shared/shared-styles.js';
+import {FILE_DOWNLOAD_WARNING, ALLOWED_ATTACHMENT_EXTENSIONS,
+  ALLOWED_CONTENT_TYPE_PREFIXES} from 'shared/settings.js';
+import 'elements/chops/chops-button/chops-button.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/**
+ * `<mr-attachment>`
+ *
+ * Display attachments for Monorail comments.
+ *
+ */
+export class MrAttachment extends connectStore(LitElement) {
+  /** @override */
+  static get properties() {
+    return {
+      attachment: {type: Object},
+      projectName: {type: String},
+      localId: {type: Number},
+      sequenceNum: {type: Number},
+      canDelete: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        .attachment-view,
+        .attachment-download {
+          margin-left: 8px;
+          display: block;
+        }
+        .attachment-delete {
+          margin-left: 16px;
+          color: var(--chops-button-color);
+          background: var(--chops-button-bg);
+          border-color: transparent;
+        }
+        .comment-attachment {
+          min-width: 20%;
+          width: fit-content;
+          background: var(--chops-card-details-bg);
+          padding: 4px;
+          margin: 8px;
+          overflow: auto;
+        }
+        .comment-attachment-header {
+          display: flex;
+          flex-wrap: nowrap;
+        }
+        .filename {
+          margin-left: 8px;
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+        }
+        .filename-deleted {
+          margin-right: 4px;
+        }
+        .filesize {
+          margin-left: 8px;
+          white-space: nowrap;
+        }
+        .preview {
+          border: 2px solid #c3d9ff;
+          padding: 1px;
+          max-width: 98%;
+        }
+        .preview:hover {
+          border: 2px solid blue;
+        }
+      `];
+  }
+
+
+  /** @override */
+  render() {
+    return html`
+      <div class="comment-attachment">
+        <div class="filename">
+          ${this.attachment.isDeleted ? html`
+            <div class="filename-deleted">[Deleted]</div>
+          ` : ''}
+          <b>${this.attachment.filename}</b>
+          ${this.canDelete ? html`
+            <chops-button
+              class="attachment-delete"
+              @click=${this._deleteAttachment}>
+              ${this.attachment.isDeleted ? 'Undelete' : 'Delete'}
+            </chops-button>
+          ` : ''}
+        </div>
+        ${!this.attachment.isDeleted ? html`
+          <div class="comment-attachment-header">
+            <div class="filesize">${_bytesOrKbOrMb(this.attachment.size)}</div>
+            ${this.attachment.viewUrl ? html`
+              <a
+                class="attachment-view"
+                href=${this.attachment.viewUrl}
+                target="_blank"
+              >View</a>
+            `: ''}
+            <a
+              class="attachment-download"
+              href=${this.attachment.downloadUrl}
+              target="_blank"
+              ?hidden=${!this.attachment.downloadUrl}
+              @click=${this._warnOnDownload}
+            >Download</a>
+          </div>
+          ${this.attachment.thumbnailUrl ? html`
+            <a href=${this.attachment.viewUrl} target="_blank">
+              <img
+                class="preview" alt="attachment preview"
+                src=${this.attachment.thumbnailUrl}>
+            </a>
+          ` : ''}
+          ${_isVideo(this.attachment.contentType) ? html`
+            <video
+              src=${this.attachment.viewUrl}
+              class="preview"
+              controls
+              width="640"
+              preload="metadata"
+            ></video>
+          ` : ''}
+        ` : ''}
+      </div>
+    `;
+  }
+
+  /**
+   * Deletes a given attachment in a comment.
+   */
+  _deleteAttachment() {
+    const issueRef = {
+      projectName: this.projectName,
+      localId: this.localId,
+    };
+
+    const promise = prpcClient.call(
+        'monorail.Issues', 'DeleteAttachment',
+        {
+          issueRef,
+          sequenceNum: this.sequenceNum,
+          attachmentId: this.attachment.attachmentId,
+          delete: !this.attachment.isDeleted,
+        });
+
+    promise.then(() => {
+      store.dispatch(issueV0.fetchComments(issueRef));
+    }, (error) => {
+      console.log('Failed to (un)delete attachment', error);
+    });
+  }
+
+  /**
+   * Give the user a warning before they download files that Monorail thinks
+   * might have the potential to be unsafe.
+   * @param {MouseEvent} e
+   */
+  _warnOnDownload(e) {
+    const isAllowedType = ALLOWED_CONTENT_TYPE_PREFIXES.some((prefix) => {
+      return this.attachment.contentType.startsWith(prefix);
+    });
+    const isAllowedExtension = ALLOWED_ATTACHMENT_EXTENSIONS.some((ext) => {
+      return this.attachment.filename.toLowerCase().endsWith(ext);
+    });
+
+    if (isAllowedType || isAllowedExtension) return;
+    if (!window.confirm(FILE_DOWNLOAD_WARNING)) {
+      e.preventDefault();
+    }
+  }
+}
+
+function _isVideo(contentType) {
+  if (!contentType) return;
+  return contentType.startsWith('video/');
+}
+
+function _bytesOrKbOrMb(numBytes) {
+  if (numBytes < 1024) {
+    return `${numBytes} bytes`; // e.g., 128 bytes
+  } else if (numBytes < 99 * 1024) {
+    return `${(numBytes / 1024).toFixed(1)} KB`; // e.g. 23.4 KB
+  } else if (numBytes < 1024 * 1024) {
+    return `${(numBytes / 1024).toFixed(0)} KB`; // e.g., 219 KB
+  } else if (numBytes < 99 * 1024 * 1024) {
+    return `${(numBytes / 1024 / 1024).toFixed(1)} MB`; // e.g., 21.9 MB
+  } else {
+    return `${(numBytes / 1024 / 1024).toFixed(0)} MB`; // e.g., 100 MB
+  }
+}
+
+customElements.define('mr-attachment', MrAttachment);
diff --git a/static_src/elements/framework/mr-comment-content/mr-attachment.test.js b/static_src/elements/framework/mr-comment-content/mr-attachment.test.js
new file mode 100644
index 0000000..ec79c66
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-attachment.test.js
@@ -0,0 +1,228 @@
+// 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, expect} from 'chai';
+import {MrAttachment} from './mr-attachment.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {FILE_DOWNLOAD_WARNING} from 'shared/settings.js';
+
+let element;
+
+describe('mr-attachment', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-attachment');
+    document.body.appendChild(element);
+    sinon.stub(prpcClient, 'call').returns(Promise.resolve({}));
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrAttachment);
+  });
+
+  it('shows image thumbnail', async () => {
+    element.attachment = {
+      thumbnailUrl: 'thumbnail.jpeg',
+      contentType: 'image/jpeg',
+    };
+    await element.updateComplete;
+    const img = element.shadowRoot.querySelector('img');
+    assert.isNotNull(img);
+    assert.isTrue(img.src.endsWith('thumbnail.jpeg'));
+  });
+
+  it('shows video thumbnail', async () => {
+    element.attachment = {
+      viewUrl: 'video.mp4',
+      contentType: 'video/mpeg',
+    };
+    await element.updateComplete;
+    const video = element.shadowRoot.querySelector('video');
+    assert.isNotNull(video);
+    assert.isTrue(video.src.endsWith('video.mp4'));
+  });
+
+  it('does not show image thumbnail if deleted', async () => {
+    element.attachment = {
+      thumbnailUrl: 'thumbnail.jpeg',
+      contentType: 'image/jpeg',
+      isDeleted: true,
+    };
+    await element.updateComplete;
+    const img = element.shadowRoot.querySelector('img');
+    assert.isNull(img);
+  });
+
+  it('does not show video thumbnail if deleted', async () => {
+    element.attachment = {
+      viewUrl: 'video.mp4',
+      contentType: 'video/mpeg',
+      isDeleted: true,
+    };
+    await element.updateComplete;
+    const video = element.shadowRoot.querySelector('video');
+    assert.isNull(video);
+  });
+
+  it('deletes attachment', async () => {
+    prpcClient.call.callsFake(() => Promise.resolve({}));
+
+    element.attachment = {
+      attachmentId: 67890,
+      isDeleted: false,
+    };
+    element.canDelete = true;
+    element.projectName = 'proj';
+    element.localId = 1234;
+    element.sequenceNum = 3;
+    await element.updateComplete;
+
+    const deleteButton = element.shadowRoot.querySelector('chops-button');
+    deleteButton.click();
+
+    assert.deepEqual(prpcClient.call.getCall(0).args, [
+      'monorail.Issues', 'DeleteAttachment',
+      {
+        issueRef: {
+          projectName: 'proj',
+          localId: 1234,
+        },
+        sequenceNum: 3,
+        attachmentId: 67890,
+        delete: true,
+      },
+    ]);
+    assert.isTrue(prpcClient.call.calledOnce);
+  });
+
+  it('undeletes attachment', async () => {
+    prpcClient.call.callsFake(() => Promise.resolve({}));
+    element.attachment = {
+      attachmentId: 67890,
+      isDeleted: true,
+    };
+    element.canDelete = true;
+    element.projectName = 'proj';
+    element.localId = 1234;
+    element.sequenceNum = 3;
+    await element.updateComplete;
+
+    const deleteButton = element.shadowRoot.querySelector('chops-button');
+    deleteButton.click();
+
+    assert.deepEqual(prpcClient.call.getCall(0).args, [
+      'monorail.Issues', 'DeleteAttachment',
+      {
+        issueRef: {
+          projectName: 'proj',
+          localId: 1234,
+        },
+        sequenceNum: 3,
+        attachmentId: 67890,
+        delete: false,
+      },
+    ]);
+    assert.isTrue(prpcClient.call.calledOnce);
+  });
+
+  it('view link is not displayed if not given', async () => {
+    element.attachment = {};
+    await element.updateComplete;
+    const viewLink = element.shadowRoot.querySelector('.attachment-view');
+    assert.isNull(viewLink);
+  });
+
+  it('view link is displayed if given', async () => {
+    element.attachment = {
+      viewUrl: 'http://example.com/attachment.foo',
+    };
+    await element.updateComplete;
+    const viewLink = element.shadowRoot.querySelector('.attachment-view');
+    assert.isNotNull(viewLink);
+    expect(viewLink).to.be.displayed;
+    assert.equal(viewLink.href, 'http://example.com/attachment.foo');
+  });
+
+  describe('download', () => {
+    let downloadLink;
+
+    beforeEach(async () => {
+      sinon.stub(window, 'confirm').returns(false);
+
+
+      element.attachment = {};
+      await element.updateComplete;
+      downloadLink = element.shadowRoot.querySelector('.attachment-download');
+      // Prevent Karma from opening up new tabs because of simulated link
+      // clicks.
+      downloadLink.removeAttribute('target');
+    });
+
+    afterEach(() => {
+      window.confirm.restore();
+    });
+
+    it('download link is not displayed if not given', async () => {
+      element.attachment = {};
+      await element.updateComplete;
+      assert.isTrue(downloadLink.hidden);
+    });
+
+    it('download link is displayed if given', async () => {
+      element.attachment = {
+        downloadUrl: 'http://example.com/attachment.foo',
+      };
+      await element.updateComplete;
+      const downloadLink = element.shadowRoot.querySelector(
+          '.attachment-download');
+      assert.isFalse(downloadLink.hidden);
+      expect(downloadLink).to.be.displayed;
+      assert.equal(downloadLink.href, 'http://example.com/attachment.foo');
+    });
+
+    it('download allows recognized file extension and type', async () => {
+      element.attachment = {
+        contentType: 'image/png',
+        filename: 'not-a-virus.png',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.notCalled(window.confirm);
+    });
+
+    it('file extension matching is case insensitive', async () => {
+      element.attachment = {
+        contentType: 'image/png',
+        filename: 'not-a-virus.PNG',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.notCalled(window.confirm);
+    });
+
+    it('download warns on unrecognized file extension and type', async () => {
+      element.attachment = {
+        contentType: 'application/virus',
+        filename: 'fake-virus.exe',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.calledOnce(window.confirm);
+      sinon.assert.calledWith(window.confirm, FILE_DOWNLOAD_WARNING);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-comment-content.js b/static_src/elements/framework/mr-comment-content/mr-comment-content.js
new file mode 100644
index 0000000..c2bf3e8
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-comment-content.js
@@ -0,0 +1,131 @@
+// 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 {ifDefined} from 'lit-html/directives/if-defined';
+import {autolink} from 'autolink.js';
+import {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 {SHARED_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+import {shouldRenderMarkdown, renderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+
+/**
+ * `<mr-comment-content>`
+ *
+ * Displays text for a comment.
+ *
+ */
+export class MrCommentContent extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+
+    this.content = '';
+    this.commentReferences = new Map();
+    this.isDeleted = false;
+    this.projectName = '';
+    this.author = '';
+    this.prefs = {};
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      content: {type: String},
+      commentReferences: {type: Object},
+      revisionUrlFormat: {type: String},
+      isDeleted: {
+        type: Boolean,
+        reflect: true,
+      },
+      projectName: {type: String},
+      author: {type: String},
+      prefs: {type: Object},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      MD_STYLES,
+      css`
+        :host {
+          word-break: break-word;
+          font-size: var(--chops-main-font-size);
+          line-height: 130%;
+          font-family: var(--mr-toggled-font-family);
+        }
+        :host([isDeleted]) {
+          color: #888;
+          font-style: italic;
+        }
+        .line {
+          white-space: pre-wrap;
+        }
+        .strike-through {
+          text-decoration: line-through;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    if (shouldRenderMarkdown({project: this.projectName, author: this.author,
+          enabled: this._renderMarkdown})) {
+      return html`
+        <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+        <div class="markdown">
+          ${unsafeHTML(renderMarkdown(this.content))}
+        </div>
+        `;
+    }
+    const runs = autolink.markupAutolinks(
+        this.content, this.commentReferences, this.projectName,
+        this.revisionUrlFormat);
+    const templates = runs.map((run) => {
+      switch (run.tag) {
+        case 'b':
+          return html`<b class="line">${run.content}</b>`;
+        case 'br':
+          return html`<br>`;
+        case 'a':
+          return html`<a
+            class="line"
+            target="_blank"
+            href=${run.href}
+            class=${run.css}
+            title=${ifDefined(run.title)}
+          >${run.content}</a>`;
+        default:
+          return html`<span class="line">${run.content}</span>`;
+      }
+    });
+    return html`${templates}`;
+  }
+
+  /**
+   * Helper to get state of Markdown rendering.
+   * @return {boolean} Whether to render Markdown.
+   */
+  get _renderMarkdown() {
+    const {prefs} = this;
+    if (!prefs) return true;
+    return prefs.get('render_markdown');
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.commentReferences = issueV0.commentReferences(state);
+    this.projectName = issueV0.viewedIssueRef(state).projectName;
+    this.revisionUrlFormat =
+      projectV0.viewedPresentationConfig(state).revisionUrlFormat;
+    this.prefs = userV0.prefs(state);
+  }
+}
+customElements.define('mr-comment-content', MrCommentContent);
diff --git a/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js b/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js
new file mode 100644
index 0000000..4eeaab5
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js
@@ -0,0 +1,84 @@
+// 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 {MrCommentContent} from './mr-comment-content.js';
+
+
+let element;
+
+describe('mr-comment-content', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-comment-content');
+    document.body.appendChild(element);
+
+    document.body.style.setProperty('--mr-toggled-font-family', 'Some-font');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    document.body.style.removeProperty('--mr-toggled-font-family');
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCommentContent);
+  });
+
+  it('changes rendered font based on --mr-toggled-font-family', async () => {
+    element.content = 'A comment';
+
+    await element.updateComplete;
+
+    const fontFamily = window.getComputedStyle(element).getPropertyValue(
+        'font-family');
+
+    assert.equal(fontFamily, 'Some-font');
+  });
+
+  it('does not render spurious spaces', async () => {
+    element.content =
+      'Some text before a go/link and more text before <b>some bold text</b>.';
+
+    await element.updateComplete;
+
+    const textContents = Array.from(element.shadowRoot.children).map(
+        (child) => child.textContent);
+
+    assert.deepEqual(textContents, [
+      'Some text before a',
+      ' ',
+      'go/link',
+      ' and more text before ',
+      'some bold text',
+      '.',
+    ]);
+
+    assert.deepEqual(
+        element.shadowRoot.textContent,
+        'Some text before a go/link and more text before some bold text.');
+  });
+
+  it('does render markdown', async () => {
+    element.prefs = new Map([['render_markdown', true]]);
+    element.content = '### this is a header';
+    element.projectName = 'monkeyrail';
+
+    await element.updateComplete;
+
+    const headerText = element.shadowRoot.querySelector('h3').textContent;
+    assert.equal(headerText, 'this is a header');
+  });
+
+  it('does not render markdown when prefs are set to false', async () => {
+    element.prefs = new Map([['render_markdown', false]]);
+    element.projectName = 'monkeyrail';
+    element.content = '### this is a header';
+
+    await element.updateComplete;
+
+    const commentText = element.shadowRoot.textContent;
+    assert.equal(commentText, '### this is a header');
+  });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-description.js b/static_src/elements/framework/mr-comment-content/mr-description.js
new file mode 100644
index 0000000..89ae105
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-description.js
@@ -0,0 +1,137 @@
+// 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 './mr-comment-content.js';
+import './mr-attachment.js';
+
+import {relativeTime} from
+  'elements/chops/chops-timestamp/chops-timestamp-helpers';
+
+
+/**
+ * `<mr-description>`
+ *
+ * Element for displaying a description or survey.
+ *
+ */
+export class MrDescription extends LitElement {
+  /** @override */
+  constructor() {
+    super();
+
+    this.descriptionList = [];
+    this.selectedIndex = 0;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      descriptionList: {type: Array},
+      selectedIndex: {type: Number},
+    };
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('descriptionList')) {
+      if (!this.descriptionList || !this.descriptionList.length) return;
+      this.selectedIndex = this.descriptionList.length - 1;
+    }
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      .select-container {
+        text-align: right;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    const selectedDescription = this.selectedDescription;
+
+    return html`
+      <div class="select-container">
+        <select
+          @change=${this._selectChanged}
+          ?hidden=${!this.descriptionList || this.descriptionList.length <= 1}
+          aria-label="Description history menu">
+          ${this.descriptionList.map((desc, i) => this._renderDescriptionOption(desc, i))}
+        </select>
+      </div>
+      <mr-comment-content
+        .content=${selectedDescription.content}
+        .author=${selectedDescription.commenter.displayName}
+      ></mr-comment-content>
+      <div>
+        ${(selectedDescription.attachments || []).map((attachment) => html`
+          <mr-attachment
+            .attachment=${attachment}
+            .projectName=${selectedDescription.projectName}
+            .localId=${selectedDescription.localId}
+            .sequenceNum=${selectedDescription.sequenceNum}
+            .canDelete=${selectedDescription.canDelete}
+          ></mr-attachment>
+        `)}
+      </div>
+    `;
+  }
+
+  /**
+   * Getter for the currently viewed description.
+   * @return {Comment} The description object.
+   */
+  get selectedDescription() {
+    const descriptions = this.descriptionList || [];
+    const index = Math.max(
+      Math.min(this.selectedIndex, descriptions.length - 1),
+      0);
+    return descriptions[index] || {};
+  }
+
+  /**
+   * Helper to render a <select> <option> for a single description, for our
+   * description selector.
+   * @param {Comment} description
+   * @param {Number} index
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderDescriptionOption(description, index) {
+    const {commenter, timestamp} = description || {};
+    const byLine = commenter ? `by ${commenter.displayName}` : '';
+    return html`
+      <option value=${index} ?selected=${index === this.selectedIndex}>
+        Description #${index + 1} ${byLine} (${_relativeTime(timestamp)})
+      </option>
+    `;
+  }
+
+  /**
+   * Updates the element's selectedIndex when the user changes the select menu.
+   * @param {Event} evt
+   */
+  _selectChanged(evt) {
+    if (!evt || !evt.target) return;
+    this.selectedIndex = Number.parseInt(evt.target.value);
+  }
+}
+
+/**
+ * Template helper for rendering relative time.
+ * @param {number} unixTime Unix timestamp in seconds.
+ * @return {string} human readable timestamp.
+ */
+function _relativeTime(unixTime) {
+  unixTime = Number.parseInt(unixTime);
+  if (Number.isNaN(unixTime)) return;
+  return relativeTime(new Date(unixTime * 1000));
+}
+
+customElements.define('mr-description', MrDescription);
diff --git a/static_src/elements/framework/mr-comment-content/mr-description.test.js b/static_src/elements/framework/mr-comment-content/mr-description.test.js
new file mode 100644
index 0000000..9d39149
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-description.test.js
@@ -0,0 +1,81 @@
+// 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 {MrDescription} from './mr-description.js';
+
+
+let element;
+
+describe('mr-description', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-description');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrDescription);
+  });
+
+  it('changes rendered description on select change', async () => {
+    element.descriptionList = [
+      {content: 'description one', commenter: {displayName: 'name'}},
+      {content: 'description two', commenter: {displayName: 'name'}},
+    ];
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    const commentContent =
+      element.shadowRoot.querySelector('mr-comment-content');
+    assert.equal('description two', commentContent.content);
+
+    element.selectedIndex = 0;
+
+    await element.updateComplete;
+
+    assert.equal('description one', commentContent.content);
+  });
+
+  it('hides selector when only one description', async () => {
+    element.descriptionList = [
+      {content: 'Hello world', commenter: {displayName: 'name@email.com'}},
+      {content: 'rutabaga', commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    const selectMenu = element.shadowRoot.querySelector('select');
+    assert.isFalse(selectMenu.hidden);
+
+    element.descriptionList = [
+      {content: 'blehh', commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    assert.isTrue(selectMenu.hidden);
+  });
+
+  it('selector still renders when one description is deleted', async () => {
+    element.descriptionList = [
+      {content: 'Hello world', commenter: {displayName: 'name@email.com'}},
+      {isDeleted: true, commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    const selectMenu = element.shadowRoot.querySelector('select');
+    assert.isFalse(selectMenu.hidden);
+
+    const options = selectMenu.querySelectorAll('option');
+
+    assert.include(options[0].textContent, 'Description #1 by name@email.com');
+    assert.include(options[1].textContent, 'Description #2');
+  });
+});
diff --git a/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js
new file mode 100644
index 0000000..264b976
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js
@@ -0,0 +1,63 @@
+// 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 './mr-dropdown.js';
+
+/**
+ * `<mr-account-dropdown>`
+ *
+ * Account dropdown menu for Monorail.
+ *
+ */
+export class MrAccountDropdown extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+        :host {
+          position: relative;
+          display: inline-block;
+          height: 100%;
+          font-size: inherit;
+        }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-dropdown
+        .text=${this.userDisplayName}
+        .items=${this.items}
+        .icon="arrow_drop_down"
+      ></mr-dropdown>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: String,
+      logoutUrl: String,
+      loginUrl: String,
+    };
+  }
+
+  get items() {
+    return [
+      {text: 'Switch accounts', url: this.loginUrl},
+      {separator: true},
+      {text: 'Profile', url: `/u/${this.userDisplayName}`},
+      {text: 'Updates', url: `/u/${this.userDisplayName}/updates`},
+      {text: 'Settings', url: '/hosting/settings'},
+      {text: 'Saved queries', url: `/u/${this.userDisplayName}/queries`},
+      {text: 'Hotlists', url: `/u/${this.userDisplayName}/hotlists`},
+      {separator: true},
+      {text: 'Sign out', url: this.logoutUrl},
+    ];
+  }
+}
+
+customElements.define('mr-account-dropdown', MrAccountDropdown);
diff --git a/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js
new file mode 100644
index 0000000..f365823
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js
@@ -0,0 +1,23 @@
+// 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 {MrAccountDropdown} from './mr-account-dropdown.js';
+
+let element;
+
+describe('mr-account-dropdown', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-account-dropdown');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrAccountDropdown);
+  });
+});
diff --git a/static_src/elements/framework/mr-dropdown/mr-dropdown.js b/static_src/elements/framework/mr-dropdown/mr-dropdown.js
new file mode 100644
index 0000000..4564ab0
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-dropdown.js
@@ -0,0 +1,367 @@
+// 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 {ifDefined} from 'lit-html/directives/if-defined';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'shared/typedef.js';
+
+export const SCREENREADER_ATTRIBUTE_ERROR = `For screenreader support,
+  mr-dropdown must always have either a label or a text property defined.`;
+
+/**
+ * `<mr-dropdown>`
+ *
+ * Dropdown menu for Monorail.
+ *
+ */
+export class MrDropdown extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          position: relative;
+          display: inline-block;
+          height: 100%;
+          font-size: inherit;
+          font-family: var(--chops-font-family);
+          --mr-dropdown-icon-color: var(--chops-primary-icon-color);
+          --mr-dropdown-icon-font-size: var(--chops-icon-font-size);
+          --mr-dropdown-anchor-font-weight: var(--chops-link-font-weight);
+          --mr-dropdown-anchor-padding: 4px 0.25em;
+          --mr-dropdown-anchor-justify-content: center;
+          --mr-dropdown-menu-max-height: initial;
+          --mr-dropdown-menu-overflow: initial;
+          --mr-dropdown-menu-min-width: 120%;
+          --mr-dropdown-menu-font-size: var(--chops-large-font-size);
+          --mr-dropdown-menu-icon-size: var(--chops-icon-font-size);
+        }
+        :host([hidden]) {
+          display: none;
+          visibility: hidden;
+        }
+        :host(:not([opened])) .menu {
+          display: none;
+          visibility: hidden;
+        }
+        strong {
+          font-size: var(--chops-large-font-size);
+        }
+        i.material-icons {
+          font-size: var(--mr-dropdown-icon-font-size);
+          display: inline-block;
+          color: var(--mr-dropdown-icon-color);
+          padding: 0 2px;
+          box-sizing: border-box;
+        }
+        i.material-icons[hidden],
+        .menu-item > i.material-icons[hidden] {
+          display: none;
+        }
+        .menu-item > i.material-icons {
+          display: block;
+          font-size: var(--mr-dropdown-menu-icon-size);
+          width: var(--mr-dropdown-menu-icon-size);
+          height: var(--mr-dropdown-menu-icon-size);
+          margin-right: 8px;
+        }
+        .anchor:disabled {
+          color: var(--chops-button-disabled-color);
+        }
+        button.anchor {
+          box-sizing: border-box;
+          background: none;
+          border: none;
+          font-size: inherit;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          align-items: center;
+          justify-content: var(--mr-dropdown-anchor-justify-content);
+          cursor: pointer;
+          padding: var(--mr-dropdown-anchor-padding);
+          color: var(--chops-link-color);
+          font-weight: var(--mr-dropdown-anchor-font-weight);
+          font-family: inherit;
+        }
+        /* menuAlignment options: right, left, side. */
+        .menu.right {
+          right: 0px;
+        }
+        .menu.left {
+          left: 0px;
+        }
+        .menu.side {
+          left: 100%;
+          top: 0;
+        }
+        .menu {
+          font-size: var(--mr-dropdown-menu-font-size);
+          position: absolute;
+          min-width: var(--mr-dropdown-menu-min-width);
+          max-height: var(--mr-dropdown-menu-max-height);
+          overflow: var(--mr-dropdown-menu-overflow);
+          top: 90%;
+          display: block;
+          background: var(--chops-white);
+          border: var(--chops-accessible-border);
+          z-index: 990;
+          box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
+          font-family: inherit;
+        }
+        .menu-item {
+          background: none;
+          margin: 0;
+          border: 0;
+          box-sizing: border-box;
+          text-decoration: none;
+          white-space: nowrap;
+          display: flex;
+          align-items: center;
+          justify-content: left;
+          width: 100%;
+          padding: 0.25em 8px;
+          transition: 0.2s background ease-in-out;
+
+        }
+        .menu-item[hidden] {
+          display: none;
+        }
+        mr-dropdown.menu-item {
+          width: 100%;
+          padding: 0;
+          --mr-dropdown-anchor-padding: 0.25em 8px;
+          --mr-dropdown-anchor-justify-content: space-between;
+        }
+        .menu hr {
+          width: 96%;
+          margin: 0 2%;
+          border: 0;
+          height: 1px;
+          background: hsl(0, 0%, 80%);
+        }
+        .menu a {
+          cursor: pointer;
+          color: var(--chops-link-color);
+        }
+        .menu a:hover, .menu a:focus {
+          background: var(--chops-active-choice-bg);
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <button class="anchor"
+        @click=${this.toggle}
+        @keydown=${this._exitMenuOnEsc}
+        ?disabled=${this.disabled}
+        title=${this.title || this.label}
+        aria-label=${this.label}
+        aria-expanded=${this.opened}
+      >
+        ${this.text}
+        <i class="material-icons" aria-hidden="true">${this.icon}</i>
+      </button>
+      <div class="menu ${this.menuAlignment}">
+        ${this.items.map((item, index) => this._renderItem(item, index))}
+        <slot></slot>
+      </div>
+    `;
+  }
+
+  /**
+   * Render a single dropdown menu item.
+   * @param {MenuItem} item
+   * @param {number} index The item's position in the list of items.
+   * @return {TemplateResult}
+   */
+  _renderItem(item, index) {
+    if (item.separator) {
+      // The menu item is a no-op divider between sections.
+      return html`
+        <strong ?hidden=${!item.text} class="menu-item">
+          ${item.text}
+        </strong>
+        <hr />
+      `;
+    }
+    if (item.items && item.items.length) {
+      // The menu contains a sub-menu.
+      return html`
+        <mr-dropdown
+          .text=${item.text}
+          .items=${item.items}
+          menuAlignment="side"
+          icon="arrow_right"
+          data-idx=${index}
+          class="menu-item"
+        ></mr-dropdown>
+      `;
+    }
+
+    return html`
+      <a
+        href=${ifDefined(item.url)}
+        @click=${this._runItemHandler}
+        @keydown=${this._onItemKeydown}
+        data-idx=${index}
+        tabindex="0"
+        class="menu-item"
+      >
+        <i
+          class="material-icons"
+          ?hidden=${item.icon === undefined}
+        >${item.icon}</i>
+        ${item.text}
+      </a>
+    `;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.label = '';
+    this.text = '';
+    this.items = [];
+    this.icon = 'arrow_drop_down';
+    this.menuAlignment = 'right';
+    this.opened = false;
+    this.disabled = false;
+
+    this._boundCloseOnOutsideClick = this._closeOnOutsideClick.bind(this);
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      title: {type: String},
+      label: {type: String},
+      text: {type: String},
+      items: {type: Array},
+      icon: {type: String},
+      menuAlignment: {type: String},
+      opened: {type: Boolean, reflect: true},
+      disabled: {type: Boolean},
+    };
+  }
+
+  /**
+   * Either runs the click handler attached to the clicked item and closes the
+   * menu.
+   * @param {MouseEvent|KeyboardEvent} e
+   */
+  _runItemHandler(e) {
+    if (e instanceof MouseEvent || e.code === 'Enter') {
+      const idx = e.target.dataset.idx;
+      if (idx !== undefined && this.items[idx].handler) {
+        this.items[idx].handler();
+      }
+      this.close();
+    }
+  }
+
+  /**
+   * Runs multiple event handlers when a user types a key while
+   * focusing a menu item.
+   * @param {KeyboardEvent} e
+   */
+  _onItemKeydown(e) {
+    this._runItemHandler(e);
+    this._exitMenuOnEsc(e);
+  }
+
+  /**
+   * If the user types Esc while focusing any dropdown item, then
+   * exit the dropdown.
+   * @param {KeyboardEvent} e
+   */
+  _exitMenuOnEsc(e) {
+    if (e.key === 'Escape') {
+      this.close();
+
+      // Return focus to the anchor of the dropdown on closing, so that
+      // users don't lose their overall focus position within the page.
+      const anchor = this.shadowRoot.querySelector('.anchor');
+      anchor.focus();
+    }
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    window.addEventListener('click', this._boundCloseOnOutsideClick, true);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener('click', this._boundCloseOnOutsideClick, true);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('label') || changedProperties.has('text')) {
+      if (!this.label && !this.text) {
+        console.error(SCREENREADER_ATTRIBUTE_ERROR);
+      }
+    }
+  }
+
+  /**
+   * Closes and opens the dropdown menu.
+   */
+  toggle() {
+    this.opened = !this.opened;
+  }
+
+  /**
+   * Opens the dropdown menu.
+   */
+  open() {
+    this.opened = true;
+  }
+
+  /**
+   * Closes the dropdown menu.
+   */
+  close() {
+    this.opened = false;
+  }
+
+  /**
+   * Click a specific item in mr-dropdown, using JavaScript. Useful for testing.
+   *
+   * @param {number} i index of the item to click.
+   */
+  clickItem(i) {
+    const items = this.shadowRoot.querySelectorAll('.menu-item');
+    items[i].click();
+  }
+
+  /**
+   * @param {MouseEvent} evt
+   * @private
+   */
+  _closeOnOutsideClick(evt) {
+    if (!this.opened) return;
+
+    const hasMenu = evt.composedPath().find(
+        (node) => {
+          return node === this;
+        },
+    );
+    if (hasMenu) return;
+
+    this.close();
+  }
+}
+
+customElements.define('mr-dropdown', MrDropdown);
diff --git a/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js b/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js
new file mode 100644
index 0000000..51f8ce9
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js
@@ -0,0 +1,276 @@
+// 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 {MrDropdown, SCREENREADER_ATTRIBUTE_ERROR} from './mr-dropdown.js';
+import sinon from 'sinon';
+
+let element;
+let randomButton;
+
+describe('mr-dropdown', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-dropdown');
+    document.body.appendChild(element);
+    element.label = 'new dropdown';
+
+    randomButton = document.createElement('button');
+    document.body.appendChild(randomButton);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    document.body.removeChild(randomButton);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrDropdown);
+  });
+
+  it('warns users about accessibility when no label or text', async () => {
+    element.label = 'ok';
+    sinon.spy(console, 'error');
+
+    await element.updateComplete;
+    sinon.assert.notCalled(console.error);
+
+    element.label = undefined;
+
+    await element.updateComplete;
+    sinon.assert.calledWith(console.error, SCREENREADER_ATTRIBUTE_ERROR);
+
+    console.error.restore();
+  });
+
+  it('toggle changes opened state', () => {
+    element.open();
+    assert.isTrue(element.opened);
+
+    element.close();
+    assert.isFalse(element.opened);
+
+    element.toggle();
+    assert.isTrue(element.opened);
+
+    element.toggle();
+    assert.isFalse(element.opened);
+
+    element.toggle();
+    element.toggle();
+    assert.isFalse(element.opened);
+  });
+
+  it('clicking outside element closes menu', () => {
+    element.open();
+    assert.isTrue(element.opened);
+
+    randomButton.click();
+
+    assert.isFalse(element.opened);
+  });
+
+  it('escape while focusing the anchor closes menu', async () => {
+    element.open();
+    await element.updateComplete;
+
+    assert.isTrue(element.opened);
+
+    const anchor = element.shadowRoot.querySelector('.anchor');
+    anchor.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+
+    assert.isFalse(element.opened);
+  });
+
+  it('other key while focusing the anchor does not close menu', async () => {
+    element.open();
+    await element.updateComplete;
+
+    assert.isTrue(element.opened);
+
+    const anchor = element.shadowRoot.querySelector('.anchor');
+    anchor.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
+
+    assert.isTrue(element.opened);
+  });
+
+  it('escape while focusing an item closes the menu', async () => {
+    element.items = [{text: 'An item'}];
+    element.open();
+    await element.updateComplete;
+
+    assert.isTrue(element.opened);
+
+    const item = element.shadowRoot.querySelector('.menu-item');
+    item.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+
+    assert.isFalse(element.opened);
+  });
+
+  it('icon hidden when undefined', async () => {
+    element.items = [
+      {text: 'test'},
+    ];
+
+    await element.updateComplete;
+
+    const icon = element.shadowRoot.querySelector(
+        '.menu-item > .material-icons');
+
+    assert.isTrue(icon.hidden);
+  });
+
+  it('icon shown when defined, even as empty string', async () => {
+    element.items = [
+      {text: 'test', icon: ''},
+    ];
+
+    await element.updateComplete;
+
+    const icon = element.shadowRoot.querySelector(
+        '.menu-item > .material-icons');
+
+    assert.isFalse(icon.hidden);
+    assert.equal(icon.textContent.trim(), '');
+  });
+
+  it('icon shown when set to material icon', async () => {
+    element.items = [
+      {text: 'test', icon: 'check'},
+    ];
+
+    await element.updateComplete;
+
+    const icon = element.shadowRoot.querySelector(
+        '.menu-item > .material-icons');
+
+    assert.isFalse(icon.hidden);
+    assert.equal(icon.textContent.trim(), 'check');
+  });
+
+  it('items with handlers are handled', async () => {
+    const handler1 = sinon.spy();
+    const handler2 = sinon.spy();
+    const handler3 = sinon.spy();
+
+    element.items = [
+      {
+        url: '#',
+        text: 'blah',
+        handler: handler1,
+      },
+      {
+        url: '#',
+        text: 'rutabaga noop',
+        handler: handler2,
+      },
+      {
+        url: '#',
+        text: 'click me please',
+        handler: handler3,
+      },
+    ];
+
+    element.open();
+
+    await element.updateComplete;
+
+    element.clickItem(0);
+
+    assert.isTrue(handler1.calledOnce);
+    assert.isFalse(handler2.called);
+    assert.isFalse(handler3.called);
+
+    element.clickItem(2);
+
+    assert.isTrue(handler1.calledOnce);
+    assert.isFalse(handler2.called);
+    assert.isTrue(handler3.calledOnce);
+  });
+
+  describe('nested dropdown menus', () => {
+    beforeEach(() => {
+      element.items = [
+        {
+          text: 'test',
+          items: [
+            {text: 'item 1'},
+            {text: 'item 2'},
+            {text: 'item 3'},
+          ],
+        },
+      ];
+
+      element.open();
+    });
+
+    it('nested dropdown menu renders', async () => {
+      await element.updateComplete;
+
+      const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+
+      assert.equal(nestedDropdown.text, 'test');
+      assert.deepEqual(nestedDropdown.items, [
+        {text: 'item 1'},
+        {text: 'item 2'},
+        {text: 'item 3'},
+      ]);
+    });
+
+    it('clicking nested item with handler calls handler', async () => {
+      const handler = sinon.stub();
+      element.items = [{
+        text: 'test',
+        items: [
+          {text: 'item 1'},
+          {
+            text: 'item with handler',
+            handler,
+          },
+        ],
+      }];
+
+      await element.updateComplete;
+
+      const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+
+      nestedDropdown.open();
+      await element.updateComplete;
+
+      // Clicking an unrelated nested item shouldn't call the handler.
+      nestedDropdown.clickItem(0);
+      // Nor should clicking the parent item call the handler.
+      element.clickItem(0);
+      sinon.assert.notCalled(handler);
+
+      element.open();
+      nestedDropdown.open();
+      await element.updateComplete;
+
+      nestedDropdown.clickItem(1);
+      sinon.assert.calledOnce(handler);
+    });
+
+    it('clicking nested dropdown menu toggles nested menu', async () => {
+      await element.updateComplete;
+
+      const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+      const nestedAnchor = nestedDropdown.shadowRoot.querySelector('.anchor');
+
+      assert.isTrue(element.opened);
+      assert.isFalse(nestedDropdown.opened);
+
+      nestedAnchor.click();
+      await element.updateComplete;
+
+      assert.isTrue(element.opened);
+      assert.isTrue(nestedDropdown.opened);
+
+      nestedAnchor.click();
+      await element.updateComplete;
+
+      assert.isTrue(element.opened);
+      assert.isFalse(nestedDropdown.opened);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-error/mr-error.js b/static_src/elements/framework/mr-error/mr-error.js
new file mode 100644
index 0000000..084a326
--- /dev/null
+++ b/static_src/elements/framework/mr-error/mr-error.js
@@ -0,0 +1,51 @@
+// 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';
+
+
+/**
+ * `<mr-error>`
+ *
+ * A container for showing errors.
+ *
+ */
+export class MrError extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: flex;
+        align-items: center;
+        flex-direction: row;
+        justify-content: flex-start;
+        box-sizing: border-box;
+        width: 100%;
+        margin: 0.5em 0;
+        padding: 0.25em 8px;
+        border: 1px solid #B71C1C;
+        border-radius: 4px;
+        background: #FFEBEE;
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      i.material-icons {
+        color: #B71C1C;
+        margin-right: 4px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <i class="material-icons">close</i>
+      <slot></slot>
+    `;
+  }
+}
+
+customElements.define('mr-error', MrError);
diff --git a/static_src/elements/framework/mr-header/mr-header.js b/static_src/elements/framework/mr-header/mr-header.js
new file mode 100644
index 0000000..6603c85
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.js
@@ -0,0 +1,427 @@
+// 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 userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import 'elements/framework/mr-keystrokes/mr-keystrokes.js';
+import '../mr-dropdown/mr-dropdown.js';
+import '../mr-dropdown/mr-account-dropdown.js';
+import './mr-search-bar.js';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import {logEvent} from 'monitoring/client-logger.js';
+
+/**
+ * @type {Object<string, string>} JS coding of enum values from
+ *    appengine/monorail/api/v3/api_proto/project_objects.proto.
+ */
+const projectRoles = Object.freeze({
+  OWNER: 'Owner',
+  MEMBER: 'Member',
+  CONTRIBUTOR: 'Contributor',
+  NONE: '',
+});
+
+/**
+ * `<mr-header>`
+ *
+ * The header for Monorail.
+ *
+ */
+export class MrHeader extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          color: var(--chops-header-text-color);
+          box-sizing: border-box;
+          background: hsl(221, 67%, 92%);
+          width: 100%;
+          height: var(--monorail-header-height);
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          z-index: 800;
+          background-color: var(--chops-primary-header-bg);
+          border-bottom: var(--chops-normal-border);
+          top: 0;
+          position: fixed;
+          padding: 0 4px;
+          font-size: var(--chops-large-font-size);
+        }
+        @media (max-width: 840px) {
+          :host {
+            position: static;
+          }
+        }
+        a {
+          font-size: inherit;
+          color: var(--chops-link-color);
+          text-decoration: none;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          height: 100%;
+          padding: 0 4px;
+          flex-grow: 0;
+          flex-shrink: 0;
+        }
+        a[hidden] {
+          display: none;
+        }
+        a.button {
+          font-size: inherit;
+          height: auto;
+          margin: 0 8px;
+          border: 0;
+          height: 30px;
+        }
+        .home-link {
+          color: var(--chops-gray-900);
+          letter-spacing: 0.5px;
+          font-size: 18px;
+          font-weight: 400;
+          display: flex;
+          font-stretch: 100%;
+          padding-left: 8px;
+        }
+        a.home-link img {
+          /** Cover up default padding with the custom logo. */
+          margin-left: -8px;
+        }
+        a.home-link:hover {
+          text-decoration: none;
+        }
+        mr-search-bar {
+          margin-left: 8px;
+          flex-grow: 2;
+          max-width: 1000px;
+        }
+        i.material-icons {
+          font-size: var(--chops-icon-font-size);
+          color: var(--chops-primary-icon-color);
+        }
+        i.material-icons[hidden] {
+          display: none;
+        }
+        .right-section {
+          font-size: inherit;
+          display: flex;
+          align-items: center;
+          height: 100%;
+          margin-left: auto;
+          justify-content: flex-end;
+        }
+        .hamburger-icon:hover {
+          text-decoration: none;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return this.projectName ?
+        this._renderProjectScope() : this._renderNonProjectScope();
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderProjectScope() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <mr-keystrokes
+        .issueId=${this.queryParams.id}
+        .queryParams=${this.queryParams}
+        .issueEntryUrl=${this.issueEntryUrl}
+      ></mr-keystrokes>
+      <a href="/p/${this.projectName}/issues/list" class="home-link">
+        ${this.projectThumbnailUrl ? html`
+          <img
+            class="project-logo"
+            src=${this.projectThumbnailUrl}
+            title=${this.projectName}
+          />
+        ` : this.projectName}
+      </a>
+      <mr-dropdown
+        class="project-selector"
+        .text=${this.projectName}
+        .items=${this._projectDropdownItems}
+        menuAlignment="left"
+        title=${this.presentationConfig.projectSummary}
+      ></mr-dropdown>
+      <a class="button emphasized new-issue-link" href=${this.issueEntryUrl}>
+        New issue
+      </a>
+      <mr-search-bar
+        .projectName=${this.projectName}
+        .userDisplayName=${this.userDisplayName}
+        .projectSavedQueries=${this.presentationConfig.savedQueries}
+        .initialCan=${this._currentCan}
+        .initialQuery=${this._currentQuery}
+        .queryParams=${this.queryParams}
+      ></mr-search-bar>
+
+      <div class="right-section">
+        <mr-dropdown
+          icon="settings"
+          label="Project Settings"
+          .items=${this._projectSettingsItems}
+        ></mr-dropdown>
+
+        ${this._renderAccount()}
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderNonProjectScope() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <a class="hamburger-icon" title="Main menu" hidden>
+        <i class="material-icons">menu</i>
+      </a>
+      ${this._headerTitle ?
+          html`<span class="home-link">${this._headerTitle}</span>` :
+          html`<a href="/" class="home-link">Monorail</a>`}
+
+      <div class="right-section">
+        ${this._renderAccount()}
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderAccount() {
+    if (!this.userDisplayName) {
+      return html`<a href=${this.loginUrl}>Sign in</a>`;
+    }
+
+    return html`
+      <mr-account-dropdown
+        .userDisplayName=${this.userDisplayName}
+        .logoutUrl=${this.logoutUrl}
+        .loginUrl=${this.loginUrl}
+      ></mr-account-dropdown>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      loginUrl: {type: String},
+      logoutUrl: {type: String},
+      projectName: {type: String},
+      // Project thumbnail is set separately from presentationConfig to prevent
+      // "flashing" logo when navigating EZT pages.
+      projectThumbnailUrl: {type: String},
+      userDisplayName: {type: String},
+      isSiteAdmin: {type: Boolean},
+      userProjects: {type: Object},
+      presentationConfig: {type: Object},
+      queryParams: {type: Object},
+      // TODO(zhangtiff): Change this to be dynamically computed by the
+      //   frontend with logic similar to ComputeIssueEntryURL().
+      issueEntryUrl: {type: String},
+      clientLogger: {type: Object},
+      _headerTitle: {type: String},
+      _currentQuery: {type: String},
+      _currentCan: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.presentationConfig = {};
+    this.userProjects = {};
+    this.isSiteAdmin = false;
+
+    this._headerTitle = '';
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.projectName = projectV0.viewedProjectName(state);
+
+    this.userProjects = userV0.projects(state);
+
+    const currentUser = userV0.currentUser(state);
+    this.isSiteAdmin = currentUser ? currentUser.isSiteAdmin : false;
+
+    const presentationConfig = projectV0.viewedPresentationConfig(state);
+    this.presentationConfig = presentationConfig;
+    // Set separately in order allow EZT pages to load project logo before
+    // the GetPresentationConfig pRPC request.
+    this.projectThumbnailUrl = presentationConfig.projectThumbnailUrl;
+
+    this._headerTitle = sitewide.headerTitle(state);
+
+    this._currentQuery = sitewide.currentQuery(state);
+    this._currentCan = sitewide.currentCan(state);
+
+    this.queryParams = sitewide.queryParams(state);
+  }
+
+  /**
+   * @return {boolean} whether the currently logged in user has admin
+   *   privileges for the currently viewed project.
+   */
+  get canAdministerProject() {
+    if (!this.userDisplayName) return false; // Not logged in.
+    if (this.isSiteAdmin) return true;
+    if (!this.userProjects || !this.userProjects.ownerOf) return false;
+    return this.userProjects.ownerOf.includes(this.projectName);
+  }
+
+  /**
+   * @return {string} The name of the role the user has in the viewed project.
+   */
+  get roleInCurrentProject() {
+    if (!this.userProjects || !this.projectName) return projectRoles.NONE;
+    const {ownerOf = [], memberOf = [], contributorTo = []} = this.userProjects;
+
+    if (ownerOf.includes(this.projectName)) return projectRoles.OWNER;
+    if (memberOf.includes(this.projectName)) return projectRoles.MEMBER;
+    if (contributorTo.includes(this.projectName)) {
+      return projectRoles.CONTRIBUTOR;
+    }
+
+    return projectRoles.NONE;
+  }
+
+  // TODO(crbug.com/monorail/6891): Remove once we deprecate the old issue
+  // filing wizard.
+  /**
+   * @return {string} A URL for the page the issue filing wizard posts to.
+   */
+  get _wizardPostUrl() {
+    // The issue filing wizard posts to the legacy issue entry page's ".do"
+    // endpoint.
+    return `${this._origin}/p/${this.projectName}/issues/entry.do`;
+  }
+
+  /**
+   * @return {string} The domain name of the current page.
+   */
+  get _origin() {
+    return window.location.origin;
+  }
+
+  /**
+   * Computes the URL the user should see to a file an issue, accounting
+   * for the case where a project has a customIssueEntryUrl to navigate to
+   * the wizard as well.
+   * @return {string} The URL that "New issue" button goes to.
+   */
+  get issueEntryUrl() {
+    const config = this.presentationConfig;
+    const role = this.roleInCurrentProject;
+    const mayBeRedirectedToWizard = role === projectRoles.NONE;
+    if (!this.userDisplayName || !config || !config.customIssueEntryUrl ||
+        !mayBeRedirectedToWizard) {
+      return `/p/${this.projectName}/issues/entry`;
+    }
+
+    const token = prpcClient.token;
+
+    const customUrl = this.presentationConfig.customIssueEntryUrl;
+
+    return `${customUrl}?token=${token}&role=${
+      role}&continue=${this._wizardPostUrl}`;
+  }
+
+  /**
+   * @return {Array<MenuItem>} the dropdown items for the project selector,
+   *   showing which projects a user can switch to.
+   */
+  get _projectDropdownItems() {
+    const {userProjects, loginUrl} = this;
+    if (!this.userDisplayName) {
+      return [{text: 'Sign in to see your projects', url: loginUrl}];
+    }
+
+    const items = [];
+    const starredProjects = userProjects.starredProjects || [];
+    const projects = (userProjects.ownerOf || [])
+        .concat(userProjects.memberOf || [])
+        .concat(userProjects.contributorTo || []);
+
+    if (projects.length) {
+      projects.sort();
+      items.push({text: 'My Projects', separator: true});
+
+      projects.forEach((project) => {
+        items.push({text: project, url: `/p/${project}/issues/list`});
+      });
+    }
+
+    if (starredProjects.length) {
+      starredProjects.sort();
+      items.push({text: 'Starred Projects', separator: true});
+
+      starredProjects.forEach((project) => {
+        items.push({text: project, url: `/p/${project}/issues/list`});
+      });
+    }
+
+    if (items.length) {
+      items.push({separator: true});
+    }
+
+    items.push({text: 'All projects', url: '/hosting/'});
+    items.forEach((item) => {
+      item.handler = () => this._projectChangedHandler(item.url);
+    });
+    return items;
+  }
+
+  /**
+   * @return {Array<MenuItem>} dropdown menu items to show in the project
+   *   settings menu.
+   */
+  get _projectSettingsItems() {
+    const {projectName, canAdministerProject} = this;
+    const items = [
+      {text: 'People', url: `/p/${projectName}/people/list`},
+      {text: 'Development Process', url: `/p/${projectName}/adminIntro`},
+      {text: 'History', url: `/p/${projectName}/updates/list`},
+    ];
+
+    if (canAdministerProject) {
+      items.push({separator: true});
+      items.push({text: 'Administer', url: `/p/${projectName}/admin`});
+    }
+    return items;
+  }
+
+  /**
+   * Records Google Analytics events for when users change projects using
+   * the selector.
+   * @param {string} url which project URL the user is navigating to.
+   */
+  _projectChangedHandler(url) {
+    // Just log it to GA and continue.
+    logEvent('mr-header', 'project-change', url);
+  }
+}
+
+customElements.define('mr-header', MrHeader);
diff --git a/static_src/elements/framework/mr-header/mr-header.test.js b/static_src/elements/framework/mr-header/mr-header.test.js
new file mode 100644
index 0000000..277347f
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.test.js
@@ -0,0 +1,191 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+import {MrHeader} from './mr-header.js';
+
+
+window.CS_env = {
+  token: 'foo-token',
+};
+
+let element;
+
+describe('mr-header', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-header');
+    document.body.appendChild(element);
+
+    window.ga = sinon.stub();
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrHeader);
+  });
+
+  it('presentationConfig renders', async () => {
+    element.projectName = 'best-project';
+    element.projectThumbnailUrl = 'http://images.google.com/';
+    element.presentationConfig = {
+      projectSummary: 'The best project',
+    };
+
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelector('.project-logo').src,
+        'http://images.google.com/');
+
+    assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+        '/p/best-project/issues/entry');
+
+    assert.equal(element.shadowRoot.querySelector('.project-selector').title,
+        'The best project');
+  });
+
+  describe('issueEntryUrl', () => {
+    let oldToken;
+
+    beforeEach(() => {
+      oldToken = prpcClient.token;
+      prpcClient.token = 'token1';
+
+      element.projectName = 'proj';
+
+      sinon.stub(element, '_origin').get(() => 'http://localhost');
+    });
+
+    afterEach(() => {
+      prpcClient.token = oldToken;
+    });
+
+    it('updates on project change', async () => {
+      await element.updateComplete;
+
+      assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+          '/p/proj/issues/entry');
+
+      element.projectName = 'the-best-project';
+
+      await element.updateComplete;
+
+      assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+          '/p/the-best-project/issues/entry');
+    });
+
+    it('generates wizard URL when customIssueEntryUrl defined', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {ownerOf: ['not-proj']};
+      element.userDisplayName = 'test@example.com';
+      assert.equal(element.issueEntryUrl,
+          'https://issue.wizard?token=token1&role=&' +
+          'continue=http://localhost/p/proj/issues/entry.do');
+    });
+
+    it('uses default issue filing URL when user is not logged in', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userDisplayName = '';
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project owner', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {ownerOf: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project member', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {memberOf: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project contributor', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {contributorTo: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+  });
+
+
+  it('canAdministerProject is false when user is not logged in', () => {
+    element.userDisplayName = '';
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('canAdministerProject is true when user is site admin', () => {
+    element.userDisplayName = 'test@example.com';
+    element.isSiteAdmin = true;
+
+    assert.isTrue(element.canAdministerProject);
+
+    element.isSiteAdmin = false;
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('canAdministerProject is true when user is owner', () => {
+    element.userDisplayName = 'test@example.com';
+    element.isSiteAdmin = false;
+
+    element.projectName = 'chromium';
+    element.userProjects = {ownerOf: ['chromium']};
+
+    assert.isTrue(element.canAdministerProject);
+
+    element.projectName = 'v8';
+
+    assert.isFalse(element.canAdministerProject);
+
+    element.userProjects = {memberOf: ['v8']};
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('_projectDropdownItems tells user to sign in if not logged in', () => {
+    element.userDisplayName = '';
+    element.loginUrl = 'http://login';
+
+    const items = element._projectDropdownItems;
+
+    // My Projects
+    assert.deepEqual(items[0], {
+      text: 'Sign in to see your projects',
+      url: 'http://login',
+    });
+  });
+
+  it('_projectDropdownItems computes projects for user', () => {
+    element.userProjects = {
+      ownerOf: ['chromium'],
+      memberOf: ['v8'],
+      contributorTo: ['skia'],
+      starredProjects: ['gerrit'],
+    };
+    element.userDisplayName = 'test@example.com';
+
+    const items = element._projectDropdownItems;
+
+    // TODO(http://crbug.com/monorail/6236): Replace these checks with
+    // deepInclude once we upgrade Chai.
+    // My Projects
+    assert.equal(items[1].text, 'chromium');
+    assert.equal(items[1].url, '/p/chromium/issues/list');
+    assert.equal(items[2].text, 'skia');
+    assert.equal(items[2].url, '/p/skia/issues/list');
+    assert.equal(items[3].text, 'v8');
+    assert.equal(items[3].url, '/p/v8/issues/list');
+
+    // Starred Projects
+    assert.equal(items[5].text, 'gerrit');
+    assert.equal(items[5].url, '/p/gerrit/issues/list');
+  });
+});
diff --git a/static_src/elements/framework/mr-header/mr-search-bar.js b/static_src/elements/framework/mr-header/mr-search-bar.js
new file mode 100644
index 0000000..536dfcf
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-search-bar.js
@@ -0,0 +1,501 @@
+// 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 '../mr-dropdown/mr-dropdown.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import ClientLogger from 'monitoring/client-logger';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+
+// Search field input regex testing for all digits
+// indicating that the user wants to jump to the specified issue.
+const JUMP_RE = /^\d+$/;
+
+/**
+ * `<mr-search-bar>`
+ *
+ * The searchbar for Monorail.
+ *
+ */
+export class MrSearchBar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --mr-search-bar-background: var(--chops-white);
+        --mr-search-bar-border-radius: 4px;
+        --mr-search-bar-border: var(--chops-normal-border);
+        --mr-search-bar-chip-color: var(--chops-gray-200);
+        height: 30px;
+        font-size: var(--chops-large-font-size);
+      }
+      input#searchq {
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        flex-grow: 2;
+        min-width: 100px;
+        border: none;
+        border-top: var(--mr-search-bar-border);
+        border-bottom: var(--mr-search-bar-border);
+        background: var(--mr-search-bar-background);
+        height: 100%;
+        box-sizing: border-box;
+        padding: 0 2px;
+        font-size: inherit;
+      }
+      mr-dropdown {
+        text-align: right;
+        display: flex;
+        text-overflow: ellipsis;
+        box-sizing: border-box;
+        background: var(--mr-search-bar-background);
+        border: var(--mr-search-bar-border);
+        border-left: 0;
+        border-radius: 0 var(--mr-search-bar-border-radius)
+          var(--mr-search-bar-border-radius) 0;
+        height: 100%;
+        align-items: center;
+        justify-content: center;
+        text-decoration: none;
+      }
+      button {
+        font-size: inherit;
+        order: -1;
+        background: var(--mr-search-bar-background);
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        height: 100%;
+        box-sizing: border-box;
+        border: var(--mr-search-bar-border);
+        border-left: none;
+        border-right: none;
+        padding: 0 8px;
+      }
+      form {
+        display: flex;
+        height: 100%;
+        width: 100%;
+        align-items: center;
+        justify-content: flex-start;
+        flex-direction: row;
+      }
+      i.material-icons {
+        font-size: var(--chops-icon-font-size);
+        color: var(--chops-primary-icon-color);
+      }
+      .select-container {
+        order: -2;
+        max-width: 150px;
+        min-width: 50px;
+        flex-shrink: 1;
+        height: 100%;
+        position: relative;
+        box-sizing: border-box;
+        border: var(--mr-search-bar-border);
+        border-radius: var(--mr-search-bar-border-radius) 0 0
+          var(--mr-search-bar-border-radius);
+        background: var(--mr-search-bar-chip-color);
+      }
+      .select-container i.material-icons {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        position: absolute;
+        right: 0;
+        top: 0;
+        height: 100%;
+        width: 20px;
+        z-index: 2;
+        padding: 0;
+      }
+      select {
+        color: var(--chops-primary-font-color);
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        -webkit-appearance: none;
+        -moz-appearance: none;
+        appearance: none;
+        text-overflow: ellipsis;
+        cursor: pointer;
+        width: 100%;
+        height: 100%;
+        background: none;
+        margin: 0;
+        padding: 0 20px 0 8px;
+        box-sizing: border-box;
+        border: 0;
+        z-index: 3;
+        font-size: inherit;
+        position: relative;
+      }
+      select::-ms-expand {
+        display: none;
+      }
+      select::after {
+        position: relative;
+        right: 0;
+        content: 'arrow_drop_down';
+        font-family: 'Material Icons';
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <form
+        @submit=${this._submitSearch}
+        @keypress=${this._submitSearchWithKeypress}
+      >
+        ${this._renderSearchScopeSelector()}
+        <input
+          id="searchq"
+          type="text"
+          name="q"
+          placeholder="Search ${this.projectName} issues..."
+          .value=${this.initialQuery || ''}
+          autocomplete="off"
+          aria-label="Search box"
+          @focus=${this._searchEditStarted}
+          @blur=${this._searchEditFinished}
+          spellcheck="false"
+        />
+        <button type="submit">
+          <i class="material-icons">search</i>
+        </button>
+        <mr-dropdown
+          label="Search options"
+          .items=${this._searchMenuItems}
+        ></mr-dropdown>
+      </form>
+    `;
+  }
+
+  /**
+   * Render helper for the select menu that lets user select which search
+   * context/saved query they want to use.
+   * @return {TemplateResult}
+   */
+  _renderSearchScopeSelector() {
+    return html`
+      <div class="select-container">
+        <i class="material-icons" role="presentation">arrow_drop_down</i>
+        <select
+          id="can"
+          name="can"
+          @change=${this._redirectOnSelect}
+          aria-label="Search scope"
+        >
+          <optgroup label="Search within">
+            <option
+              value="1"
+              ?selected=${this.initialCan === '1'}
+            >All issues</option>
+            <option
+              value="2"
+              ?selected=${this.initialCan === '2'}
+            >Open issues</option>
+            <option
+              value="3"
+              ?selected=${this.initialCan === '3'}
+            >Open and owned by me</option>
+            <option
+              value="4"
+              ?selected=${this.initialCan === '4'}
+            >Open and reported by me</option>
+            <option
+              value="5"
+              ?selected=${this.initialCan === '5'}
+            >Open and starred by me</option>
+            <option
+              value="8"
+              ?selected=${this.initialCan === '8'}
+            >Open with comment by me</option>
+            <option
+              value="6"
+              ?selected=${this.initialCan === '6'}
+            >New issues</option>
+            <option
+              value="7"
+              ?selected=${this.initialCan === '7'}
+            >Issues to verify</option>
+          </optgroup>
+          <optgroup label="Project queries" ?hidden=${!this.userDisplayName}>
+            ${this._renderSavedQueryOptions(this.projectSavedQueries, 'project-query')}
+            <option data-href="/p/${this.projectName}/adminViews">
+              Manage project queries...
+            </option>
+          </optgroup>
+          <optgroup label="My saved queries" ?hidden=${!this.userDisplayName}>
+            ${this._renderSavedQueryOptions(this.userSavedQueries, 'user-query')}
+            <option data-href="/u/${this.userDisplayName}/queries">
+              Manage my saved queries...
+            </option>
+          </optgroup>
+        </select>
+      </div>
+    `;
+  }
+
+  /**
+   * Render helper for adding saved queries to the search scope select.
+   * @param {Array<SavedQuery>} queries Queries to render.
+   * @param {string} className CSS class to be applied to each option.
+   * @return {Array<TemplateResult>}
+   */
+  _renderSavedQueryOptions(queries, className) {
+    if (!queries) return;
+    return queries.map((query) => html`
+      <option
+        class=${className}
+        value=${query.queryId}
+        ?selected=${this.initialCan === query.queryId}
+      >${query.name}</option>
+    `);
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      projectName: {type: String},
+      userDisplayName: {type: String},
+      initialCan: {type: String},
+      initialQuery: {type: String},
+      projectSavedQueries: {type: Array},
+      userSavedQueries: {type: Array},
+      queryParams: {type: Object},
+      keptQueryParams: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.queryParams = {};
+    this.keptQueryParams = [
+      'sort',
+      'groupby',
+      'colspec',
+      'x',
+      'y',
+      'mode',
+      'cells',
+      'num',
+    ];
+    this.initialQuery = '';
+    this.initialCan = '2';
+    this.projectSavedQueries = [];
+    this.userSavedQueries = [];
+
+    this.clientLogger = new ClientLogger('issues');
+
+    this._page = page;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Global event listeners. Make sure to unbind these when the
+    // element disconnects.
+    this._boundFocus = this.focus.bind(this);
+    window.addEventListener('focus-search', this._boundFocus);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener('focus-search', this._boundFocus);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (this.userDisplayName && changedProperties.has('userDisplayName')) {
+      const userSavedQueriesPromise = prpcClient.call('monorail.Users',
+          'GetSavedQueries', {});
+      userSavedQueriesPromise.then((resp) => {
+        this.userSavedQueries = resp.savedQueries;
+      });
+    }
+  }
+
+  /**
+   * Sends an event to ClientLogger describing that the user started typing
+   * a search query.
+   */
+  _searchEditStarted() {
+    this.clientLogger.logStart('query-edit', 'user-time');
+    this.clientLogger.logStart('issue-search', 'user-time');
+  }
+
+  /**
+   * Sends an event to ClientLogger saying that the user finished typing a
+   * search.
+   */
+  _searchEditFinished() {
+    this.clientLogger.logEnd('query-edit');
+  }
+
+  /**
+   * On Shift+Enter, this handler opens the search in a new tab.
+   * @param {KeyboardEvent} e
+   */
+  _submitSearchWithKeypress(e) {
+    if (e.key === 'Enter' && (e.shiftKey)) {
+      const form = e.currentTarget;
+      this._runSearch(form, true);
+    }
+    // In all other cases, we want to let the submit handler do the work.
+    // ie: pressing 'Enter' on a form should natively open it in a new tab.
+  }
+
+  /**
+   * Update the URL on form submit.
+   * @param {Event} e
+   */
+  _submitSearch(e) {
+    e.preventDefault();
+
+    const form = e.target;
+    this._runSearch(form);
+  }
+
+  /**
+   * Updates the URL with the new search set in the query string.
+   * @param {HTMLFormElement} form the native form element to submit.
+   * @param {boolean=} newTab whether to open the search in a new tab.
+   */
+  _runSearch(form, newTab) {
+    this.clientLogger.logEnd('query-edit');
+    this.clientLogger.logPause('issue-search', 'user-time');
+    this.clientLogger.logStart('issue-search', 'computer-time');
+
+    const params = {};
+
+    this.keptQueryParams.forEach((param) => {
+      if (param in this.queryParams) {
+        params[param] = this.queryParams[param];
+      }
+    });
+
+    params.q = form.q.value.trim();
+    params.can = form.can.value;
+
+    this._navigateToNext(params, newTab);
+  }
+
+  /**
+   * Attempt to jump-to-issue, otherwise continue to list view
+   * @param {Object} params URL navigation parameters
+   * @param {boolean} newTab
+   */
+  async _navigateToNext(params, newTab = false) {
+    let resp;
+    if (JUMP_RE.test(params.q)) {
+      const message = {
+        issueRef: {
+          projectName: this.projectName,
+          localId: params.q,
+        },
+      };
+
+      try {
+        resp = await prpcClient.call(
+            'monorail.Issues', 'GetIssue', message,
+        );
+      } catch (error) {
+        // Fall through to navigateToList
+      }
+    }
+    if (resp && resp.issue) {
+      const link = issueRefToUrl(resp.issue, params);
+      this._page(link);
+    } else {
+      this._navigateToList(params, newTab);
+    }
+  }
+
+  /**
+   * Navigate to list view, currently splits on old and new view
+   * @param {Object} params URL navigation parameters
+   * @param {boolean} newTab
+   * @fires Event#refreshList
+   * @private
+   */
+  _navigateToList(params, newTab = false) {
+    const pathname = `/p/${this.projectName}/issues/list`;
+
+    const hasChanges = !window.location.pathname.startsWith(pathname) ||
+      this.queryParams.q !== params.q ||
+      this.queryParams.can !== params.can;
+
+    const url =`${pathname}?${qs.stringify(params)}`;
+
+    if (newTab) {
+      window.open(url, '_blank', 'noopener');
+    } else if (hasChanges) {
+      this._page(url);
+    } else {
+      // TODO(zhangtiff): Replace this event with Redux once all of Monorail
+      // uses Redux.
+      // This is needed because navigating to the exact same page does not
+      // cause a URL change to happen.
+      this.dispatchEvent(new Event('refreshList',
+          {'composed': true, 'bubbles': true}));
+    }
+  }
+
+  /**
+   * Wrap the native focus() function for the search form to allow parent
+   * elements to focus the search.
+   */
+  focus() {
+    const search = this.shadowRoot.querySelector('#searchq');
+    search.focus();
+  }
+
+  /**
+   * Populates the search dropdown.
+   * @return {Array<MenuItem>}
+   */
+  get _searchMenuItems() {
+    const projectName = this.projectName;
+    return [
+      {
+        text: 'Advanced search',
+        url: `/p/${projectName}/issues/advsearch`,
+      },
+      {
+        text: 'Search tips',
+        url: `/p/${projectName}/issues/searchtips`,
+      },
+    ];
+  }
+
+  /**
+   * The search dropdown includes links like "Manage my saved queries..."
+   * that automatically navigate a user to a new page when they select those
+   * options.
+   * @param {Event} evt
+   */
+  _redirectOnSelect(evt) {
+    const target = evt.target;
+    const option = target.options[target.selectedIndex];
+
+    if (option.dataset.href) {
+      this._page(option.dataset.href);
+    }
+  }
+}
+
+customElements.define('mr-search-bar', MrSearchBar);
diff --git a/static_src/elements/framework/mr-header/mr-search-bar.test.js b/static_src/elements/framework/mr-header/mr-search-bar.test.js
new file mode 100644
index 0000000..c758a41
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-search-bar.test.js
@@ -0,0 +1,244 @@
+// 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 {MrSearchBar} from './mr-search-bar.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+import {clientLoggerFake} from 'shared/test/fakes.js';
+
+
+window.CS_env = {
+  token: 'foo-token',
+};
+
+let element;
+
+describe('mr-search-bar', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-search-bar');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrSearchBar);
+  });
+
+  it('render user saved queries', async () => {
+    element.userDisplayName = 'test@user.com';
+    element.userSavedQueries = [
+      {name: 'test query', queryId: 101},
+      {name: 'hello world', queryId: 202},
+    ];
+
+    await element.updateComplete;
+
+    const queryOptions = element.shadowRoot.querySelectorAll(
+        '.user-query');
+
+    assert.equal(queryOptions.length, 2);
+
+    assert.equal(queryOptions[0].value, '101');
+    assert.equal(queryOptions[0].textContent, 'test query');
+
+    assert.equal(queryOptions[1].value, '202');
+    assert.equal(queryOptions[1].textContent, 'hello world');
+  });
+
+  it('render project saved queries', async () => {
+    element.userDisplayName = 'test@user.com';
+    element.projectSavedQueries = [
+      {name: 'test query', queryId: 101},
+      {name: 'hello world', queryId: 202},
+    ];
+
+    await element.updateComplete;
+
+    const queryOptions = element.shadowRoot.querySelectorAll(
+        '.project-query');
+
+    assert.equal(queryOptions.length, 2);
+
+    assert.equal(queryOptions[0].value, '101');
+    assert.equal(queryOptions[0].textContent, 'test query');
+
+    assert.equal(queryOptions[1].value, '202');
+    assert.equal(queryOptions[1].textContent, 'hello world');
+  });
+
+  it('search input resets form value when initialQuery changes', async () => {
+    element.initialQuery = 'first query';
+    await element.updateComplete;
+
+    const queryInput = element.shadowRoot.querySelector('#searchq');
+
+    assert.equal(queryInput.value, 'first query');
+
+    // Simulate a user typing something into the search form.
+    queryInput.value = 'blah';
+
+    element.initialQuery = 'second query';
+    await element.updateComplete;
+
+    // 'blah' disappears because the new initialQuery causes the form to
+    // reset.
+    assert.equal(queryInput.value, 'second query');
+  });
+
+  it('unrelated property changes do not reset query form', async () => {
+    element.initialQuery = 'first query';
+    await element.updateComplete;
+
+    const queryInput = element.shadowRoot.querySelector('#searchq');
+
+    assert.equal(queryInput.value, 'first query');
+
+    // Simulate a user typing something into the search form.
+    queryInput.value = 'blah';
+
+    element.initialCan = '5';
+    await element.updateComplete;
+
+    assert.equal(queryInput.value, 'blah');
+  });
+
+  it('spell check is off for search bar', async () => {
+    await element.updateComplete;
+    const searchElement = element.shadowRoot.querySelector('#searchq');
+    assert.equal(searchElement.getAttribute('spellcheck'), 'false');
+  });
+
+  describe('search form submit', () => {
+    let prpcClientStub;
+    beforeEach(() => {
+      element.clientLogger = clientLoggerFake();
+
+      element._page = sinon.stub();
+      sinon.stub(window, 'open');
+
+      element.projectName = 'chromium';
+      prpcClientStub = sinon.stub(prpcClient, 'call');
+    });
+
+    afterEach(() => {
+      window.open.restore();
+      prpcClient.call.restore();
+    });
+
+    it('prevents default', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      // Note: HTMLFormElement's submit function does not run submit handlers
+      // but clicking a submit buttons programmatically works.
+      const event = new Event('submit');
+      sinon.stub(event, 'preventDefault');
+      form.dispatchEvent(event);
+
+      sinon.assert.calledOnce(event.preventDefault);
+    });
+
+    it('uses initial values when no form changes', async () => {
+      element.initialQuery = 'test query';
+      element.initialCan = '3';
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=test%20query&can=3');
+    });
+
+    it('adds form values to url', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = 'test';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=test&can=1');
+    });
+
+    it('trims query', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = '  abc  ';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=abc&can=1');
+    });
+
+    it('jumps to issue for digit-only query', async () => {
+      prpcClientStub.returns(Promise.resolve({issue: 'hello world'}));
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = '123';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      await element._navigateToNext;
+
+      const expected = issueRefToUrl('hello world', {q: '123', can: '1'});
+      sinon.assert.calledWith(element._page, expected);
+    });
+
+    it('only keeps kept query params', async () => {
+      element.queryParams = {fakeParam: 'test', x: 'Status'};
+      element.keptParams = ['x'];
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?x=Status&q=&can=2');
+    });
+
+    it('on shift+enter opens search in new tab', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = 'test';
+      form.can.value = '1';
+
+      // Dispatch event from an input in the form.
+      form.q.dispatchEvent(new KeyboardEvent('keypress',
+          {key: 'Enter', shiftKey: true, bubbles: true}));
+
+      sinon.assert.calledOnce(window.open);
+      sinon.assert.calledWith(window.open,
+          '/p/chromium/issues/list?q=test&can=1', '_blank', 'noopener');
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js
new file mode 100644
index 0000000..13f8267
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js
@@ -0,0 +1,62 @@
+// 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.
+
+/** @const {string} CSV download link's data href prefix, RFC 4810 Section 3 */
+export const CSV_DATA_HREF_PREFIX = 'data:text/csv;charset=utf-8,';
+
+/**
+ * Format array into plaintext csv
+ * @param {Array<Array>} data
+ * @return {string}
+ */
+export const convertListContentToCsv = (data) => {
+  const result = data.reduce((acc, row) => {
+    return `${acc}\r\n${row.map(preventCSVInjectionAndStringify).join(',')}`;
+  }, '');
+  // Remove leading /r and /n
+  return result.slice(2);
+};
+
+/**
+ * Prevent CSV injection, escape double quotes, and wrap with double quotes
+ * See owasp.org/index.php/CSV_Injection
+ * @param {string} cell
+ * @return {string}
+ */
+export const preventCSVInjectionAndStringify = (cell) => {
+  // Prepend all double quotes with another double quote, RFC 4810 Section 2.7
+  let escaped = cell.replace(/"/g, '""');
+
+  // prevent CSV injection: owasp.org/index.php/CSV_Injection
+  if (cell[0] === '=' ||
+      cell[0] === '+' ||
+      cell[0] === '-' ||
+      cell[0] === '@') {
+    escaped = `'${escaped}`;
+  }
+
+  // Wrap cell with double quotes, RFC 4810 Section 2.7
+  return `"${escaped}"`;
+};
+
+/**
+ * Prepare data for csv download by converting array of array into csv string
+ * @param {Array<Array<string>>} data
+ * @param {Array<string>=} headers Column headers
+ * @return {string} CSV formatted string
+ */
+export const prepareDataForDownload = (data, headers = []) => {
+  const mainContent = [headers, ...data];
+
+  return `${convertListContentToCsv(mainContent)}`;
+};
+
+/**
+ * Constructs download link url from csv string data.
+ * @param {string} data CSV data
+ * @return {string}
+ */
+export const constructHref = (data = '') => {
+  return `${CSV_DATA_HREF_PREFIX}${encodeURIComponent(data)}`;
+};
diff --git a/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js
new file mode 100644
index 0000000..cd124a5
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js
@@ -0,0 +1,145 @@
+// 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 {
+  constructHref,
+  convertListContentToCsv,
+  prepareDataForDownload,
+  preventCSVInjectionAndStringify,
+} from './list-to-csv-helpers.js';
+
+describe('constructHref', () => {
+  it('has default of empty string', () => {
+    const result = constructHref();
+    assert.equal(result, 'data:text/csv;charset=utf-8,');
+  });
+
+  it('starts with data:', () => {
+    const result = constructHref('');
+    assert.isTrue(result.startsWith('data:'));
+  });
+
+  it('uses charset=utf-8', () => {
+    const result = constructHref('');
+    assert.isTrue(result.search('charset=utf-8') > -1);
+  });
+
+  it('encodes URI component', () => {
+    const encodeFuncStub = sinon.stub(window, 'encodeURIComponent');
+    constructHref('');
+    sinon.assert.calledOnce(encodeFuncStub);
+
+    window.encodeURIComponent.restore();
+  });
+
+  it('encodes URI component', () => {
+    const input = 'foo, bar fizz=buzz';
+    const expected = 'foo%2C%20bar%20fizz%3Dbuzz';
+    const output = constructHref(input);
+
+    assert.equal(expected, output.split(',')[1]);
+  });
+});
+
+describe('convertListContentToCsv', () => {
+  it('joins rows with carriage return and line feed, CRLF', () => {
+    const input = [['foobar'], ['fizzbuzz']];
+    const expected = '"foobar"\r\n"fizzbuzz"';
+    assert.equal(expected, convertListContentToCsv(input));
+  });
+
+  it('joins columns with commas', () => {
+    const input = [['foo', 'bar', 'fizz', 'buzz']];
+    const expected = '"foo","bar","fizz","buzz"';
+    assert.equal(expected, convertListContentToCsv(input));
+  });
+
+  it('starts with non-empty row', () => {
+    const input = [['foobar']];
+    const expected = '"foobar"';
+    const result = convertListContentToCsv(input);
+    assert.equal(expected, result);
+    assert.isFalse(result.startsWith('\r\n'));
+  });
+});
+
+describe('prepareDataForDownload', () => {
+  it('prepends header row', () => {
+    const headers = ['column1', 'column2'];
+    const result = prepareDataForDownload([['a', 'b']], headers);
+
+    const expected = `"column1","column2"`;
+    assert.equal(expected, result.split('\r\n')[0]);
+    assert.isTrue(result.startsWith(expected));
+  });
+});
+
+describe('preventCSVInjectionAndStringify', () => {
+  it('prepends all double quotes with another double quote', () => {
+    let input = '"hello world"';
+    let expect = '""hello world""';
+    assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+
+    input = 'Just a double quote: " ';
+    expect = 'Just a double quote: "" ';
+    assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+
+    input = 'Multiple"double"quotes"""';
+    expect = 'Multiple""double""quotes""""""';
+    assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+  });
+
+  it('wraps string with double quotes', () => {
+    let input = '"hello world"';
+    let expected = preventCSVInjectionAndStringify(input);
+    assert.equal('"', expected[0]);
+    assert.equal('"', expected[expected.length-1]);
+
+    input = 'For unevent quotes too: " ';
+    expected = '"For unevent quotes too: "" "';
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+    input = 'And for ending quotes"""';
+    expected = '"And for ending quotes"""""""';
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+
+  it('wraps strings containing commas with double quotes', () => {
+    const input = 'Let\'s, add, a bunch, of, commas,';
+    const expected = '"Let\'s, add, a bunch, of, commas,"';
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+
+  it('can handle strings containing commas and new line chars', () => {
+    const input = `""new"",\r\nline  "" "",\r\nand 'end', and end`;
+    const expected = `"""""new"""",\r\nline  """" """",\r\nand 'end', and end"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+
+  it('preserves single quotes', () => {
+    let input = `all the 'single' quotes`;
+    let expected = `"all the 'single' quotes"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+    input = `''''' fives single quotes before and after '''''`;
+    expected = `"''''' fives single quotes before and after '''''"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+
+  it('prevents csv injection', () => {
+    let input = `@@Should prepend with single quote`;
+    let expected = `"'@@Should prepend with single quote"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+    input = `at symbol @ later on, do not expect ' at start`;
+    expected = `"at symbol @ later on, do not expect ' at start"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+    input = `==@+=--@Should prepend with single quote`;
+    expected = `"'==@+=--@Should prepend with single quote"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-list/mr-issue-list.js b/static_src/elements/framework/mr-issue-list/mr-issue-list.js
new file mode 100644
index 0000000..3e0a279
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-issue-list.js
@@ -0,0 +1,1575 @@
+// 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, store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-crbug-link/mr-crbug-link.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import {constructHref, prepareDataForDownload} from './list-to-csv-helpers.js';
+import {
+  issueRefToUrl,
+  issueRefToString,
+  issueStringToRef,
+  issueToIssueRef,
+  issueToIssueRefString,
+  labelRefsToOneWordLabels,
+} from 'shared/convertersV0.js';
+import {isTextInput, findDeepEventTarget} from 'shared/dom-helpers.js';
+import {
+  urlWithNewParams,
+  pluralize,
+  setHasAny,
+  objectValuesForKeys,
+} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {parseColSpec, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import './mr-show-columns-dropdown.js';
+
+/**
+ * Column to display name mapping dictionary
+ * @type {Object<string, string>}
+ */
+const COLUMN_DISPLAY_NAMES = Object.freeze({
+  'summary': 'Summary + Labels',
+});
+
+/** @const {number} Button property value of DOM click event */
+const PRIMARY_BUTTON = 0;
+/** @const {number} Button property value of DOM auxclick event */
+const MIDDLE_BUTTON = 1;
+
+/** @const {string} A short transition to ease movement of list items. */
+const EASE_OUT_TRANSITION = 'transform 0.05s cubic-bezier(0, 0, 0.2, 1)';
+
+/**
+ * Really high cardinality attributes like ID and Summary are unlikely to be
+ * useful if grouped, so it's better to just hide the option.
+ * @const {Set<string>}
+ */
+const UNGROUPABLE_COLUMNS = new Set(['id', 'summary']);
+
+/**
+ * Columns that should render as issue links.
+ * @const {Set<string>}
+ */
+const ISSUE_COLUMNS = new Set(['id', 'mergedinto', 'blockedon', 'blocking']);
+
+/**
+ * `<mr-issue-list>`
+ *
+ * A list of issues intended to be used in multiple contexts.
+ * @extends {LitElement}
+ */
+export class MrIssueList extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          width: 100%;
+          font-size: var(--chops-main-font-size);
+        }
+        table {
+          width: 100%;
+        }
+        .edit-widget-container {
+          display: flex;
+          flex-wrap: no-wrap;
+          align-items: center;
+        }
+        mr-issue-star {
+          --mr-star-size: 18px;
+          margin-bottom: 1px;
+          margin-left: 4px;
+        }
+        input[type="checkbox"] {
+          cursor: pointer;
+          margin: 0 4px;
+          width: 16px;
+          height: 16px;
+          border-radius: 2px;
+          box-sizing: border-box;
+          appearance: none;
+          -webkit-appearance: none;
+          border: 2px solid var(--chops-gray-400);
+          position: relative;
+          background: var(--chops-white);
+        }
+        th input[type="checkbox"] {
+          border-color: var(--chops-gray-500);
+        }
+        input[type="checkbox"]:checked {
+          background: var(--chops-primary-accent-color);
+          border-color: var(--chops-primary-accent-color);
+        }
+        input[type="checkbox"]:checked::after {
+          left: 1px;
+          top: 2px;
+          position: absolute;
+          content: "";
+          width: 8px;
+          height: 4px;
+          border: 2px solid white;
+          border-right: none;
+          border-top: none;
+          transform: rotate(-45deg);
+        }
+        td, th.group-header {
+          padding: 4px 8px;
+          text-overflow: ellipsis;
+          border-bottom: var(--chops-normal-border);
+          cursor: pointer;
+          font-weight: normal;
+        }
+        .group-header-content {
+          height: 100%;
+          width: 100%;
+          align-items: center;
+          display: flex;
+        }
+        th.group-header i.material-icons {
+          font-size: var(--chops-icon-font-size);
+          color: var(--chops-primary-icon-color);
+          margin-right: 4px;
+        }
+        td.ignore-navigation {
+          cursor: default;
+        }
+        th {
+          background: var(--chops-table-header-bg);
+          white-space: nowrap;
+          text-align: left;
+          border-bottom: var(--chops-normal-border);
+        }
+        th.selection-header {
+          padding: 3px 8px;
+        }
+        th > mr-dropdown, th > mr-show-columns-dropdown {
+          font-weight: normal;
+          color: var(--chops-link-color);
+          --mr-dropdown-icon-color: var(--chops-link-color);
+          --mr-dropdown-anchor-padding: 3px 8px;
+          --mr-dropdown-anchor-font-weight: bold;
+          --mr-dropdown-menu-min-width: 150px;
+        }
+        tr {
+          padding: 0 8px;
+        }
+        tr[selected] {
+          background: var(--chops-selected-bg);
+        }
+        td:first-child, th:first-child {
+          border-left: 4px solid transparent;
+        }
+        tr[cursored] > td:first-child {
+          border-left: 4px solid var(--chops-blue-700);
+        }
+        mr-crbug-link {
+          /* We need the shortlink to be hidden but still accessible.
+          * The opacity attribute visually hides a link while still
+          * keeping it in the DOM.opacity. */
+          --mr-crbug-link-opacity: 0;
+          --mr-crbug-link-opacity-focused: 1;
+        }
+        td:hover > mr-crbug-link {
+          --mr-crbug-link-opacity: 1;
+        }
+        .col-summary, .header-summary {
+          /* Setting a table cell to 100% width makes it take up
+          * all remaining space in the table, not the full width of
+          * the table. */
+          width: 100%;
+        }
+        .summary-label {
+          display: inline-block;
+          margin: 0 2px;
+          color: var(--chops-green-800);
+          text-decoration: none;
+          font-size: 90%;
+        }
+        .summary-label:hover {
+          text-decoration: underline;
+        }
+        td.draggable i {
+          opacity: 0;
+        }
+        td.draggable {
+          color: var(--chops-primary-icon-color);
+          cursor: grab;
+          padding-left: 0;
+          padding-right: 0;
+        }
+        tr.dragged {
+          opacity: 0.74;
+        }
+        tr:hover td.draggable i {
+          opacity: 1;
+        }
+        .csv-download-container {
+          border-bottom: none;
+          text-align: end;
+          cursor: default;
+        }
+        #hidden-data-link {
+          display: none;
+        }
+        @media (min-width: 1024px) {
+          .first-row th {
+            position: sticky;
+            top: var(--monorail-header-height);
+            z-index: 10;
+          }
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const selectAllChecked = this._selectedIssues.size > 0;
+    const checkboxLabel = `Select ${selectAllChecked ? 'None' : 'All'}`;
+
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <table cellspacing="0">
+        <thead>
+          <tr class="first-row">
+            ${this.rerank ? html`<th></th>` : ''}
+            <th class="selection-header">
+              <div class="edit-widget-container">
+                ${this.selectionEnabled ? html`
+                  <input
+                    class="select-all"
+                    .checked=${selectAllChecked}
+                    type="checkbox"
+                    aria-label=${checkboxLabel}
+                    title=${checkboxLabel}
+                    @change=${this._selectAll}
+                  />
+                ` : ''}
+              </div>
+            </th>
+            ${this.columns.map((column, i) => this._renderHeader(column, i))}
+            <th style="z-index: ${this.highestZIndex};">
+              <mr-show-columns-dropdown
+                title="Show columns"
+                menuAlignment="right"
+                .columns=${this.columns}
+                .issues=${this.issues}
+                .defaultFields=${this.defaultFields}
+              ></mr-show-columns-dropdown>
+            </th>
+          </tr>
+        </thead>
+        <tbody>
+          ${this._renderIssues()}
+        </tbody>
+        ${this.userDisplayName && html`
+          <tfoot><tr><td colspan=999 class="csv-download-container">
+            <a id="download-link" aria-label="Download page as CSV"
+                @click=${this._downloadCsv} href>CSV</a>
+            <a id="hidden-data-link" download="${this.projectName}-issues.csv"
+              href=${this._csvDataHref}></a>
+          </td></tr></tfoot>
+        `}
+      </table>
+    `;
+  }
+
+  /**
+   * @param {string} column
+   * @param {number} i The index of the column in the table.
+   * @return {TemplateResult} html for header for the i-th column.
+   * @private
+   */
+  _renderHeader(column, i) {
+    // zIndex is used to render the z-index property in descending order
+    const zIndex = this.highestZIndex - i;
+    const colKey = column.toLowerCase();
+    const name = colKey in COLUMN_DISPLAY_NAMES ? COLUMN_DISPLAY_NAMES[colKey] :
+      column;
+    return html`
+      <th style="z-index: ${zIndex};" class="header-${colKey}">
+        <mr-dropdown
+          class="dropdown-${colKey}"
+          .text=${name}
+          .items=${this._headerActions(column, i)}
+          menuAlignment="left"
+        ></mr-dropdown>
+      </th>`;
+  }
+
+  /**
+   * @param {string} column
+   * @param {number} i The index of the column in the table.
+   * @return {Array<Object>} Available actions for the column.
+   * @private
+   */
+  _headerActions(column, i) {
+    const columnKey = column.toLowerCase();
+
+    const isGroupable = this.sortingAndGroupingEnabled &&
+        !UNGROUPABLE_COLUMNS.has(columnKey);
+
+    let showOnly = [];
+    if (isGroupable) {
+      const values = [...this._uniqueValuesByColumn.get(columnKey)];
+      if (values.length) {
+        showOnly = [{
+          text: 'Show only',
+          items: values.map((v) => ({
+            text: v,
+            handler: () => this.showOnly(column, v),
+          })),
+        }];
+      }
+    }
+    const sortingActions = this.sortingAndGroupingEnabled ? [
+      {
+        text: 'Sort up',
+        handler: () => this.updateSortSpec(column),
+      },
+      {
+        text: 'Sort down',
+        handler: () => this.updateSortSpec(column, true),
+      },
+    ] : [];
+    const actions = [
+      ...sortingActions,
+      ...showOnly,
+      {
+        text: 'Hide column',
+        handler: () => this.removeColumn(i),
+      },
+    ];
+    if (isGroupable) {
+      actions.push({
+        text: 'Group rows',
+        handler: () => this.addGroupBy(i),
+      });
+    }
+    return actions;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderIssues() {
+    // Keep track of all the groups that we've seen so far to create
+    // group headers as needed.
+    const {issues, groupedIssues} = this;
+
+    if (groupedIssues) {
+      // Make sure issues in groups are rendered with unique indices across
+      // groups to make sure hot keys and the like still work.
+      let indexOffset = 0;
+      return html`${groupedIssues.map(({groupName, issues}) => {
+        const template = html`
+          ${this._renderGroup(groupName, issues, indexOffset)}
+        `;
+        indexOffset += issues.length;
+        return template;
+      })}`;
+    }
+
+    return html`
+      ${issues.map((issue, i) => this._renderRow(issue, i))}
+    `;
+  }
+
+  /**
+   * @param {string} groupName
+   * @param {Array<Issue>} issues
+   * @param {number} iOffset
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderGroup(groupName, issues, iOffset) {
+    if (!this.groups.length) return html``;
+
+    const count = issues.length;
+    const groupKey = groupName.toLowerCase();
+    const isHidden = this._hiddenGroups.has(groupKey);
+
+    return html`
+      <tr>
+        <th
+          class="group-header"
+          colspan="${this.numColumns}"
+          @click=${() => this._toggleGroup(groupKey)}
+          aria-expanded=${(!isHidden).toString()}
+        >
+          <div class="group-header-content">
+            <i
+              class="material-icons"
+              title=${isHidden ? 'Show' : 'Hide'}
+            >${isHidden ? 'add' : 'remove'}</i>
+            ${count} ${pluralize(count, 'issue')}: ${groupName}
+          </div>
+        </th>
+      </tr>
+      ${issues.map((issue, i) => this._renderRow(issue, iOffset + i, isHidden))}
+    `;
+  }
+
+  /**
+   * @param {string} groupKey Lowercase group key.
+   * @private
+   */
+  _toggleGroup(groupKey) {
+    if (this._hiddenGroups.has(groupKey)) {
+      this._hiddenGroups.delete(groupKey);
+    } else {
+      this._hiddenGroups.add(groupKey);
+    }
+
+    // Lit-element's default hasChanged check does not notice when Sets mutate.
+    this.requestUpdate('_hiddenGroups');
+  }
+
+  /**
+   * @param {Issue} issue
+   * @param {number} i Index within the list of issues
+   * @param {boolean=} isHidden
+   * @return {TemplateResult}
+   */
+  _renderRow(issue, i, isHidden = false) {
+    const rowSelected = this._selectedIssues.has(issueRefToString(issue));
+    const id = issueRefToString(issue);
+    const cursorId = issueRefToString(this.cursor);
+    const hasCursor = cursorId === id;
+    const dragged = this._dragging && rowSelected;
+
+    return html`
+      <tr
+        class="row-${i} list-row ${dragged ? 'dragged' : ''}"
+        ?selected=${rowSelected}
+        ?cursored=${hasCursor}
+        ?hidden=${isHidden}
+        data-issue-ref=${id}
+        data-index=${i}
+        data-name=${issue.name}
+        @focus=${this._setRowAsCursorOnFocus}
+        @click=${this._clickIssueRow}
+        @auxclick=${this._clickIssueRow}
+        @keydown=${this._keydownIssueRow}
+        tabindex="0"
+      >
+        ${this.rerank ? html`
+          <td class="draggable ignore-navigation"
+              @mousedown=${this._onMouseDown}>
+            <i class="material-icons" title="Drag issue">drag_indicator</i>
+          </td>
+        ` : ''}
+        <td class="ignore-navigation">
+          <div class="edit-widget-container">
+            ${this.selectionEnabled ? html`
+              <input
+                class="issue-checkbox"
+                .value=${id}
+                .checked=${rowSelected}
+                type="checkbox"
+                data-index=${i}
+                aria-label="Select Issue ${issue.localId}"
+                @change=${this._selectIssue}
+                @click=${this._selectIssueRange}
+              />
+            ` : ''}
+            ${this.starringEnabled ? html`
+              <mr-issue-star
+                .issueRef=${issueToIssueRef(issue)}
+              ></mr-issue-star>
+            ` : ''}
+          </div>
+        </td>
+
+        ${this.columns.map((column) => html`
+          <td class="col-${column.toLowerCase()}">
+            ${this._renderCell(column, issue)}
+          </td>
+        `)}
+
+        <td>
+          <mr-crbug-link .issue=${issue}></mr-crbug-link>
+        </td>
+      </tr>
+    `;
+  }
+
+  /**
+   * @param {string} column
+   * @param {Issue} issue
+   * @return {TemplateResult} Html for the given column for the given issue.
+   * @private
+   */
+  _renderCell(column, issue) {
+    const columnName = column.toLowerCase();
+    if (columnName === 'summary') {
+      return html`
+        ${issue.summary}
+        ${labelRefsToOneWordLabels(issue.labelRefs).map(({label}) => html`
+          <a
+            class="summary-label"
+            href="/p/${issue.projectName}/issues/list?q=label%3A${label}"
+          >${label}</a>
+        `)}
+      `;
+    }
+    const values = this.extractFieldValues(issue, column);
+
+    if (!values.length) return EMPTY_FIELD_VALUE;
+
+    // TODO(zhangtiff): Make this based on the "ISSUE" field type rather than a
+    // hardcoded list of issue fields.
+    if (ISSUE_COLUMNS.has(columnName)) {
+      return values.map((issueRefString, i) => {
+        const issue = this._issueForRefString(issueRefString, this.projectName);
+        return html`
+          <mr-issue-link
+            .projectName=${this.projectName}
+            .issue=${issue}
+            .queryParams=${this._queryParams}
+            short
+          ></mr-issue-link>${values.length - 1 > i ? ', ' : ''}
+        `;
+      });
+    }
+    return values.join(', ');
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Array of columns to display.
+       */
+      columns: {type: Array},
+      /**
+       * Array of built in fields that are available outside of project
+       * configuration.
+       */
+      defaultFields: {type: Array},
+      /**
+       * 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},
+      /**
+       * Array of columns that are used as groups for issues.
+       */
+      groups: {type: Array},
+      /**
+       * List of issues to display.
+       */
+      issues: {type: Array},
+      /**
+       * A Redux action creator that calls the API to rerank the issues
+       * in the list. If set, reranking is enabled for this issue list.
+       */
+      rerank: {type: Object},
+      /**
+       * Whether issues should be selectable or not.
+       */
+      selectionEnabled: {type: Boolean},
+      /**
+       * Whether issues should be sortable and groupable or not. This will
+       * change how column headers will be displayed. The ability to sort and
+       * group are currently coupled.
+       */
+      sortingAndGroupingEnabled: {type: Boolean},
+      /**
+       * Whether to show issue starring or not.
+       */
+      starringEnabled: {type: Boolean},
+      /**
+       * A query representing the current set of matching issues in the issue
+       * list. Does not necessarily match queryParams.q since queryParams.q can
+       * be empty while currentQuery is set to a default project query.
+       */
+      currentQuery: {type: String},
+      /**
+       * Object containing URL parameters to be preserved when issue links are
+       * clicked. This Object is only used for the purpose of preserving query
+       * parameters across links, not for the purpose of evaluating the query
+       * parameters themselves to get values like columns, sort, or q. This
+       * separation is important because we don't want to tightly couple this
+       * list component with a specific URL system.
+       * @private
+       */
+      _queryParams: {type: Object},
+      /**
+       * The initial cursor that a list view uses. This attribute allows users
+       * of the list component to specify and control the cursor. When the
+       * initialCursor attribute updates, the list focuses the element specified
+       * by the cursor.
+       */
+      initialCursor: {type: String},
+      /**
+       * Logged in user's display name
+       */
+      userDisplayName: {type: String},
+      /**
+       * IssueRef Object specifying which issue the user is currently focusing.
+       */
+      _localCursor: {type: Object},
+      /**
+       * Set of group keys that are currently hidden.
+       */
+      _hiddenGroups: {type: Object},
+      /**
+       * Set of all selected issues where each entry is an issue ref string.
+       */
+      _selectedIssues: {type: Object},
+      /**
+       * List of unique phase names for all phases in issues.
+       */
+      _phaseNames: {type: Array},
+      /**
+       * True iff the user is dragging issues.
+       */
+      _dragging: {type: Boolean},
+      /**
+       * CSV data in data HREF format, used to download csv
+       */
+      _csvDataHref: {type: String},
+      /**
+       * Function to get a full Issue object for a given ref string.
+       */
+      _issueForRefString: {type: Object},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {Array<Issue>} */
+    this.issues = [];
+    // TODO(jojwang): monorail:6336#c8, when ezt listissues page is fully
+    // deprecated, remove phaseNames from mr-issue-list.
+    this._phaseNames = [];
+    /** @type {IssueRef} */
+    this._localCursor;
+    /** @type {IssueRefString} */
+    this.initialCursor;
+    /** @type {Set<IssueRefString>} */
+    this._selectedIssues = new Set();
+    /** @type {string} */
+    this.projectName;
+    /** @type {Object} */
+    this._queryParams = {};
+    /** @type {string} */
+    this.currentQuery = '';
+    /**
+     * @param {Array<String>} items
+     * @param {number} index
+     * @return {Promise<void>}
+     */
+    this.rerank = null;
+    /** @type {boolean} */
+    this.selectionEnabled = false;
+    /** @type {boolean} */
+    this.sortingAndGroupingEnabled = false;
+    /** @type {boolean} */
+    this.starringEnabled = false;
+    /** @type {Array} */
+    this.columns = ['ID', 'Summary'];
+    /** @type {Array<string>} */
+    this.defaultFields = [];
+    /** @type {Array} */
+    this.groups = [];
+    this.userDisplayName = '';
+
+    /** @type {function(KeyboardEvent): void} */
+    this._boundRunListHotKeys = this._runListHotKeys.bind(this);
+    /** @type {function(MouseEvent): void} */
+    this._boundOnMouseMove = this._onMouseMove.bind(this);
+    /** @type {function(MouseEvent): void} */
+    this._boundOnMouseUp = this._onMouseUp.bind(this);
+
+    /**
+     * @param {Issue} _issue
+     * @param {string} _fieldName
+     * @return {Array<string>}
+     */
+    this.extractFieldValues = (_issue, _fieldName) => [];
+
+    /**
+     * @param {IssueRefString} _issueRefString
+     * @param {string} projectName The currently viewed project.
+     * @return {Issue}
+     */
+    this._issueForRefString = (_issueRefString, projectName) =>
+      issueStringToRef(_issueRefString, projectName);
+
+    this._hiddenGroups = new Set();
+
+    this._starredIssues = new Set();
+    this._fetchingStarredIssues = false;
+    this._starringIssues = new Map();
+
+    this._uniqueValuesByColumn = new Map();
+
+    this._dragging = false;
+    this._mouseX = null;
+    this._mouseY = null;
+
+    /** @type {number} */
+    this._lastSelectedCheckbox = -1;
+
+    // Expose page.js for stubbing.
+    this._page = page;
+    /** @type {string} page data in csv format as data href */
+    this._csvDataHref = '';
+  };
+
+  /** @override */
+  stateChanged(state) {
+    this._starredIssues = issueV0.starredIssues(state);
+    this._fetchingStarredIssues =
+        issueV0.requests(state).fetchStarredIssues.requesting;
+    this._starringIssues = issueV0.starringIssues(state);
+
+    this._phaseNames = (issueV0.issueListPhaseNames(state) || []);
+    this._queryParams = sitewide.queryParams(state);
+
+    this._issueForRefString = issueV0.issueForRefString(state);
+  }
+
+  /** @override */
+  firstUpdated() {
+    // Only attach an event listener once the DOM has rendered.
+    window.addEventListener('keydown', this._boundRunListHotKeys);
+    this._dataLink = this.shadowRoot.querySelector('#hidden-data-link');
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener('keydown', this._boundRunListHotKeys);
+  }
+
+  /**
+   * @override
+   * @fires CustomEvent#selectionChange
+   */
+  update(changedProperties) {
+    if (changedProperties.has('issues')) {
+      // Clear selected issues to avoid an ever-growing Set size. In the future,
+      // we may want to consider saving selections across issue reloads, though,
+      // such as in the case or list refreshing.
+      this._selectedIssues = new Set();
+      this.dispatchEvent(new CustomEvent('selectionChange'));
+
+      // Clear group toggle state when the list of issues changes to prevent an
+      // ever-growing Set size.
+      this._hiddenGroups = new Set();
+
+      this._lastSelectedCheckbox = -1;
+    }
+
+    const valuesByColumnArgs = ['issues', 'columns', 'extractFieldValues'];
+    if (setHasAny(changedProperties, valuesByColumnArgs)) {
+      this._uniqueValuesByColumn = this._computeUniqueValuesByColumn(
+          ...objectValuesForKeys(this, valuesByColumnArgs));
+    }
+
+    super.update(changedProperties);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('initialCursor')) {
+      const ref = issueStringToRef(this.initialCursor, this.projectName);
+      const row = this._getRowFromIssueRef(ref);
+      if (row) {
+        row.focus();
+      }
+    }
+  }
+
+  /**
+   * Iterates through all issues in a list to sort unique values
+   * across columns, for use in the "Show only" feature.
+   * @param {Array} issues
+   * @param {Array} columns
+   * @param {function(Issue, string): Array<string>} fieldExtractor
+   * @return {Map} Map where each entry has a String key for the
+   *   lowercase column name and a Set value, continuing all values for
+   *   that column.
+   */
+  _computeUniqueValuesByColumn(issues, columns, fieldExtractor) {
+    const valueMap = new Map(
+        columns.map((col) => [col.toLowerCase(), new Set()]));
+
+    issues.forEach((issue) => {
+      columns.forEach((col) => {
+        const key = col.toLowerCase();
+        const valueSet = valueMap.get(key);
+
+        const values = fieldExtractor(issue, col);
+        // Note: This allows multiple casings of the same values to be added
+        // to the Set.
+        values.forEach((v) => valueSet.add(v));
+      });
+    });
+    return valueMap;
+  }
+
+  /**
+   * Used for dynamically computing z-index to ensure column dropdowns overlap
+   * properly.
+   */
+  get highestZIndex() {
+    return this.columns.length + 10;
+  }
+
+  /**
+   * The number of columns displayed in the table. This is the count of
+   * customized columns + number of built in columns.
+   */
+  get numColumns() {
+    return this.columns.length + 2;
+  }
+
+  /**
+   * Sort issues into groups if groups are defined. The grouping feature is used
+   * when the "groupby" URL parameter is set in the list view.
+   */
+  get groupedIssues() {
+    if (!this.groups || !this.groups.length) return;
+
+    const issuesByGroup = new Map();
+
+    this.issues.forEach((issue) => {
+      const groupName = this._groupNameForIssue(issue);
+      const groupKey = groupName.toLowerCase();
+
+      if (!issuesByGroup.has(groupKey)) {
+        issuesByGroup.set(groupKey, {groupName, issues: [issue]});
+      } else {
+        const entry = issuesByGroup.get(groupKey);
+        entry.issues.push(issue);
+      }
+    });
+    return [...issuesByGroup.values()];
+  }
+
+  /**
+   * The currently selected issue, with _localCursor overriding initialCursor.
+   *
+   * @return {IssueRef} The currently selected issue.
+   */
+  get cursor() {
+    if (this._localCursor) {
+      return this._localCursor;
+    }
+    if (this.initialCursor) {
+      return issueStringToRef(this.initialCursor, this.projectName);
+    }
+    return {};
+  }
+
+  /**
+   * Computes the name of the group that an issue belongs to. Issues are grouped
+   * by fields that the user specifies and group names are generated using a
+   * combination of an issue's field values for all specified groups.
+   *
+   * @param {Issue} issue
+   * @return {string}
+   */
+  _groupNameForIssue(issue) {
+    const groups = this.groups;
+    const keyPieces = [];
+
+    groups.forEach((group) => {
+      const values = this.extractFieldValues(issue, group);
+      if (!values.length) {
+        keyPieces.push(`-has:${group}`);
+      } else {
+        values.forEach((v) => {
+          keyPieces.push(`${group}=${v}`);
+        });
+      }
+    });
+
+    return keyPieces.join(' ');
+  }
+
+  /**
+   * @return {Array<Issue>} Selected issues in the order they appear.
+   */
+  get selectedIssues() {
+    return this.issues.filter((issue) =>
+      this._selectedIssues.has(issueToIssueRefString(issue)));
+  }
+
+  /**
+   * Update the search query to filter values matching a specific one.
+   *
+   * @param {string} column name of the column being filtered.
+   * @param {string} value value of the field to filter by.
+   */
+  showOnly(column, value) {
+    column = column.toLowerCase();
+
+    // TODO(zhangtiff): Handle edge cases where column names are not
+    // mapped directly to field names. For example, "AllLabels", should
+    // query for "Labels".
+    const querySegment = `${column}=${value}`;
+
+    let query = this.currentQuery.trim();
+
+    if (!query.includes(querySegment)) {
+      query += ' ' + querySegment;
+
+      this._updateQueryParams({q: query.trim()}, ['start']);
+    }
+  }
+
+  /**
+   * Update sort parameter in the URL based on user input.
+   *
+   * @param {string} column name of the column to be sorted.
+   * @param {boolean} descending descending or ascending order.
+   */
+  updateSortSpec(column, descending = false) {
+    column = column.toLowerCase();
+    const oldSpec = this._queryParams.sort || '';
+    const columns = parseColSpec(oldSpec.toLowerCase());
+
+    // Remove any old instances of the same sort spec.
+    const newSpec = columns.filter(
+        (c) => c && c !== column && c !== `-${column}`);
+
+    newSpec.unshift(`${descending ? '-' : ''}${column}`);
+
+    this._updateQueryParams({sort: newSpec.join(' ')}, ['start']);
+  }
+
+  /**
+   * Updates the groupby URL parameter to include a new column to group.
+   *
+   * @param {number} i index of the column to be grouped.
+   */
+  addGroupBy(i) {
+    const groups = [...this.groups];
+    const columns = [...this.columns];
+    const groupedColumn = columns[i];
+    columns.splice(i, 1);
+
+    groups.unshift(groupedColumn);
+
+    this._updateQueryParams({
+      groupby: groups.join(' '),
+      colspec: columns.join('+'),
+    }, ['start']);
+  }
+
+  /**
+   * Removes the column at a particular index.
+   *
+   * @param {number} i the issue column to be removed.
+   */
+  removeColumn(i) {
+    const columns = [...this.columns];
+    columns.splice(i, 1);
+    this.reloadColspec(columns);
+  }
+
+  /**
+   * Adds a new column to a particular index.
+   *
+   * @param {string} name of the new column added.
+   */
+  addColumn(name) {
+    this.reloadColspec([...this.columns, name]);
+  }
+
+  /**
+   * Reflects changes to the columns of an issue list to the URL, through
+   * frontend routing.
+   *
+   * @param {Array} newColumns the new colspec to set in the URL.
+   */
+  reloadColspec(newColumns) {
+    this._updateQueryParams({colspec: newColumns.join('+')});
+  }
+
+  /**
+   * Navigates to the same URL as the current page, but with query
+   * params updated.
+   *
+   * @param {Object} newParams keys and values of the queryParams
+   * Object to be updated.
+   * @param {Array} deletedParams keys to be cleared from queryParams.
+   */
+  _updateQueryParams(newParams = {}, deletedParams = []) {
+    const url = urlWithNewParams(this._baseUrl(), this._queryParams, newParams,
+        deletedParams);
+    this._page(url);
+  }
+
+  /**
+   * Get the current URL of the page, without query params. Useful for
+   * test stubbing.
+   *
+   * @return {string} the URL of the list page, without params.
+   */
+  _baseUrl() {
+    return window.location.pathname;
+  }
+
+  /**
+   * Run issue list hot keys. This event handler needs to be bound globally
+   * because a list cursor can be defined even when no element in the list is
+   * focused.
+   * @param {KeyboardEvent} e
+   */
+  _runListHotKeys(e) {
+    if (!this.issues || !this.issues.length) return;
+    const target = findDeepEventTarget(e);
+    if (!target || isTextInput(target)) return;
+
+    const key = e.key;
+
+    const activeRow = this._getCursorElement();
+
+    let i = -1;
+    if (activeRow) {
+      i = Number.parseInt(activeRow.dataset.index);
+
+      const issue = this.issues[i];
+
+      switch (key) {
+        case 's': // Star focused issue.
+          this._starIssue(issueToIssueRef(issue));
+          return;
+        case 'x': // Toggle selection of focused issue.
+          const issueRefString = issueToIssueRefString(issue);
+          this._updateSelectedIssues([issueRefString],
+              !this._selectedIssues.has(issueRefString));
+          return;
+        case 'o': // Open current issue.
+        case 'O': // Open current issue in new tab.
+          this._navigateToIssue(issue, e.shiftKey);
+          return;
+      }
+    }
+
+    // Move up and down the issue list.
+    // 'j' moves 'down'.
+    // 'k' moves 'up'.
+    if (key === 'j' || key === 'k') {
+      if (key === 'j') { // Navigate down the list.
+        i += 1;
+        if (i >= this.issues.length) {
+          i = 0;
+        }
+      } else if (key === 'k') { // Navigate up the list.
+        i -= 1;
+        if (i < 0) {
+          i = this.issues.length - 1;
+        }
+      }
+
+      const nextRow = this.shadowRoot.querySelector(`.row-${i}`);
+      this._setRowAsCursor(nextRow);
+    }
+  }
+
+  /**
+   * @return {HTMLTableRowElement}
+   */
+  _getCursorElement() {
+    const cursor = this.cursor;
+    if (cursor) {
+      // If there's a cursor set, use that instead of focus.
+      return this._getRowFromIssueRef(cursor);
+    }
+    return;
+  }
+
+  /**
+   * @param {FocusEvent} e
+   */
+  _setRowAsCursorOnFocus(e) {
+    this._setRowAsCursor(/** @type {HTMLTableRowElement} */ (e.target));
+  }
+
+  /**
+   *
+   * @param {HTMLTableRowElement} row
+   */
+  _setRowAsCursor(row) {
+    this._localCursor = issueStringToRef(row.dataset.issueRef,
+        this.projectName);
+    row.focus();
+  }
+
+  /**
+   * @param {IssueRef} ref The issueRef to query for.
+   * @return {HTMLTableRowElement}
+   */
+  _getRowFromIssueRef(ref) {
+    return this.shadowRoot.querySelector(
+        `.list-row[data-issue-ref="${issueRefToString(ref)}"]`);
+  }
+
+  /**
+   * Returns an Array containing every <tr> in the list, excluding the header.
+   * @return {Array<HTMLTableRowElement>}
+   */
+  _getRows() {
+    return Array.from(this.shadowRoot.querySelectorAll('.list-row'));
+  }
+
+  /**
+   * Returns an Array containing every selected <tr> in the list.
+   * @return {Array<HTMLTableRowElement>}
+   */
+  _getSelectedRows() {
+    return this._getRows().filter((row) => {
+      return this._selectedIssues.has(row.dataset.issueRef);
+    });
+  }
+
+  /**
+   * @param {IssueRef} issueRef Issue to star
+   */
+  _starIssue(issueRef) {
+    if (!this.starringEnabled) return;
+    const issueKey = issueRefToString(issueRef);
+
+    // TODO(zhangtiff): Find way to share star disabling logic more.
+    const isStarring = this._starringIssues.has(issueKey) &&
+      this._starringIssues.get(issueKey).requesting;
+    const starEnabled = !this._fetchingStarredIssues && !isStarring;
+    if (starEnabled) {
+      const newIsStarred = !this._starredIssues.has(issueKey);
+      this._starIssueInternal(issueRef, newIsStarred);
+    }
+  }
+
+  /**
+   * Wrap store.dispatch and issue.star, for testing.
+   *
+   * @param {IssueRef} issueRef the issue being starred.
+   * @param {boolean} newIsStarred whether to star or unstar the issue.
+   * @private
+   */
+  _starIssueInternal(issueRef, newIsStarred) {
+    store.dispatch(issueV0.star(issueRef, newIsStarred));
+  }
+  /**
+   * @param {Event} e
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _selectAll(e) {
+    const checkbox = /** @type {HTMLInputElement} */ (e.target);
+
+    if (checkbox.checked) {
+      this._selectedIssues = new Set(this.issues.map(issueRefToString));
+    } else {
+      this._selectedIssues = new Set();
+    }
+    this.dispatchEvent(new CustomEvent('selectionChange'));
+  }
+
+  // TODO(zhangtiff): Implement Shift+Click to select a range of checkboxes
+  // for the 'x' hot key.
+  /**
+   * @param {MouseEvent} e
+   * @private
+   */
+  _selectIssueRange(e) {
+    if (!this.selectionEnabled) return;
+
+    const checkbox = /** @type {HTMLInputElement} */ (e.target);
+
+    const index = Number.parseInt(checkbox.dataset.index);
+    if (Number.isNaN(index)) {
+      console.error('Issue checkbox has invalid data-index attribute.');
+      return;
+    }
+
+    const lastIndex = this._lastSelectedCheckbox;
+    if (e.shiftKey && lastIndex >= 0) {
+      const newCheckedState = checkbox.checked;
+
+      const start = Math.min(lastIndex, index);
+      const end = Math.max(lastIndex, index) + 1;
+
+      const updatedIssueKeys = this.issues.slice(start, end).map(
+          issueToIssueRefString);
+      this._updateSelectedIssues(updatedIssueKeys, newCheckedState);
+    }
+
+    this._lastSelectedCheckbox = index;
+  }
+
+  /**
+   * @param {Event} e
+   * @private
+   */
+  _selectIssue(e) {
+    if (!this.selectionEnabled) return;
+
+    const checkbox = /** @type {HTMLInputElement} */ (e.target);
+    const issueKey = checkbox.value;
+
+    this._updateSelectedIssues([issueKey], checkbox.checked);
+  }
+
+  /**
+   * @param {Array<IssueRefString>} issueKeys Stringified issue refs.
+   * @param {boolean} selected
+   * @fires CustomEvent#selectionChange
+   * @private
+   */
+  _updateSelectedIssues(issueKeys, selected) {
+    let hasChanges = false;
+
+    issueKeys.forEach((issueKey) => {
+      const oldSelection = this._selectedIssues.has(issueKey);
+
+      if (selected) {
+        this._selectedIssues.add(issueKey);
+      } else if (this._selectedIssues.has(issueKey)) {
+        this._selectedIssues.delete(issueKey);
+      }
+
+      const newSelection = this._selectedIssues.has(issueKey);
+
+      hasChanges = hasChanges || newSelection !== oldSelection;
+    });
+
+
+    if (hasChanges) {
+      this.requestUpdate('_selectedIssues');
+      this.dispatchEvent(new CustomEvent('selectionChange'));
+    }
+  }
+
+  /**
+   * Handles 'Enter' being pressed when a row is focused.
+   * Note we install the 'Enter' listener on the row rather than the window so
+   * 'Enter' behaves as expected when the focus is on other elements.
+   *
+   * @param {KeyboardEvent} e
+   * @private
+   */
+  _keydownIssueRow(e) {
+    if (e.key === 'Enter') {
+      this._maybeOpenIssueRow(e);
+    }
+  }
+
+  /**
+   * Handles mouseDown to start drag events.
+   * @param {MouseEvent} event
+   * @private
+   */
+  _onMouseDown(event) {
+    event.cancelable && event.preventDefault();
+
+    this._mouseX = event.clientX;
+    this._mouseY = event.clientY;
+
+    this._setRowAsCursor(event.currentTarget.parentNode);
+    this._startDrag();
+
+    // We add the event listeners to window because the mouse can go out of the
+    // bounds of the target element. window.mouseUp still triggers even if the
+    // mouse is outside the browser window.
+    window.addEventListener('mousemove', this._boundOnMouseMove);
+    window.addEventListener('mouseup', this._boundOnMouseUp);
+  }
+
+  /**
+   * Handles mouseMove to continue drag events.
+   * @param {MouseEvent} event
+   * @private
+   */
+  _onMouseMove(event) {
+    event.cancelable && event.preventDefault();
+
+    const x = event.clientX - this._mouseX;
+    const y = event.clientY - this._mouseY;
+    this._continueDrag(x, y);
+  }
+
+  /**
+   * Handles mouseUp to end drag events.
+   * @param {MouseEvent} event
+   * @private
+   */
+  _onMouseUp(event) {
+    event.cancelable && event.preventDefault();
+
+    window.removeEventListener('mousemove', this._boundOnMouseMove);
+    window.removeEventListener('mouseup', this._boundOnMouseUp);
+
+    this._endDrag(event.clientY - this._mouseY);
+  }
+
+  /**
+   * Gives a visual indicator that we've started dragging an issue row.
+   * @private
+   */
+  _startDrag() {
+    this._dragging = true;
+
+    // If the dragged row is not selected, select it.
+    // TODO(dtu): Allow dragging an existing selection for multi-drag.
+    const issueRefString = issueRefToString(this.cursor);
+    this._selectedIssues = new Set();
+    this._updateSelectedIssues([issueRefString], true);
+  }
+
+  /**
+   * @param {number} x The x-distance the cursor has moved since mouseDown.
+   * @param {number} y The y-distance the cursor has moved since mouseDown.
+   * @private
+   */
+  _continueDrag(x, y) {
+    // Unselected rows: Transition them to their new positions.
+    const [rows, initialIndex, finalIndex] = this._computeRerank(y);
+    this._translateRows(rows, initialIndex, finalIndex);
+
+    // Selected rows: Stick them to the cursor. No transition.
+    for (const row of this._getSelectedRows()) {
+      row.style.transform = `translate(${x}px, ${y}px`;
+    };
+  }
+
+  /**
+   * @param {number} y The y-distance the cursor has moved since mouseDown.
+   * @private
+   */
+  async _endDrag(y) {
+    this._dragging = false;
+
+    // Unselected rows: Transition them to their new positions.
+    const [rows, initialIndex, finalIndex] = this._computeRerank(y);
+    const targetTranslation =
+        this._translateRows(rows, initialIndex, finalIndex);
+
+    // Selected rows: Transition them to their final positions
+    // and reset their opacity.
+    const selectedRows = this._getSelectedRows();
+    for (const row of selectedRows) {
+      row.style.transition = EASE_OUT_TRANSITION;
+      row.style.transform = `translate(0px, ${targetTranslation}px)`;
+    };
+
+    // Submit the change.
+    const items = selectedRows.map((row) => row.dataset.name);
+    await this.rerank(items, finalIndex);
+
+    // Reset the transforms.
+    for (const row of this._getRows()) {
+      row.style.transition = '';
+      row.style.transform = '';
+    };
+
+    // Set the cursor to the new row.
+    // In order to focus the correct element, we need the DOM to be in sync
+    // with the issue list. We modified this.issues, so wait for a re-render.
+    await this.updateComplete;
+    const selector = `.list-row[data-index="${finalIndex}"]`;
+    this.shadowRoot.querySelector(selector).focus();
+  }
+
+  /**
+   * Computes the starting and ending indices of the cursor row,
+   * given how far the mouse has been dragged in the y-direction.
+   * The indices assume the cursor row has been removed from the list.
+   * @param {number} y The y-distance the cursor has moved since mouseDown.
+   * @return {[Array<HTMLTableRowElement>, number, number]} A tuple containing:
+   *     An Array of table rows with the cursor row removed.
+   *     The initial index of the cursor row.
+   *     The final index of the cursor row.
+   * @private
+   */
+  _computeRerank(y) {
+    const row = this._getCursorElement();
+    const rows = this._getRows();
+    const listTop = row.parentNode.offsetTop;
+
+    // Find the initial index of the cursor row.
+    // TODO(dtu): If we support multi-drag, this should be the adjusted index of
+    // the first selected row after collapsing spaces in the selected group.
+    const initialIndex = rows.indexOf(row);
+    rows.splice(initialIndex, 1);
+
+    // Compute the initial and final y-positions of the top
+    // of the cursor row relative to the top of the list.
+    const initialY = row.offsetTop - listTop;
+    const finalY = initialY + y;
+
+    // Compute the final index of the cursor row.
+    // The break points are the halfway marks of each row.
+    let finalIndex = 0;
+    for (finalIndex = 0; finalIndex < rows.length; ++finalIndex) {
+      const rowTop = rows[finalIndex].offsetTop - listTop -
+          (finalIndex >= initialIndex ? row.scrollHeight : 0);
+      const breakpoint = rowTop + rows[finalIndex].scrollHeight / 2;
+      if (breakpoint > finalY) {
+        break;
+      }
+    }
+
+    return [rows, initialIndex, finalIndex];
+  }
+
+  /**
+   * @param {Array<HTMLTableRowElement>} rows Array of table rows with the
+   *    cursor row removed.
+   * @param {number} initialIndex The initial index of the cursor row.
+   * @param {number} finalIndex The final index of the cursor row.
+   * @return {number} The number of pixels the cursor row moved.
+   * @private
+   */
+  _translateRows(rows, initialIndex, finalIndex) {
+    const firstIndex = Math.min(initialIndex, finalIndex);
+    const lastIndex = Math.max(initialIndex, finalIndex);
+
+    const rowHeight = this._getCursorElement().scrollHeight;
+    const translation = initialIndex < finalIndex ? -rowHeight : rowHeight;
+
+    let targetTranslation = 0;
+    for (let i = 0; i < rows.length; ++i) {
+      rows[i].style.transition = EASE_OUT_TRANSITION;
+      if (i >= firstIndex && i < lastIndex) {
+        rows[i].style.transform = `translate(0px, ${translation}px)`;
+        targetTranslation += rows[i].scrollHeight;
+      } else {
+        rows[i].style.transform = '';
+      }
+    }
+
+    return initialIndex < finalIndex ? targetTranslation : -targetTranslation;
+  }
+
+  /**
+   * Handle click and auxclick on issue row.
+   * @param {MouseEvent} event
+   * @private
+   */
+  _clickIssueRow(event) {
+    if (event.button === PRIMARY_BUTTON || event.button === MIDDLE_BUTTON) {
+      this._maybeOpenIssueRow(
+          event, /* openNewTab= */ event.button === MIDDLE_BUTTON);
+    }
+  }
+
+  /**
+   * Checks that the given event should not be ignored, then navigates to the
+   * issue associated with the row.
+   *
+   * @param {MouseEvent|KeyboardEvent} rowEvent A click or 'enter' on a row.
+   * @param {boolean=} openNewTab Forces opening in a new tab
+   * @private
+   */
+  _maybeOpenIssueRow(rowEvent, openNewTab = false) {
+    const path = rowEvent.composedPath();
+    const containsIgnoredElement = path.find(
+        (node) => (node.tagName || '').toUpperCase() === 'A' ||
+        (node.classList && node.classList.contains('ignore-navigation')));
+    if (containsIgnoredElement) return;
+
+    const row = /** @type {HTMLTableRowElement} */ (rowEvent.currentTarget);
+
+    const i = Number.parseInt(row.dataset.index);
+
+    if (i >= 0 && i < this.issues.length) {
+      this._navigateToIssue(this.issues[i], openNewTab || rowEvent.metaKey ||
+          rowEvent.ctrlKey);
+    }
+  }
+
+  /**
+   * @param {Issue} issue
+   * @param {boolean} newTab
+   * @private
+   */
+  _navigateToIssue(issue, newTab) {
+    const link = issueRefToUrl(issueToIssueRef(issue),
+        this._queryParams);
+
+    if (newTab) {
+      // Whether the link opens in a new tab or window is based on the
+      // user's browser preferences.
+      window.open(link, '_blank', 'noopener');
+    } else {
+      this._page(link);
+    }
+  }
+
+  /**
+   * Convert an issue's data into an array of strings, where the columns
+   * match this.columns. Extracting data like _renderCell.
+   * @param {Issue} issue
+   * @return {Array<string>}
+   * @private
+   */
+  _convertIssueToPlaintextArray(issue) {
+    return this.columns.map((column) => {
+      return this.extractFieldValues(issue, column).join(', ');
+    });
+  }
+
+  /**
+   * Convert each Issue into array of strings, where the columns
+   * match this.columns.
+   * @return {Array<Array<string>>}
+   * @private
+   */
+  _convertIssuesToPlaintextArrays() {
+    return this.issues.map(this._convertIssueToPlaintextArray.bind(this));
+  }
+
+  /**
+   * Download content as csv. Conversion to CSV only on button click
+   * instead of on data change because CSV download is not often used.
+   * @param {MouseEvent} event
+   * @private
+   */
+  async _downloadCsv(event) {
+    event.preventDefault();
+
+    if (this.userDisplayName) {
+      // convert issues to array of arrays of strings
+      const issueData = this._convertIssuesToPlaintextArrays();
+
+      // convert the data into csv formatted string.
+      const csvDataString = prepareDataForDownload(issueData, this.columns);
+
+      // construct data href
+      const href = constructHref(csvDataString);
+
+      // modify a tag's href
+      this._csvDataHref = href;
+      await this.requestUpdate('_csvDataHref');
+
+      // click to trigger download
+      this._dataLink.click();
+
+      // reset dataHref
+      this._csvDataHref = '';
+    }
+  }
+};
+
+customElements.define('mr-issue-list', MrIssueList);
diff --git a/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js b/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js
new file mode 100644
index 0000000..3861e32
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js
@@ -0,0 +1,1328 @@
+// 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 * as projectV0 from 'reducers/projectV0.js';
+import {stringValuesForIssueField} from 'shared/issue-fields.js';
+import {MrIssueList} from './mr-issue-list.js';
+
+let element;
+
+const listRowIsFocused = (element, i) => {
+  const focused = element.shadowRoot.activeElement;
+  assert.equal(focused.tagName.toUpperCase(), 'TR');
+  assert.equal(focused.dataset.index, `${i}`);
+};
+
+describe('mr-issue-list', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-list');
+    element.extractFieldValues = projectV0.extractFieldValuesFromIssue({});
+    document.body.appendChild(element);
+
+    sinon.stub(element, '_baseUrl').returns('/p/chromium/issues/list');
+    sinon.stub(element, '_page');
+    sinon.stub(window, 'open');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    window.open.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueList);
+  });
+
+  it('issue summaries render', async () => {
+    element.issues = [
+      {summary: 'test issue'},
+      {summary: 'I have a summary'},
+    ];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const summaries = element.shadowRoot.querySelectorAll('.col-summary');
+
+    assert.equal(summaries.length, 2);
+
+    assert.equal(summaries[0].textContent.trim(), 'test issue');
+    assert.equal(summaries[1].textContent.trim(), 'I have a summary');
+  });
+
+  it('one word labels render in summary column', async () => {
+    element.issues = [
+      {
+        projectName: 'test',
+        localId: 1,
+        summary: 'test issue',
+        labelRefs: [
+          {label: 'ignore-multi-word-labels'},
+          {label: 'Security'},
+          {label: 'A11y'},
+        ],
+      },
+    ];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const summary = element.shadowRoot.querySelector('.col-summary');
+    const labels = summary.querySelectorAll('.summary-label');
+
+    assert.equal(labels.length, 2);
+
+    assert.equal(labels[0].textContent.trim(), 'Security');
+    assert.include(labels[0].href,
+        '/p/test/issues/list?q=label%3ASecurity');
+    assert.equal(labels[1].textContent.trim(), 'A11y');
+    assert.include(labels[1].href,
+        '/p/test/issues/list?q=label%3AA11y');
+  });
+
+  it('blocking column renders issue links', async () => {
+    element.issues = [
+      {
+        projectName: 'test',
+        localId: 1,
+        blockingIssueRefs: [
+          {projectName: 'test', localId: 2},
+          {projectName: 'test', localId: 3},
+        ],
+      },
+    ];
+    element.columns = ['Blocking'];
+
+    await element.updateComplete;
+
+    const blocking = element.shadowRoot.querySelector('.col-blocking');
+    const link = blocking.querySelector('mr-issue-link');
+    assert.equal(link.href, '/p/test/issues/detail?id=2');
+  });
+
+  it('blockedOn column renders issue links', async () => {
+    element.issues = [
+      {
+        projectName: 'test',
+        localId: 1,
+        blockedOnIssueRefs: [{projectName: 'test', localId: 2}],
+      },
+    ];
+    element.columns = ['BlockedOn'];
+
+    await element.updateComplete;
+
+    const blocking = element.shadowRoot.querySelector('.col-blockedon');
+    const link = blocking.querySelector('mr-issue-link');
+    assert.equal(link.href, '/p/test/issues/detail?id=2');
+  });
+
+  it('mergedInto column renders issue link', async () => {
+    element.issues = [
+      {
+        projectName: 'test',
+        localId: 1,
+        mergedIntoIssueRef: {projectName: 'test', localId: 2},
+      },
+    ];
+    element.columns = ['MergedInto'];
+
+    await element.updateComplete;
+
+    const blocking = element.shadowRoot.querySelector('.col-mergedinto');
+    const link = blocking.querySelector('mr-issue-link');
+    assert.equal(link.href, '/p/test/issues/detail?id=2');
+  });
+
+  it('clicking issue link does not trigger _navigateToIssue', async () => {
+    sinon.stub(element, '_navigateToIssue');
+
+    // Prevent the page from actually navigating on the link click.
+    const clickIntercepter = sinon.spy((e) => {
+      e.preventDefault();
+    });
+    window.addEventListener('click', clickIntercepter);
+
+    element.issues = [
+      {projectName: 'test', localId: 1, summary: 'test issue'},
+      {projectName: 'test', localId: 2, summary: 'I have a summary'},
+    ];
+    element.columns = ['ID'];
+
+    await element.updateComplete;
+
+    const idLink = element.shadowRoot.querySelector('.col-id > mr-issue-link');
+
+    idLink.click();
+
+    sinon.assert.calledOnce(clickIntercepter);
+    sinon.assert.notCalled(element._navigateToIssue);
+
+    window.removeEventListener('click', clickIntercepter);
+  });
+
+  it('clicking issue row opens issue', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 22,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.click();
+
+    sinon.assert.calledWith(element._page, '/p/chromium/issues/detail?id=22');
+    sinon.assert.notCalled(window.open);
+  });
+
+  it('ctrl+click on row opens issue in new tab', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 24,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.dispatchEvent(new MouseEvent('click',
+        {ctrlKey: true, bubbles: true}));
+
+    sinon.assert.calledWith(window.open,
+        '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+  });
+
+  it('meta+click on row opens issue in new tab', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 24,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.dispatchEvent(new MouseEvent('click',
+        {metaKey: true, bubbles: true}));
+
+    sinon.assert.calledWith(window.open,
+        '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+  });
+
+  it('mouse wheel click on row opens issue in new tab', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 24,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.dispatchEvent(new MouseEvent('auxclick',
+        {button: 1, bubbles: true}));
+
+    sinon.assert.calledWith(window.open,
+        '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+  });
+
+  it('right click on row does not navigate', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 24,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.dispatchEvent(new MouseEvent('auxclick',
+        {button: 2, bubbles: true}));
+
+    sinon.assert.notCalled(window.open);
+  });
+
+  it('AllLabels column renders', async () => {
+    element.issues = [
+      {labelRefs: [{label: 'test'}, {label: 'hello-world'}]},
+      {labelRefs: [{label: 'one-label'}]},
+    ];
+
+    element.columns = ['AllLabels'];
+
+    await element.updateComplete;
+
+    const labels = element.shadowRoot.querySelectorAll('.col-alllabels');
+
+    assert.equal(labels.length, 2);
+
+    assert.equal(labels[0].textContent.trim(), 'test, hello-world');
+    assert.equal(labels[1].textContent.trim(), 'one-label');
+  });
+
+  it('issues sorted into groups when groups defined', async () => {
+    element.issues = [
+      {ownerRef: {displayName: 'test@example.com'}},
+      {ownerRef: {displayName: 'test@example.com'}},
+      {ownerRef: {displayName: 'other.user@example.com'}},
+      {},
+    ];
+
+    element.columns = ['Owner'];
+    element.groups = ['Owner'];
+
+    await element.updateComplete;
+
+    const owners = element.shadowRoot.querySelectorAll('.col-owner');
+    assert.equal(owners.length, 4);
+
+    const groupHeaders = element.shadowRoot.querySelectorAll(
+        '.group-header');
+    assert.equal(groupHeaders.length, 3);
+
+    assert.include(groupHeaders[0].textContent,
+        '2 issues: Owner=test@example.com');
+    assert.include(groupHeaders[1].textContent,
+        '1 issue: Owner=other.user@example.com');
+    assert.include(groupHeaders[2].textContent, '1 issue: -has:Owner');
+  });
+
+  it('toggling group hides members', async () => {
+    element.issues = [
+      {ownerRef: {displayName: 'group1@example.com'}},
+      {ownerRef: {displayName: 'group2@example.com'}},
+    ];
+
+    element.columns = ['Owner'];
+    element.groups = ['Owner'];
+
+    await element.updateComplete;
+
+    const issueRows = element.shadowRoot.querySelectorAll('.list-row');
+    assert.equal(issueRows.length, 2);
+
+    assert.isFalse(issueRows[0].hidden);
+    assert.isFalse(issueRows[1].hidden);
+
+    const groupHeaders = element.shadowRoot.querySelectorAll(
+        '.group-header');
+    assert.equal(groupHeaders.length, 2);
+
+    // Toggle first group hidden.
+    groupHeaders[0].click();
+    await element.updateComplete;
+
+    assert.isTrue(issueRows[0].hidden);
+    assert.isFalse(issueRows[1].hidden);
+  });
+
+  it('reloadColspec navigates to page with new colspec', () => {
+    element.columns = ['ID', 'Summary'];
+    element._queryParams = {};
+
+    element.reloadColspec(['Summary', 'AllLabels']);
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?colspec=Summary%2BAllLabels');
+  });
+
+  it('updateSortSpec navigates to page with new sort option', async () => {
+    element.columns = ['ID', 'Summary'];
+    element._queryParams = {};
+
+    await element.updateComplete;
+
+    element.updateSortSpec('Summary', true);
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?sort=-summary');
+  });
+
+  it('updateSortSpec navigates to first page when on later page', async () => {
+    element.columns = ['ID', 'Summary'];
+    element._queryParams = {start: '100', q: 'owner:me'};
+
+    await element.updateComplete;
+
+    element.updateSortSpec('Summary', true);
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?q=owner%3Ame&sort=-summary');
+  });
+
+  it('updateSortSpec prepends new option to existing sort', async () => {
+    element.columns = ['ID', 'Summary', 'Owner'];
+    element._queryParams = {sort: '-summary+owner'};
+
+    await element.updateComplete;
+
+    element.updateSortSpec('ID');
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?sort=id%20-summary%20owner');
+  });
+
+  it('updateSortSpec removes existing instances of sorted column', async () => {
+    element.columns = ['ID', 'Summary', 'Owner'];
+    element._queryParams = {sort: '-summary+owner+owner'};
+
+    await element.updateComplete;
+
+    element.updateSortSpec('Owner', true);
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?sort=-owner%20-summary');
+  });
+
+  it('_uniqueValuesByColumn re-computed when columns update', async () => {
+    element.issues = [
+      {id: 1, projectName: 'chromium'},
+      {id: 2, projectName: 'chromium'},
+      {id: 3, projectName: 'chrOmiUm'},
+      {id: 1, projectName: 'other'},
+    ];
+    element.columns = [];
+    await element.updateComplete;
+
+    assert.deepEqual(element._uniqueValuesByColumn, new Map());
+
+    element.columns = ['project'];
+    await element.updateComplete;
+
+    assert.deepEqual(element._uniqueValuesByColumn,
+        new Map([['project', new Set(['chromium', 'chrOmiUm', 'other'])]]));
+  });
+
+  it('showOnly adds new search term to query', async () => {
+    element.currentQuery = 'owner:me';
+    element._queryParams = {};
+
+    await element.updateComplete;
+
+    element.showOnly('Priority', 'High');
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?q=owner%3Ame%20priority%3DHigh');
+  });
+
+  it('addColumn adds a column', () => {
+    element.columns = ['ID', 'Summary'];
+
+    sinon.stub(element, 'reloadColspec');
+
+    element.addColumn('AllLabels');
+
+    sinon.assert.calledWith(element.reloadColspec,
+        ['ID', 'Summary', 'AllLabels']);
+  });
+
+  it('removeColumn removes a column', () => {
+    element.columns = ['ID', 'Summary'];
+
+    sinon.stub(element, 'reloadColspec');
+
+    element.removeColumn(0);
+
+    sinon.assert.calledWith(element.reloadColspec, ['Summary']);
+  });
+
+  it('clicking hide column in column header removes column', async () => {
+    element.columns = ['ID', 'Summary'];
+
+    sinon.stub(element, 'removeColumn');
+
+    await element.updateComplete;
+
+    const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+    dropdown.clickItem(0); // Hide column.
+
+    sinon.assert.calledWith(element.removeColumn, 1);
+  });
+
+  it('starring disabled when starringEnabled is false', async () => {
+    element.starringEnabled = false;
+    element.issues = [
+      {projectName: 'test', localId: 1, summary: 'test issue'},
+      {projectName: 'test', localId: 2, summary: 'I have a summary'},
+    ];
+
+    await element.updateComplete;
+
+    let stars = element.shadowRoot.querySelectorAll('mr-issue-star');
+    assert.equal(stars.length, 0);
+
+    element.starringEnabled = true;
+    await element.updateComplete;
+
+    stars = element.shadowRoot.querySelectorAll('mr-issue-star');
+    assert.equal(stars.length, 2);
+  });
+
+  describe('issue sorting and grouping enabled', () => {
+    beforeEach(() => {
+      element.sortingAndGroupingEnabled = true;
+    });
+
+    it('clicking sort up column header sets sort spec', async () => {
+      element.columns = ['ID', 'Summary'];
+
+      sinon.stub(element, 'updateSortSpec');
+
+      await element.updateComplete;
+
+      const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+      dropdown.clickItem(0); // Sort up.
+
+      sinon.assert.calledWith(element.updateSortSpec, 'Summary');
+    });
+
+    it('clicking sort down column header sets sort spec', async () => {
+      element.columns = ['ID', 'Summary'];
+
+      sinon.stub(element, 'updateSortSpec');
+
+      await element.updateComplete;
+
+      const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+      dropdown.clickItem(1); // Sort down.
+
+      sinon.assert.calledWith(element.updateSortSpec, 'Summary', true);
+    });
+
+    it('clicking group rows column header groups rows', async () => {
+      element.columns = ['Owner', 'Priority'];
+      element.groups = ['Status'];
+
+      sinon.spy(element, 'addGroupBy');
+
+      await element.updateComplete;
+
+      const dropdown = element.shadowRoot.querySelector('.dropdown-owner');
+      dropdown.clickItem(3); // Group rows.
+
+      sinon.assert.calledWith(element.addGroupBy, 0);
+
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?groupby=Owner%20Status&colspec=Priority');
+    });
+  });
+
+  describe('issue selection', () => {
+    beforeEach(() => {
+      element.selectionEnabled = true;
+    });
+
+    it('selections disabled when selectionEnabled is false', async () => {
+      element.selectionEnabled = false;
+      element.issues = [
+        {projectName: 'test', localId: 1, summary: 'test issue'},
+        {projectName: 'test', localId: 2, summary: 'I have a summary'},
+      ];
+
+      await element.updateComplete;
+
+      let checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+      assert.equal(checkboxes.length, 0);
+
+      element.selectionEnabled = true;
+      await element.updateComplete;
+
+      checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+      assert.equal(checkboxes.length, 2);
+    });
+
+    it('selected issues render selected attribute', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'another issue', localId: 2, projectName: 'proj'},
+        {summary: 'issue 2', localId: 3, projectName: 'proj'},
+      ];
+      element.columns = ['Summary'];
+
+      await element.updateComplete;
+
+      element._selectedIssues = new Set(['proj:1']);
+
+      await element.updateComplete;
+
+      const issues = element.shadowRoot.querySelectorAll('tr[selected]');
+
+      assert.equal(issues.length, 1);
+      assert.equal(issues[0].dataset.index, '0');
+      assert.include(issues[0].textContent, 'issue 1');
+    });
+
+    it('select all / none conditionally shows tooltip', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+      assert.deepEqual(element.selectedIssues, []);
+
+      const selectAll = element.shadowRoot.querySelector('.select-all');
+
+      // No issues selected, offer "Select All".
+      assert.equal(selectAll.title, 'Select All');
+      assert.equal(selectAll.getAttribute('aria-label'), 'Select All');
+
+      selectAll.click();
+
+      await element.updateComplete;
+
+      // Some issues selected, offer "Select None".
+      assert.equal(selectAll.title, 'Select None');
+      assert.equal(selectAll.getAttribute('aria-label'), 'Select None');
+    });
+
+    it('clicking select all selects all issues', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, []);
+
+      const selectAll = element.shadowRoot.querySelector('.select-all');
+      selectAll.click();
+
+      assert.deepEqual(element.selectedIssues, [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ]);
+    });
+
+    it('when checked select all deselects all issues', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+
+      element._selectedIssues = new Set(['proj:1', 'proj:2']);
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ]);
+
+      const selectAll = element.shadowRoot.querySelector('.select-all');
+      selectAll.click();
+
+      assert.deepEqual(element.selectedIssues, []);
+    });
+
+    it('selected issues added when issues checked', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'another issue', localId: 2, projectName: 'proj'},
+        {summary: 'issue 2', localId: 3, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, []);
+
+      const checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+
+      assert.equal(checkboxes.length, 3);
+
+      checkboxes[2].dispatchEvent(new MouseEvent('click'));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {summary: 'issue 2', localId: 3, projectName: 'proj'},
+      ]);
+
+      checkboxes[0].dispatchEvent(new MouseEvent('click'));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 3, projectName: 'proj'},
+      ]);
+    });
+
+    it('shift+click selects issues in a range', async () => {
+      element.issues = [
+        {localId: 1, projectName: 'proj'},
+        {localId: 2, projectName: 'proj'},
+        {localId: 3, projectName: 'proj'},
+        {localId: 4, projectName: 'proj'},
+        {localId: 5, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, []);
+
+      const checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+
+      // First click.
+      checkboxes[0].dispatchEvent(new MouseEvent('click'));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {localId: 1, projectName: 'proj'},
+      ]);
+
+      // Second click.
+      checkboxes[3].dispatchEvent(new MouseEvent('click', {shiftKey: true}));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {localId: 1, projectName: 'proj'},
+        {localId: 2, projectName: 'proj'},
+        {localId: 3, projectName: 'proj'},
+        {localId: 4, projectName: 'proj'},
+      ]);
+
+      // It's possible to chain Shift+Click operations.
+      checkboxes[2].dispatchEvent(new MouseEvent('click', {shiftKey: true}));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {localId: 1, projectName: 'proj'},
+        {localId: 2, projectName: 'proj'},
+      ]);
+    });
+
+    it('fires selectionChange events', async () => {
+      const listener = sinon.stub();
+      element.addEventListener('selectionChange', listener);
+
+      // Changing the issue list clears the selection and fires an event.
+      element.issues = [{localId: 1, projectName: 'proj'}];
+      await element.updateComplete;
+      // Selecting all/deselecting all fires an event.
+      element.shadowRoot.querySelector('.select-all').click();
+      await element.updateComplete;
+      // Selecting an individual issue fires an event.
+      element.shadowRoot.querySelectorAll('.issue-checkbox')[0].click();
+
+      sinon.assert.calledThrice(listener);
+    });
+  });
+
+  describe('cursor', () => {
+    beforeEach(() => {
+      element.issues = [
+        {localId: 1, projectName: 'chromium'},
+        {localId: 2, projectName: 'chromium'},
+      ];
+    });
+
+    it('empty when no initialCursor', () => {
+      assert.deepEqual(element.cursor, {});
+
+      element.initialCursor = '';
+      assert.deepEqual(element.cursor, {});
+    });
+
+    it('parses initialCursor value', () => {
+      element.initialCursor = '1';
+      element.projectName = 'chromium';
+
+      assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 1});
+
+      element.initialCursor = 'chromium:1';
+      assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 1});
+    });
+
+    it('overrides initialCursor with _localCursor', () => {
+      element.initialCursor = 'chromium:1';
+      element._localCursor = {projectName: 'gerrit', localId: 2};
+
+      assert.deepEqual(element.cursor, {projectName: 'gerrit', localId: 2});
+    });
+
+    it('initialCursor renders cursor and focuses element', async () => {
+      element.initialCursor = 'chromium:1';
+
+      await element.updateComplete;
+
+      const row = element.shadowRoot.querySelector('.row-0');
+      assert.isTrue(row.hasAttribute('cursored'));
+      listRowIsFocused(element, 0);
+    });
+
+    it('cursor value updated when row is focused', async () => {
+      element.initialCursor = 'chromium:1';
+
+      await element.updateComplete;
+
+      // HTMLElement.focus() seems to cause a timing related flake here.
+      element.shadowRoot.querySelector('.row-1').dispatchEvent(
+          new Event('focus'));
+
+      assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 2});
+    });
+  });
+
+  describe('hot keys', () => {
+    beforeEach(() => {
+      element.issues = [
+        {localId: 1, projectName: 'chromium'},
+        {localId: 2, projectName: 'chromium'},
+        {localId: 3, projectName: 'chromium'},
+      ];
+
+      element.selectionEnabled = true;
+
+      sinon.stub(element, '_navigateToIssue');
+    });
+
+    afterEach(() => {
+      element._navigateToIssue.restore();
+    });
+
+    it('global keydown listener removed on disconnect', async () => {
+      sinon.stub(element, '_boundRunListHotKeys');
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new Event('keydown'));
+      sinon.assert.calledOnce(element._boundRunListHotKeys);
+
+      document.body.removeChild(element);
+
+      window.dispatchEvent(new Event('keydown'));
+      sinon.assert.calledOnce(element._boundRunListHotKeys);
+
+      document.body.appendChild(element);
+    });
+
+    it('pressing j defaults to first issue', async () => {
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+      listRowIsFocused(element, 0);
+    });
+
+    it('pressing j focuses next issue', async () => {
+      element.initialCursor = 'chromium:1';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+      listRowIsFocused(element, 1);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+      listRowIsFocused(element, 2);
+    });
+
+    it('pressing j at the end of the list loops around', async () => {
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('.row-2').focus();
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+      listRowIsFocused(element, 0);
+    });
+
+
+    it('pressing k defaults to last issue', async () => {
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+      listRowIsFocused(element, 2);
+    });
+
+    it('pressing k focuses previous issue', async () => {
+      element.initialCursor = 'chromium:3';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+      listRowIsFocused(element, 1);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+      listRowIsFocused(element, 0);
+    });
+
+    it('pressing k at the start of the list loops around', async () => {
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('.row-0').focus();
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+      listRowIsFocused(element, 2);
+    });
+
+    it('j and k keys treat row as focused if child is focused', async () => {
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('.row-1').querySelector(
+          'mr-issue-link').focus();
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      listRowIsFocused(element, 2);
+
+      element.shadowRoot.querySelector('.row-1').querySelector(
+          'mr-issue-link').focus();
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      listRowIsFocused(element, 0);
+    });
+
+    it('j and k keys stay on one element when one issue', async () => {
+      element.issues = [{localId: 2, projectName: 'chromium'}];
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      listRowIsFocused(element, 0);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      listRowIsFocused(element, 0);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      listRowIsFocused(element, 0);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      listRowIsFocused(element, 0);
+    });
+
+    it('j and k no-op when event is from input', async () => {
+      const input = document.createElement('input');
+      document.body.appendChild(input);
+
+      await element.updateComplete;
+
+      input.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      assert.isNull(element.shadowRoot.activeElement);
+
+      input.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      assert.isNull(element.shadowRoot.activeElement);
+
+      document.body.removeChild(input);
+    });
+
+    it('j and k no-op when event is from shadowDOM input', async () => {
+      const input = document.createElement('input');
+      const root = document.createElement('div');
+
+      root.attachShadow({mode: 'open'});
+      root.shadowRoot.appendChild(input);
+
+      document.body.appendChild(root);
+
+      await element.updateComplete;
+
+      input.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      assert.isNull(element.shadowRoot.activeElement);
+
+      input.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      assert.isNull(element.shadowRoot.activeElement);
+
+      document.body.removeChild(root);
+    });
+
+    describe('starring issue', () => {
+      beforeEach(() => {
+        element.starringEnabled = true;
+        element.initialCursor = 'chromium:2';
+      });
+
+      it('pressing s stars focused issue', async () => {
+        sinon.stub(element, '_starIssue');
+        await element.updateComplete;
+
+        window.dispatchEvent(new KeyboardEvent('keydown', {key: 's'}));
+
+        sinon.assert.calledWith(element._starIssue,
+            {localId: 2, projectName: 'chromium'});
+      });
+
+      it('starIssue does not star issue while stars are fetched', () => {
+        sinon.stub(element, '_starIssueInternal');
+        element._fetchingStarredIssues = true;
+
+        element._starIssue({localId: 2, projectName: 'chromium'});
+
+        sinon.assert.notCalled(element._starIssueInternal);
+      });
+
+      it('starIssue does not star when issue is being starred', () => {
+        sinon.stub(element, '_starIssueInternal');
+        element._starringIssues = new Map([['chromium:2', {requesting: true}]]);
+
+        element._starIssue({localId: 2, projectName: 'chromium'});
+
+        sinon.assert.notCalled(element._starIssueInternal);
+      });
+
+      it('starIssue stars issue when issue is not being starred', () => {
+        sinon.stub(element, '_starIssueInternal');
+        element._starringIssues = new Map([
+          ['chromium:2', {requesting: false}],
+        ]);
+
+        element._starIssue({localId: 2, projectName: 'chromium'});
+
+        sinon.assert.calledWith(element._starIssueInternal,
+            {localId: 2, projectName: 'chromium'}, true);
+      });
+
+      it('starIssue unstars issue when issue is already starred', () => {
+        sinon.stub(element, '_starIssueInternal');
+        element._starredIssues = new Set(['chromium:2']);
+
+        element._starIssue({localId: 2, projectName: 'chromium'});
+
+        sinon.assert.calledWith(element._starIssueInternal,
+            {localId: 2, projectName: 'chromium'}, false);
+      });
+    });
+
+    it('pressing x selects focused issue', async () => {
+      element.initialCursor = 'chromium:2';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'x'}));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {localId: 2, projectName: 'chromium'},
+      ]);
+    });
+
+    it('pressing o navigates to focused issue', async () => {
+      element.initialCursor = 'chromium:2';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'o'}));
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element._navigateToIssue);
+      sinon.assert.calledWith(element._navigateToIssue,
+          {localId: 2, projectName: 'chromium'}, false);
+    });
+
+    it('pressing shift+o opens focused issue in new tab', async () => {
+      element.initialCursor = 'chromium:2';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown',
+          {key: 'O', shiftKey: true}));
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element._navigateToIssue);
+      sinon.assert.calledWith(element._navigateToIssue,
+          {localId: 2, projectName: 'chromium'}, true);
+    });
+
+    it('enter keydown on row navigates to issue', async () => {
+      await element.updateComplete;
+
+      const row = element.shadowRoot.querySelector('.row-1');
+
+      row.dispatchEvent(
+          new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element._navigateToIssue);
+      sinon.assert.calledWith(
+          element._navigateToIssue, {localId: 2, projectName: 'chromium'},
+          false);
+    });
+
+    it('ctrl+enter keydown on row navigates to issue in new tab', async () => {
+      await element.updateComplete;
+
+      const row = element.shadowRoot.querySelector('.row-1');
+
+      // Note: metaKey would also work, but this is covered by click tests.
+      row.dispatchEvent(new KeyboardEvent(
+          'keydown', {key: 'Enter', ctrlKey: true, bubbles: true}));
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element._navigateToIssue);
+      sinon.assert.calledWith(element._navigateToIssue,
+          {localId: 2, projectName: 'chromium'}, true);
+    });
+
+    it('enter keypress outside row is ignored', async () => {
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element._navigateToIssue);
+    });
+  });
+
+  describe('_convertIssueToPlaintextArray', () => {
+    it('returns an array with as many entries as this.columns.length', () => {
+      element.columns = ['summary'];
+      const result = element._convertIssueToPlaintextArray({
+        summary: 'test issue',
+      });
+      assert.equal(element.columns.length, result.length);
+    });
+
+    it('for column id uses issueRefToString', async () => {
+      const projectName = 'some_project_name';
+      const otherProjectName = 'some_other_project';
+      const localId = '123';
+      element.columns = ['ID'];
+      element.projectName = projectName;
+
+      element.extractFieldValues = (issue, fieldName) =>
+        stringValuesForIssueField(issue, fieldName, projectName);
+
+      let result;
+      result = element._convertIssueToPlaintextArray({
+        localId,
+        projectName,
+      });
+      assert.equal(localId, result[0]);
+
+      result = element._convertIssueToPlaintextArray({
+        localId,
+        projectName: otherProjectName,
+      });
+      assert.equal(`${otherProjectName}:${localId}`, result[0]);
+    });
+
+    it('uses extractFieldValues', () => {
+      element.columns = ['summary', 'notsummary', 'anotherColumn'];
+      element.extractFieldValues = sinon.fake.returns(['a', 'b']);
+
+      element._convertIssueToPlaintextArray({summary: 'test issue'});
+      sinon.assert.callCount(element.extractFieldValues,
+          element.columns.length);
+    });
+
+    it('joins the result of extractFieldValues with ", "', () => {
+      element.columns = ['notSummary'];
+      element.extractFieldValues = sinon.fake.returns(['a', 'b']);
+
+      const result = element._convertIssueToPlaintextArray({
+        summary: 'test issue',
+      });
+      assert.deepEqual(result, ['a, b']);
+    });
+  });
+
+  describe('_convertIssuesToPlaintextArrays', () => {
+    it('maps this.issues with this._convertIssueToPlaintextArray', () => {
+      element._convertIssueToPlaintextArray = sinon.fake.returns(['foobar']);
+
+      element.columns = ['summary'];
+      element.issues = [
+        {summary: 'test issue'},
+        {summary: 'I have a summary'},
+      ];
+      const result = element._convertIssuesToPlaintextArrays();
+
+      assert.deepEqual([['foobar'], ['foobar']], result);
+      sinon.assert.callCount(element._convertIssueToPlaintextArray,
+          element.issues.length);
+    });
+  });
+
+  it('drag-and-drop', async () => {
+    element.rerank = () => {};
+    element.issues = [
+      {projectName: 'project', localId: 123, summary: 'test issue'},
+      {projectName: 'project', localId: 456, summary: 'I have a summary'},
+      {projectName: 'project', localId: 789, summary: 'third issue'},
+    ];
+    await element.updateComplete;
+
+    const rows = element._getRows();
+
+    // Mouse down on the middle element!
+    const secondRow = rows[1];
+    const dragHandle = secondRow.firstElementChild;
+    const mouseDown = new MouseEvent('mousedown', {clientX: 0, clientY: 0});
+    dragHandle.dispatchEvent(mouseDown);
+
+    assert.deepEqual(element._dragging, true);
+    assert.deepEqual(element.cursor, {projectName: 'project', localId: 456});
+    assert.deepEqual(element.selectedIssues, [element.issues[1]]);
+
+    // Drag the middle element to the end!
+    const mouseMove = new MouseEvent('mousemove', {clientX: 0, clientY: 100});
+    window.dispatchEvent(mouseMove);
+
+    assert.deepEqual(rows[0].style['transform'], '');
+    assert.deepEqual(rows[1].style['transform'], 'translate(0px, 100px)');
+    assert.match(rows[2].style['transform'], /^translate\(0px, -\d+px\)$/);
+
+    // Mouse up!
+    const mouseUp = new MouseEvent('mouseup', {clientX: 0, clientY: 100});
+    window.dispatchEvent(mouseUp);
+
+    assert.deepEqual(element._dragging, false);
+    assert.match(rows[1].style['transform'], /^translate\(0px, \d+px\)$/);
+  });
+
+  describe('CSV download', () => {
+    let _downloadCsvSpy;
+    let convertStub;
+
+    beforeEach(() => {
+      element.userDisplayName = 'notempty';
+      _downloadCsvSpy = sinon.spy(element, '_downloadCsv');
+      convertStub = sinon
+          .stub(element, '_convertIssuesToPlaintextArrays')
+          .returns([['']]);
+    });
+
+    afterEach(() => {
+      _downloadCsvSpy.restore();
+      convertStub.restore();
+    });
+
+    it('hides download link for anonymous users', async () => {
+      element.userDisplayName = '';
+      await element.updateComplete;
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      assert.isNull(downloadLink);
+    });
+
+    it('renders a #download-link', async () => {
+      await element.updateComplete;
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      assert.isNotNull(downloadLink);
+      assert.equal('inline', window.getComputedStyle(downloadLink).display);
+    });
+
+    it('renders a #hidden-data-link', async () => {
+      await element.updateComplete;
+      assert.isNotNull(element._dataLink);
+      const expected = element.shadowRoot.querySelector('#hidden-data-link');
+      assert.equal(expected, element._dataLink);
+    });
+
+    it('hides #hidden-data-link', async () => {
+      await element.updateComplete;
+      const _dataLink = element.shadowRoot.querySelector('#hidden-data-link');
+      assert.equal('none', window.getComputedStyle(_dataLink).display);
+    });
+
+    it('calls _downloadCsv on click', async () => {
+      await element.updateComplete;
+      sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      downloadLink.click();
+      await element.requestUpdate('_csvDataHref');
+
+      sinon.assert.calledOnce(_downloadCsvSpy);
+      element._dataLink.click.restore();
+    });
+
+    it('converts issues into arrays of plaintext data', async () => {
+      await element.updateComplete;
+      sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      downloadLink.click();
+      await element.requestUpdate('_csvDataHref');
+
+      sinon.assert.calledOnce(convertStub);
+      element._dataLink.click.restore();
+    });
+
+    it('triggers _dataLink click after #downloadLink click', async () => {
+      await element.updateComplete;
+      const dataLinkStub = sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+      downloadLink.click();
+
+      await element.requestUpdate('_csvDataHref');
+      sinon.assert.calledOnce(dataLinkStub);
+
+      element._dataLink.click.restore();
+    });
+
+    it('triggers _csvDataHref update and _dataLink click', async () => {
+      await element.updateComplete;
+      assert.equal('', element._csvDataHref);
+      const downloadStub = sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+      downloadLink.click();
+      assert.notEqual('', element._csvDataHref);
+      await element.requestUpdate('_csvDataHref');
+      sinon.assert.calledOnce(downloadStub);
+
+      element._dataLink.click.restore();
+    });
+
+    it('resets _csvDataHref', async () => {
+      await element.updateComplete;
+      assert.equal('', element._csvDataHref);
+
+      sinon.stub(element._dataLink, 'click');
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      downloadLink.click();
+      assert.notEqual('', element._csvDataHref);
+
+      await element.requestUpdate('_csvDataHref');
+      assert.equal('', element._csvDataHref);
+      element._dataLink.click.restore();
+    });
+
+    it('does nothing for anonymous users', async () => {
+      await element.updateComplete;
+
+      element.userDisplayName = '';
+
+      const downloadStub = sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+      downloadLink.click();
+      await element.requestUpdate('_csvDataHref');
+      sinon.assert.notCalled(downloadStub);
+
+      element._dataLink.click.restore();
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js
new file mode 100644
index 0000000..5d6a97b
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js
@@ -0,0 +1,310 @@
+// 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 {css} from 'lit-element';
+import {MrDropdown} from 'elements/framework/mr-dropdown/mr-dropdown.js';
+import page from 'page';
+import qs from 'qs';
+import {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import {fieldTypes, fieldsForIssue} from 'shared/issue-fields.js';
+
+
+/**
+ * `<mr-show-columns-dropdown>`
+ *
+ * Issue list column options dropdown.
+ *
+ */
+export class MrShowColumnsDropdown extends connectStore(MrDropdown) {
+  /** @override */
+  static get styles() {
+    return [
+      ...MrDropdown.styles,
+      css`
+        :host {
+          font-weight: normal;
+          color: var(--chops-link-color);
+          --mr-dropdown-icon-color: var(--chops-link-color);
+          --mr-dropdown-anchor-padding: 3px 8px;
+          --mr-dropdown-anchor-font-weight: bold;
+          --mr-dropdown-menu-min-width: 150px;
+          --mr-dropdown-menu-font-size: var(--chops-main-font-size);
+          --mr-dropdown-menu-icon-size: var(--chops-main-font-size);
+          /* Because we're using a sticky header, we need to make sure the
+           * dropdown cannot be taller than the screen. */
+          --mr-dropdown-menu-max-height: 80vh;
+          --mr-dropdown-menu-overflow: auto;
+        }
+      `,
+    ];
+  }
+  /** @override */
+  static get properties() {
+    return {
+      ...MrDropdown.properties,
+      /**
+       * Array of displayed columns.
+       */
+      columns: {type: Array},
+      /**
+       * Array of displayed issues.
+       */
+      issues: {type: Array},
+      /**
+       * Array of unique phase names to prepend to phase field columns.
+       */
+      // TODO(dtu): Delete after removing EZT hotlist issue list.
+      phaseNames: {type: Array},
+      /**
+       * Array of built in fields that are available outside of project
+       * configuration.
+       */
+      defaultFields: {type: Array},
+      _fieldDefs: {type: Array},
+      _labelPrefixFields: {type: Array},
+      // TODO(zhangtiff): Delete this legacy integration after removing
+      // the EZT issue list view.
+      onHideColumn: {type: Object},
+      onShowColumn: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    // Inherited from MrDropdown.
+    this.label = 'Show columns';
+    this.icon = 'more_horiz';
+
+    this.columns = [];
+    /** @type {Array<Issue>} */
+    this.issues = [];
+    this.phaseNames = [];
+    this.defaultFields = [];
+
+    // TODO(dtu): Delete after removing EZT hotlist issue list.
+    this._fieldDefs = [];
+    this._labelPrefixFields = [];
+
+    this._queryParams = {};
+    this._page = page;
+
+    // TODO(zhangtiff): Delete this legacy integration after removing
+    // the EZT issue list view.
+    this.onHideColumn = null;
+    this.onShowColumn = null;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._fieldDefs = projectV0.fieldDefs(state) || [];
+    this._labelPrefixFields = projectV0.labelPrefixFields(state) || [];
+    this._queryParams = sitewide.queryParams(state);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (this.issues.length) {
+      this.items = this.columnOptions();
+    } else {
+      // TODO(dtu): Delete after removing EZT hotlist issue list.
+      this.items = this.columnOptionsEzt(
+          this.defaultFields, this._fieldDefs, this._labelPrefixFields,
+          this.columns, this.phaseNames);
+    }
+
+    super.update(changedProperties);
+  }
+
+  /**
+   * Computes the column options available in the list view based on Issues.
+   * @return {Array<MenuItem>}
+   */
+  columnOptions() {
+    const availableFields = new Set(this.defaultFields);
+    this.issues.forEach((issue) => {
+      fieldsForIssue(issue).forEach((field) => {
+        availableFields.add(field);
+      });
+    });
+
+    // Remove selected columns from available fields.
+    this.columns.forEach((field) => availableFields.delete(field));
+    const sortedFields = [...availableFields].sort();
+
+    return [
+      // Show selected options first.
+      ...this.columns.map((field, i) => ({
+        icon: 'check',
+        text: field,
+        handler: () => this._removeColumn(i),
+      })),
+      // Unselected options come next.
+      ...sortedFields.map((field) => ({
+        icon: '',
+        text: field,
+        handler: () => this._addColumn(field),
+      })),
+    ];
+  }
+
+  // TODO(dtu): Delete after removing EZT hotlist issue list.
+  /**
+   * Computes the column options available in the list view based on project
+   * config data.
+   * @param {Array<string>} defaultFields List of built in columns.
+   * @param {Array<FieldDef>} fieldDefs List of custom fields configured in the
+   *   viewed project.
+   * @param {Array<string>} labelPrefixes List of available label prefixes for
+   *   the current project config..
+   * @param {Array<string>} selectedColumns List of columns the user is
+   *   currently viewing.
+   * @param {Array<string>} phaseNames All phase namws present in the currently
+   *   viewed issue list.
+   * @return {Array<MenuItem>}
+   */
+  columnOptionsEzt(defaultFields, fieldDefs, labelPrefixes, selectedColumns,
+      phaseNames) {
+    const selectedOptions = new Set(
+        selectedColumns.map((col) => col.toLowerCase()));
+
+    const availableFields = new Set();
+
+    // Built-in, hard-coded fields like Owner, Status, and Labels.
+    defaultFields.forEach((field) => this._addUnselectedField(
+        availableFields, field, selectedOptions));
+
+    // Custom fields.
+    fieldDefs.forEach((fd) => {
+      const {fieldRef, isPhaseField} = fd;
+      const {fieldName, type} = fieldRef;
+      if (isPhaseField) {
+        // If the custom field belongs to phases, prefix the phase name for
+        // each phase.
+        phaseNames.forEach((phaseName) => {
+          this._addUnselectedField(
+              availableFields, `${phaseName}.${fieldName}`, selectedOptions);
+        });
+        return;
+      }
+
+      // TODO(zhangtiff): Prefix custom fields with "approvalName" defined by
+      // the approval name after deprecating the old issue list page.
+
+      // Most custom fields can be directly added to the list with no
+      // modifications.
+      this._addUnselectedField(
+          availableFields, fieldName, selectedOptions);
+
+      // If the custom field is type approval, then it also has a built in
+      // "Approver" field.
+      if (type === fieldTypes.APPROVAL_TYPE) {
+        this._addUnselectedField(
+            availableFields, `${fieldName}-Approver`, selectedOptions);
+      }
+    });
+
+    // Fields inferred from label prefixes.
+    labelPrefixes.forEach((field) => this._addUnselectedField(
+        availableFields, field, selectedOptions));
+
+    const sortedFields = [...availableFields];
+    sortedFields.sort();
+
+    return [
+      ...selectedColumns.map((field, i) => ({
+        icon: 'check',
+        text: field,
+        handler: () => this._removeColumn(i),
+      })),
+      ...sortedFields.map((field) => ({
+        icon: '',
+        text: field,
+        handler: () => this._addColumn(field),
+      })),
+    ];
+  }
+
+  /**
+   * Helper that mutates a Set of column names in place, adding a given
+   * field only if it doesn't already show up in the list of selected
+   * fields.
+   * @param {Set<string>} availableFields Set of column names to mutate.
+   * @param {string} field Name of the field being added to the options.
+   * @param {Set<string>} selectedOptions Set of fieldNames that the user
+   *   is viewing.
+   * @private
+   */
+  _addUnselectedField(availableFields, field, selectedOptions) {
+    if (!selectedOptions.has(field.toLowerCase())) {
+      availableFields.add(field);
+    }
+  }
+
+  /**
+   * Removes the column at a particular index.
+   *
+   * @param {number} i the issue column to be removed.
+   */
+  _removeColumn(i) {
+    if (this.onHideColumn) {
+      if (!this.onHideColumn(this.columns[i])) {
+        return;
+      }
+    }
+    const columns = [...this.columns];
+    columns.splice(i, 1);
+    this._reloadColspec(columns);
+  }
+
+  /**
+   * Adds a new column to a particular index.
+   *
+   * @param {string} name of the new column added.
+   */
+  _addColumn(name) {
+    if (this.onShowColumn) {
+      if (!this.onShowColumn(name)) {
+        return;
+      }
+    }
+    this._reloadColspec([...this.columns, name]);
+  }
+
+  /**
+   * Reflects changes to the columns of an issue list to the URL, through
+   * frontend routing.
+   *
+   * @param {Array} newColumns the new colspec to set in the URL.
+   */
+  _reloadColspec(newColumns) {
+    this._updateQueryParams({colspec: newColumns.join(' ')});
+  }
+
+  /**
+   * Navigates to the same URL as the current page, but with query
+   * params updated.
+   *
+   * @param {Object} newParams keys and values of the queryParams
+   * Object to be updated.
+   */
+  _updateQueryParams(newParams) {
+    const params = {...this._queryParams, ...newParams};
+    this._page(`${this._baseUrl()}?${qs.stringify(params)}`);
+  }
+
+  /**
+   * Get the current URL of the page, without query params. Useful for
+   * test stubbing.
+   *
+   * @return {string} the URL of the list page, without params.
+   */
+  _baseUrl() {
+    return window.location.pathname;
+  }
+}
+
+customElements.define('mr-show-columns-dropdown', MrShowColumnsDropdown);
diff --git a/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js
new file mode 100644
index 0000000..495ffe2
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js
@@ -0,0 +1,209 @@
+// 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 {MrShowColumnsDropdown} from './mr-show-columns-dropdown.js';
+
+/** @type {MrShowColumnsDropdown} */
+let element;
+
+describe('mr-show-columns-dropdown', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-show-columns-dropdown');
+    document.body.appendChild(element);
+
+    sinon.stub(element, '_baseUrl').returns('/p/chromium/issues/list');
+    sinon.stub(element, '_page');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrShowColumnsDropdown);
+  });
+
+  it('displaying columns (spa)', async () => {
+    element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+    element.columns = ['ID'];
+    element.issues = [
+      {approvalValues: [{fieldRef: {fieldName: 'Approval-Name'}}]},
+      {fieldValues: [
+        {phaseRef: {phaseName: 'Phase'}, fieldRef: {fieldName: 'Field-Name'}},
+        {fieldRef: {fieldName: 'Field-Name'}},
+      ]},
+      {labelRefs: [{label: 'Label-Name'}]},
+    ];
+
+    await element.updateComplete;
+
+    const actual =
+        element.items.map((item) => ({icon: item.icon, text: item.text}));
+    const expected = [
+      {icon: 'check', text: 'ID'},
+      {icon: '', text: 'AllLabels'},
+      {icon: '', text: 'Approval-Name'},
+      {icon: '', text: 'Approval-Name-Approver'},
+      {icon: '', text: 'Field-Name'},
+      {icon: '', text: 'Label'},
+      {icon: '', text: 'Phase.Field-Name'},
+      {icon: '', text: 'Summary'},
+    ];
+    assert.deepEqual(actual, expected);
+  });
+
+  describe('displaying columns (ezt)', () => {
+    it('sorts default column options', async () => {
+      element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+      element.columns = [];
+      element._labelPrefixFields = [];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 3);
+
+      assert.equal(options[0].text.trim(), 'AllLabels');
+      assert.equal(options[0].icon, '');
+
+      assert.equal(options[1].text.trim(), 'ID');
+      assert.equal(options[1].icon, '');
+
+      assert.equal(options[2].text.trim(), 'Summary');
+      assert.equal(options[2].icon, '');
+    });
+
+    it('sorts selected columns above unselected columns', async () => {
+      element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+      element.columns = ['ID'];
+      element._labelPrefixFields = [];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 3);
+
+      assert.equal(options[0].text.trim(), 'ID');
+      assert.equal(options[0].icon, 'check');
+
+      assert.equal(options[1].text.trim(), 'AllLabels');
+      assert.equal(options[1].icon, '');
+
+      assert.equal(options[2].text.trim(), 'Summary');
+      assert.equal(options[2].icon, '');
+    });
+
+    it('sorts field defs and label prefix column options', async () => {
+      element.defaultFields = ['ID', 'Summary'];
+      element.columns = [];
+      element._fieldDefs = [
+        {fieldRef: {fieldName: 'HelloWorld'}},
+        {fieldRef: {fieldName: 'TestField'}},
+      ];
+
+      element._labelPrefixFields = ['Milestone', 'Priority'];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 6);
+      assert.equal(options[0].text.trim(), 'HelloWorld');
+      assert.equal(options[0].icon, '');
+
+      assert.equal(options[1].text.trim(), 'ID');
+      assert.equal(options[1].icon, '');
+
+      assert.equal(options[2].text.trim(), 'Milestone');
+      assert.equal(options[2].icon, '');
+
+      assert.equal(options[3].text.trim(), 'Priority');
+      assert.equal(options[3].icon, '');
+
+      assert.equal(options[4].text.trim(), 'Summary');
+      assert.equal(options[4].icon, '');
+
+      assert.equal(options[5].text.trim(), 'TestField');
+      assert.equal(options[5].icon, '');
+    });
+
+    it('add approver fields for approval type fields', async () => {
+      element.defaultFields = [];
+      element.columns = [];
+      element._fieldDefs = [
+        {fieldRef: {fieldName: 'HelloWorld', type: 'APPROVAL_TYPE'}},
+      ];
+      element._labelPrefixFields = [];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 2);
+      assert.equal(options[0].text.trim(), 'HelloWorld');
+      assert.equal(options[0].icon, '');
+
+      assert.equal(options[1].text.trim(), 'HelloWorld-Approver');
+      assert.equal(options[1].icon, '');
+    });
+
+    it('phase field columns are correctly named', async () => {
+      element.defaultFields = [];
+      element.columns = [];
+      element._fieldDefs = [
+        {fieldRef: {fieldName: 'Number', type: 'INT_TYPE'}, isPhaseField: true},
+        {fieldRef: {fieldName: 'Speak', type: 'STR_TYPE'}, isPhaseField: true},
+      ];
+      element._labelPrefixFields = [];
+      element.phaseNames = ['cow', 'chicken'];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 4);
+      assert.equal(options[0].text.trim(), 'chicken.Number');
+      assert.equal(options[0].icon, '');
+
+      assert.equal(options[1].text.trim(), 'chicken.Speak');
+      assert.equal(options[1].icon, '');
+
+      assert.equal(options[2].text.trim(), 'cow.Number');
+      assert.equal(options[2].icon, '');
+
+      assert.equal(options[3].text.trim(), 'cow.Speak');
+      assert.equal(options[3].icon, '');
+    });
+  });
+
+  describe('modifying columns', () => {
+    it('clicking unset column adds a column', async () => {
+      element.columns = ['ID', 'Summary'];
+      element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+      element.queryParams = {};
+
+      await element.updateComplete;
+      element.clickItem(2);
+
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?colspec=ID%20Summary%20AllLabels');
+    });
+
+    it('clicking set column removes a column', async () => {
+      element.columns = ['ID', 'Summary'];
+      element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+      element.queryParams = {};
+
+      await element.updateComplete;
+      element.clickItem(0);
+
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?colspec=Summary');
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js
new file mode 100644
index 0000000..5a3e42c
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js
@@ -0,0 +1,59 @@
+// Copyright 2020 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 'elements/chops/chops-timestamp/chops-timestamp.js';
+import {determineSloStatus} from './slo-rules.js';
+
+/** @typedef {import('./slo-rules.js').SloStatus} SloStatus */
+
+/**
+ * `<mr-issue-slo>`
+ *
+ * A widget for showing the given issue's SLO status.
+ */
+export class MrIssueSlo extends LitElement {
+  /** @override */
+  static get styles() {
+    return css``;
+  }
+
+  /** @override */
+  render() {
+    const sloStatus = this._determineSloStatus();
+    if (!sloStatus) {
+      return html`N/A`;
+    }
+    if (!sloStatus.target) {
+      return html`Done`;
+    }
+    return html`
+      <chops-timestamp .timestamp=${sloStatus.target} short></chops-timestamp>`;
+  }
+
+  /**
+   * Wrapper around slo-rules.js determineSloStatus to allow tests to override
+   * the return value.
+   * @private
+   * @return {SloStatus}
+   */
+  _determineSloStatus() {
+    return this.issue ? determineSloStatus(this.issue) : null;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+    };
+  }
+  /** @override */
+  constructor() {
+    super();
+    /** @type {Issue} */
+    this.issue;
+  }
+}
+customElements.define('mr-issue-slo', MrIssueSlo);
diff --git a/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js
new file mode 100644
index 0000000..28d23eb
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.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 {assert} from 'chai';
+import {MrIssueSlo} from './mr-issue-slo.js';
+
+
+let element;
+
+describe('mr-issue-slo', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-slo');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueSlo);
+  });
+
+  it('handles ineligible issues', async () => {
+    element._determineSloStatus = () => {
+      return null;
+    };
+    element.issue = {};
+    await element.updateComplete;
+    assert.equal(element.shadowRoot.textContent, 'N/A');
+  });
+
+  it('handles issues that have completed the SLO criteria', async () => {
+    element._determineSloStatus = () => {
+      return {target: null};
+    };
+    element.issue = {};
+    await element.updateComplete;
+    assert.equal(element.shadowRoot.textContent, 'Done');
+  });
+
+  it('handles issues that have not completed the SLO criteria', async () => {
+    element._determineSloStatus = () => {
+      return {target: 1234};
+    };
+    element.issue = {};
+    await element.updateComplete;
+    const timestampElement =
+        element.shadowRoot.querySelector('chops-timestamp');
+
+    assert.equal(timestampElement.timestamp, 1234);
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-slo/slo-rules.js b/static_src/elements/framework/mr-issue-slo/slo-rules.js
new file mode 100644
index 0000000..e351ae0
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/slo-rules.js
@@ -0,0 +1,195 @@
+// Copyright 2020 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.
+
+/**
+ * @fileoverview Determining Issues' statuses relative to SLO rules.
+ *
+ * See go/monorail-slo-v0 for more info.
+ */
+
+/**
+ * A rule determining the compliance of an issue with regard to an SLO.
+ * @typedef {Object} SloRule
+ * @property {function(Issue): SloStatus} statusFunction
+ */
+
+/**
+ * Potential statuses of an issue relative to an SLO's completion criteria.
+ * @enum {string}
+ */
+export const SloCompletionStatus = {
+  /** The completion criteria for the SloRule have not been satisfied. */
+  INCOMPLETE: 'INCOMPLETE',
+  /** The completion criteria for the SloRule have been satisfied. */
+  COMPLETE: 'COMPLETE',
+};
+
+/**
+ * The status of an issue with regard to an SloRule.
+ * @typedef {Object} SloStatus
+ * @property {SloRule} rule The rule that generated this status.
+ * @property {Date} target The time the Issue must move to completion, or null
+ *     if the issue has already moved to completion.
+ * @property {SloCompletionStatus} completion Issue's completion status.
+ */
+
+/**
+ * Chrome OS Software's SLO for issue closure (go/chromeos-software-bug-slos).
+ *
+ * Implementation based on the queries defined in Sheriffbot
+ * https://chrome-internal.googlesource.com/infra/infra_internal/+/refs/heads/main/appengine/sheriffbot/src/sheriffbot/bug_slo_daily_queries.py
+ *
+ * @const {SloRule}
+ * @private Only visible for testing.
+ */
+export const _CROS_CLOSURE_SLO = {
+  statusFunction: (issue) => {
+    if (!_isCrosClosureEligible(issue)) {
+      return null;
+    }
+
+    const pri = getPriFromIssue(issue);
+    const daysToClose = _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY[pri];
+
+    if (!daysToClose) {
+      // No applicable SLO found issues with this priority.
+      return null;
+    }
+    // Return a complete status for closed issues.
+    if (issue.statusRef && !issue.statusRef.meansOpen) {
+      return {
+        rule: _CROS_CLOSURE_SLO,
+        target: null,
+        completion: SloCompletionStatus.COMPLETE};
+    }
+
+    // Set the target based on the opening and the daysToClose.
+    const target = new Date(issue.openedTimestamp * 1000);
+    target.setDate(target.getDate() + daysToClose);
+    return {
+      rule: _CROS_CLOSURE_SLO,
+      target: target,
+      completion: SloCompletionStatus.INCOMPLETE};
+  },
+};
+
+/**
+ * @param {Issue} issue
+ * @return {string?} the pri's value, if found.
+ */
+const getPriFromIssue = (issue) => {
+  for (const fv of issue.fieldValues) {
+    if (fv.fieldRef.fieldName === 'Pri') {
+      return fv.value;
+    }
+  }
+};
+
+/**
+ * The number of days (since the issue was opened) allowed for it to be fixed.
+ * @private Only visible for testing.
+ */
+export const _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY = Object.freeze({
+  '1': 42,
+});
+
+// https://chrome-internal.googlesource.com/infra/infra_internal/+/refs/heads/main/appengine/sheriffbot/src/sheriffbot/bug_slo_daily_queries.py#97
+const CROS_ELIGIBLE_COMPONENT_PATHS = new Set([
+  'OS>Systems>CrashReporting',
+  'OS>Systems>Displays',
+  'OS>Systems>Feedback',
+  'OS>Systems>HaTS',
+  'OS>Systems>Input',
+  'OS>Systems>Input>Keyboard',
+  'OS>Systems>Input>Mouse',
+  'OS>Systems>Input>Shortcuts',
+  'OS>Systems>Input>Touch',
+  'OS>Systems>Metrics',
+  'OS>Systems>Multidevice',
+  'OS>Systems>Multidevice>Messages',
+  'OS>Systems>Multidevice>SmartLock',
+  'OS>Systems>Multidevice>Tethering',
+  'OS>Systems>Network>Bluetooth',
+  'OS>Systems>Network>Cellular',
+  'OS>Systems>Network>VPN',
+  'OS>Systems>Network>WiFi',
+  'OS>Systems>Printing',
+  'OS>Systems>Settings',
+  'OS>Systems>Spellcheck',
+  'OS>Systems>Update',
+  'OS>Systems>Wallpaper',
+  'OS>Systems>WirelessCharging',
+  'Platform>Apps>Feedback',
+  'UI>Shell>Networking',
+]);
+
+/**
+ * Determines if an issue is eligible for _CROS_CLOSURE_SLO.
+ * @param {Issue} issue
+ * @return {boolean}
+ * @private Only visible for testing.
+ */
+export const _isCrosClosureEligible = (issue) => {
+  // If at least one component applies, continue.
+  const hasEligibleComponent = issue.componentRefs.some(
+      (component) => CROS_ELIGIBLE_COMPONENT_PATHS.has(component.path));
+  if (!hasEligibleComponent) {
+    return false;
+  }
+
+  let priority = null;
+  let hasMilestone = false;
+  for (const fv of issue.fieldValues) {
+    if (fv.fieldRef.fieldName === 'Type') {
+      // These types don't apply.
+      if (fv.value === 'Feature' || fv.value === 'FLT-Launch' ||
+      fv.value === 'Postmortem-Followup' || fv.value === 'Design-Review') {
+        return false;
+      }
+    }
+    if (fv.fieldRef.fieldName === 'Pri') {
+      priority = fv.value;
+    }
+    if (fv.fieldRef.fieldName === 'M') {
+      hasMilestone = true;
+    }
+  }
+  // P1 issues with milestones don't apply.
+  if (priority === '1' && hasMilestone) {
+    return false;
+  }
+  // Issues with the ChromeOS_No_SLO label don't apply.
+  for (const labelRef of issue.labelRefs) {
+    if (labelRef.label === 'ChromeOS_No_SLO') {
+      return false;
+    }
+  }
+  return true;
+};
+
+/**
+ * Active SLO Rules.
+ * @const {Array<SloRule>}
+ */
+const SLO_RULES = [_CROS_CLOSURE_SLO];
+
+/**
+ * Determines the SloStatus for the given issue.
+ * @param {Issue} issue The issue to check.
+ * @return {SloStatus} The status of the issue, or null if no rules apply.
+ */
+export const determineSloStatus = (issue) => {
+  try {
+    for (const rule of SLO_RULES) {
+      const status = rule.statusFunction(issue);
+      if (status) {
+        return status;
+      }
+    }
+  } catch (error) {
+    // Don't bubble up any errors in SLO_RULES functions, which might sometimes
+    // be written/updated by client teams.
+  }
+  return null;
+};
diff --git a/static_src/elements/framework/mr-issue-slo/slo-rules.test.js b/static_src/elements/framework/mr-issue-slo/slo-rules.test.js
new file mode 100644
index 0000000..a48e5e2
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/slo-rules.test.js
@@ -0,0 +1,152 @@
+// Copyright 2020 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 {_CROS_CLOSURE_SLO, _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY,
+  _isCrosClosureEligible, SloCompletionStatus, determineSloStatus}
+  from './slo-rules.js';
+
+const P1_FIELD_VALUE = Object.freeze({
+  fieldRef: {
+    fieldId: 1,
+    fieldName: 'Pri',
+    type: 'ENUM_TYPE',
+  },
+  value: '1'});
+
+// TODO(crbug.com/monorail/7843): Separate testing of determineSloStatus from
+// testing of specific SLO Rules. Add testing for a rule that throws an error.
+describe('determineSloStatus', () => {
+  it('returns null for ineligible issues', () => {
+    const ineligibleIssue = {
+      componentRefs: [{path: 'Some>Other>Component'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+    assert.isNull(determineSloStatus(ineligibleIssue));
+  });
+
+  it('returns null for eligible issues without defined priority', () => {
+    const ineligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+    assert.isNull(determineSloStatus(ineligibleIssue));
+  });
+
+  it('returns SloStatus with target for incomplete eligible issues', () => {
+    const openedTimestamp = 1412362587;
+    const eligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      openedTimestamp: openedTimestamp,
+      projectName: 'x',
+    };
+    const status = determineSloStatus(eligibleIssue);
+
+    const expectedTarget = new Date(openedTimestamp * 1000);
+    expectedTarget.setDate(
+        expectedTarget.getDate() + _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY['1']);
+
+    assert.equal(status.target.valueOf(), expectedTarget.valueOf());
+    assert.equal(status.completion, SloCompletionStatus.INCOMPLETE);
+    assert.equal(status.rule, _CROS_CLOSURE_SLO);
+  });
+
+  it('returns SloStatus without target for complete eligible issues', () => {
+    const eligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+      statusRef: {status: 'Closed', meansOpen: false},
+    };
+    const status = determineSloStatus(eligibleIssue);
+    assert.isNull(status.target);
+    assert.equal(status.completion, SloCompletionStatus.COMPLETE);
+    assert.equal(status.rule, _CROS_CLOSURE_SLO);
+  });
+});
+
+describe('_isCrosClosureEligible', () => {
+  let crosIssue;
+  beforeEach(() => {
+    crosIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+  });
+
+  it('returns true when eligible', () => {
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true if at least one eligible component', () => {
+    crosIssue.componentRefs.push({path: 'Some>Other>Component'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for issues in wrong component', () => {
+    crosIssue.componentRefs = [{path: 'Some>Other>Component'}];
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Feature', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Feature'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for FLT-Launch', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'FLT-Launch'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Postmortem-Followup', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Postmortem-Followup'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Design-Review', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Design-Review'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true for other types', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'type'}, value: 'Any-Other-Type'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for p1 with milestone', () => {
+    crosIssue.fieldValues.push(P1_FIELD_VALUE);
+    crosIssue.fieldValues.push({fieldRef: {fieldName: 'M'}, value: 'any'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true for p1 without milestone', () => {
+    crosIssue.fieldValues.push(P1_FIELD_VALUE);
+    crosIssue.fieldValues.push({fieldRef: {fieldName: 'Other'}, value: 'any'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for ChromeOS_No_SLO label', () => {
+    crosIssue.labelRefs.push({label: 'ChromeOS_No_SLO'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+});
diff --git a/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js
new file mode 100644
index 0000000..9e932d6
--- /dev/null
+++ b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js
@@ -0,0 +1,421 @@
+// 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 Mousetrap from 'mousetrap';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+
+
+const SHORTCUT_DOC_GROUPS = [
+  {
+    title: 'Issue list',
+    keyDocs: [
+      {
+        keys: ['k', 'j'],
+        tip: 'up/down in the list',
+      },
+      {
+        keys: ['o', 'Enter'],
+        tip: 'open the current issue',
+      },
+      {
+        keys: ['Shift-O'],
+        tip: 'open issue in new tab',
+      },
+      {
+        keys: ['x'],
+        tip: 'select the current issue',
+      },
+    ],
+  },
+  {
+    title: 'Issue details',
+    keyDocs: [
+      {
+        keys: ['k', 'j'],
+        tip: 'prev/next issue in list',
+      },
+      {
+        keys: ['u'],
+        tip: 'up to issue list',
+      },
+      {
+        keys: ['r'],
+        tip: 'reply to current issue',
+      },
+      {
+        keys: ['Ctrl+Enter', '\u2318+Enter'],
+        tip: 'save issue reply (submit issue on issue filing page)',
+      },
+    ],
+  },
+  {
+    title: 'Anywhere',
+    keyDocs: [
+      {
+        keys: ['/'],
+        tip: 'focus on the issue search field',
+      },
+      {
+        keys: ['c'],
+        tip: 'compose a new issue',
+      },
+      {
+        keys: ['s'],
+        tip: 'star the current issue',
+      },
+      {
+        keys: ['?'],
+        tip: 'show this help dialog',
+      },
+    ],
+  },
+];
+
+/**
+ * `<mr-keystrokes>`
+ *
+ * Adds keybindings for Monorail, including a dialog for showing keystrokes.
+ * @extends {LitElement}
+ */
+export class MrKeystrokes extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return css`
+      h2 {
+        margin-top: 0;
+        display: flex;
+        justify-content: space-between;
+        font-weight: normal;
+        border-bottom: 2px solid white;
+        font-size: var(--chops-large-font-size);
+        padding-bottom: 0.5em;
+      }
+      .close-button {
+        border: 0;
+        background: 0;
+        text-decoration: underline;
+        cursor: pointer;
+      }
+      .keyboard-help {
+        display: flex;
+        align-items: flex-start;
+        justify-content: space-around;
+        flex-direction: row;
+        border-bottom: 2px solid white;
+        flex-wrap: wrap;
+      }
+      .keyboard-help-section {
+        width: 32%;
+        display: grid;
+        grid-template-columns: 40% 60%;
+        padding-bottom: 1em;
+        grid-gap: 4px;
+        min-width: 300px;
+      }
+      .help-title {
+        font-weight: bold;
+      }
+      .key-shortcut {
+        text-align: right;
+        padding-right: 8px;
+        font-weight: bold;
+        margin: 2px;
+      }
+      kbd {
+        background: var(--chops-gray-200);
+        padding: 2px 8px;
+        border-radius: 2px;
+        min-width: 28px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <chops-dialog ?opened=${this._opened}>
+        <h2>
+          Issue tracker keyboard shortcuts
+          <button class="close-button" @click=${this._closeDialog}>
+            Close
+          </button>
+        </h2>
+        <div class="keyboard-help">
+          ${this._shortcutDocGroups.map((group) => html`
+            <div class="keyboard-help-section">
+              <span></span><span class="help-title">${group.title}</span>
+              ${group.keyDocs.map((keyDoc) => html`
+                <span class="key-shortcut">
+                  ${keyDoc.keys.map((key, i) => html`
+                    <kbd>${key}</kbd>
+                    <span
+                      class="key-separator"
+                      ?hidden=${i === keyDoc.keys.length - 1}
+                    > / </span>
+                  `)}:
+                </span>
+                <span class="key-tip">${keyDoc.tip}</span>
+              `)}
+            </div>
+          `)}
+        </div>
+        <p>
+          Note: Only signed in users can star issues or add comments, and
+          only project members can select issues for bulk edits.
+        </p>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issueEntryUrl: {type: String},
+      issueId: {type: Number},
+      _projectName: {type: String},
+      queryParams: {type: Object},
+      _fetchingIsStarred: {type: Boolean},
+      _isStarred: {type: Boolean},
+      _issuePermissions: {type: Array},
+      _opened: {type: Boolean},
+      _shortcutDocGroups: {type: Array},
+      _starringIssues: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this._shortcutDocGroups = SHORTCUT_DOC_GROUPS;
+    this._opened = false;
+    this._starringIssues = new Map();
+    this._projectName = undefined;
+    this._issuePermissions = [];
+    this.issueId = undefined;
+    this.queryParams = undefined;
+    this.issueEntryUrl = undefined;
+
+    this._page = page;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._projectName = projectV0.viewedProjectName(state);
+    this._issuePermissions = issueV0.permissions(state);
+
+    const starredIssues = issueV0.starredIssues(state);
+    this._isStarred = starredIssues.has(issueRefToString(this._issueRef));
+    this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
+    this._starringIssues = issueV0.starringIssues(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('_projectName') ||
+        changedProperties.has('issueEntryUrl')) {
+      this._bindProjectKeys(this._projectName, this.issueEntryUrl);
+    }
+    if (changedProperties.has('_projectName') ||
+        changedProperties.has('issueId') ||
+        changedProperties.has('_issuePermissions') ||
+        changedProperties.has('queryParams')) {
+      this._bindIssueDetailKeys(this._projectName, this.issueId,
+          this._issuePermissions, this.queryParams);
+    }
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    this._unbindProjectKeys();
+    this._unbindIssueDetailKeys();
+  }
+
+  /** @private */
+  get _isStarring() {
+    const requestKey = issueRefToString(this._issueRef);
+    if (this._starringIssues.has(requestKey)) {
+      return this._starringIssues.get(requestKey).requesting;
+    }
+    return false;
+  }
+
+  /** @private */
+  get _issueRef() {
+    return {
+      projectName: this._projectName,
+      localId: this.issueId,
+    };
+  }
+
+  /** @private */
+  _toggleDialog() {
+    this._opened = !this._opened;
+  }
+
+  /** @private */
+  _openDialog() {
+    this._opened = true;
+  }
+
+  /** @private */
+  _closeDialog() {
+    this._opened = false;
+  }
+
+  /**
+   * @param {string} projectName
+   * @param {string} issueEntryUrl
+   * @fires CustomEvent#focus-search
+   * @private
+   */
+  _bindProjectKeys(projectName, issueEntryUrl) {
+    this._unbindProjectKeys();
+
+    if (!projectName) return;
+
+    issueEntryUrl = issueEntryUrl || `/p/${projectName}/issues/entry`;
+
+    Mousetrap.bind('/', (e) => {
+      e.preventDefault();
+      // Focus search.
+      this.dispatchEvent(new CustomEvent('focus-search',
+          {composed: true, bubbles: true}));
+    });
+
+    Mousetrap.bind('?', () => {
+      // Toggle key help.
+      this._toggleDialog();
+    });
+
+    Mousetrap.bind('esc', () => {
+      // Close key help dialog if open.
+      this._closeDialog();
+    });
+
+    Mousetrap.bind('c', () => this._page(issueEntryUrl));
+  }
+
+  /** @private */
+  _unbindProjectKeys() {
+    Mousetrap.unbind('/');
+    Mousetrap.unbind('?');
+    Mousetrap.unbind('esc');
+    Mousetrap.unbind('c');
+  }
+
+  /**
+   * @param {string} projectName
+   * @param {string} issueId
+   * @param {Array<string>} issuePermissions
+   * @param {Object} queryParams
+   * @private
+   */
+  _bindIssueDetailKeys(projectName, issueId, issuePermissions, queryParams) {
+    this._unbindIssueDetailKeys();
+
+    if (!projectName || !issueId) return;
+
+    const projectHomeUrl = `/p/${projectName}`;
+
+    const queryString = qs.stringify(queryParams);
+
+    // TODO(zhangtiff): Update these links when mr-flipper's async request
+    // finishes.
+    const prevUrl = `${projectHomeUrl}/issues/detail/previous?${queryString}`;
+    const nextUrl = `${projectHomeUrl}/issues/detail/next?${queryString}`;
+    const canComment = issuePermissions.includes('addissuecomment');
+    const canStar = issuePermissions.includes('setstar');
+
+    // Previous issue in list.
+    Mousetrap.bind('k', () => this._page(prevUrl));
+
+    // Next issue in list.
+    Mousetrap.bind('j', () => this._page(nextUrl));
+
+    // Back to list.
+    Mousetrap.bind('u', () => this._backToList());
+
+    if (canComment) {
+      // Navigate to the form to make changes.
+      Mousetrap.bind('r', () => this._jumpToEditForm());
+    }
+
+    if (canStar) {
+      Mousetrap.bind('s', () => this._starIssue());
+    }
+  }
+
+  /**
+   * Navigates back to the issue list page.
+   * @private
+   */
+  _backToList() {
+    const params = {...this.queryParams,
+      cursor: issueRefToString(this._issueRef)};
+    const queryString = qs.stringify(params);
+    if (params['hotlist_id']) {
+      // Because hotlist URLs require a server look up to be built from a
+      // hotlist ID, we have to route the request through an extra endpoint
+      // that redirects to the appropriate hotlist.
+      const listUrl = `/p/${this._projectName}/issues/detail/list?${
+        queryString}`;
+      this._page(listUrl);
+
+      // TODO(crbug.com/monorail/6341): Switch to using the new hotlist URL once
+      // hotlists have migrated.
+      // this._page(`/hotlists/${params['hotlist_id']}`);
+    } else {
+      delete params.id;
+      const listUrl = `/p/${this._projectName}/issues/list?${queryString}`;
+      this._page(listUrl);
+    }
+  }
+
+  /**
+   * Scrolls the user to the issue editing form when they press
+   * the 'r' key.
+   * @private
+   */
+  _jumpToEditForm() {
+    // Force a hash change even the hash is already makechanges.
+    if (window.location.hash.toLowerCase() === '#makechanges') {
+      window.location.hash = ' ';
+    }
+    window.location.hash = '#makechanges';
+  }
+
+  /**
+   * Stars the current issue the user is viewing on the issue detail page.
+   * @private
+   */
+  _starIssue() {
+    if (!this._fetchingIsStarred && !this._isStarring) {
+      const newIsStarred = !this._isStarred;
+
+      store.dispatch(issueV0.star(this._issueRef, newIsStarred));
+    }
+  }
+
+
+  /** @private */
+  _unbindIssueDetailKeys() {
+    Mousetrap.unbind('k');
+    Mousetrap.unbind('j');
+    Mousetrap.unbind('u');
+    Mousetrap.unbind('r');
+    Mousetrap.unbind('s');
+  }
+}
+
+customElements.define('mr-keystrokes', MrKeystrokes);
diff --git a/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js
new file mode 100644
index 0000000..0d7468f
--- /dev/null
+++ b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js
@@ -0,0 +1,194 @@
+// 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 {MrKeystrokes} from './mr-keystrokes.js';
+import Mousetrap from 'mousetrap';
+
+import {issueRefToString} from 'shared/convertersV0.js';
+
+/** @type {MrKeystrokes} */
+let element;
+
+describe('mr-keystrokes', () => {
+  beforeEach(() => {
+    element = /** @type {MrKeystrokes} */ (
+      document.createElement('mr-keystrokes'));
+    document.body.appendChild(element);
+
+    element._projectName = 'proj';
+    element.issueId = 11;
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrKeystrokes);
+  });
+
+  it('tracks if the issue is currently starring', async () => {
+    await element.updateComplete;
+    assert.isFalse(element._isStarring);
+
+    const issueRefStr = issueRefToString(element._issueRef);
+    element._starringIssues.set(issueRefStr, {requesting: true});
+    assert.isTrue(element._isStarring);
+  });
+
+  it('? and esc open and close dialog', async () => {
+    await element.updateComplete;
+    assert.isFalse(element._opened);
+
+    Mousetrap.trigger('?');
+
+    await element.updateComplete;
+    assert.isTrue(element._opened);
+
+    Mousetrap.trigger('esc');
+
+    await element.updateComplete;
+    assert.isFalse(element._opened);
+  });
+
+  describe('issue detail keys', () => {
+    beforeEach(() => {
+      sinon.stub(element, '_page');
+      sinon.stub(element, '_jumpToEditForm');
+      sinon.stub(element, '_starIssue');
+    });
+
+    it('not bound when _projectName not set', async () => {
+      element._projectName = '';
+      element.issueId = 1;
+
+      await element.updateComplete;
+
+      // Navigation hot keys.
+      Mousetrap.trigger('k');
+      Mousetrap.trigger('j');
+      Mousetrap.trigger('u');
+      sinon.assert.notCalled(element._page);
+
+      // Jump to edit form hot key.
+      Mousetrap.trigger('r');
+      sinon.assert.notCalled(element._jumpToEditForm);
+
+      // Star issue hotkey.
+      Mousetrap.trigger('s');
+      sinon.assert.notCalled(element._starIssue);
+    });
+
+    it('not bound when issueId not set', async () => {
+      element._projectName = 'proj';
+      element.issueId = 0;
+
+      await element.updateComplete;
+
+      // Navigation hot keys.
+      Mousetrap.trigger('k');
+      Mousetrap.trigger('j');
+      Mousetrap.trigger('u');
+      sinon.assert.notCalled(element._page);
+
+      // Jump to edit form hot key.
+      Mousetrap.trigger('r');
+      sinon.assert.notCalled(element._jumpToEditForm);
+
+      // Star issue hotkey.
+      Mousetrap.trigger('s');
+      sinon.assert.notCalled(element._starIssue);
+    });
+
+    it('binds j and k navigation hot keys', async () => {
+      element.queryParams = {q: 'something'};
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('k');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/detail/previous?q=something');
+
+      Mousetrap.trigger('j');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/detail/next?q=something');
+
+      Mousetrap.trigger('u');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/list?q=something&cursor=proj%3A11');
+    });
+
+    it('u key navigates back to issue list wth cursor set', async () => {
+      element.queryParams = {q: 'something'};
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('u');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/list?q=something&cursor=proj%3A11');
+    });
+
+    it('u key navigates back to hotlist when hotlist_id set', async () => {
+      element.queryParams = {hotlist_id: 1234};
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('u');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/detail/list?hotlist_id=1234&cursor=proj%3A11');
+    });
+
+    it('does not star when user does not have permission', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = [];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('s');
+      sinon.assert.notCalled(element._starIssue);
+    });
+
+    it('does star when user has permission', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = ['setstar'];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('s');
+      sinon.assert.calledOnce(element._starIssue);
+    });
+
+    it('does not star when user does not have permission', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = [];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('s');
+      sinon.assert.notCalled(element._starIssue);
+    });
+
+    it('does not jump to edit form when user cannot comment', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = [];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('r');
+      sinon.assert.notCalled(element._jumpToEditForm);
+    });
+
+    it('does jump to edit form when user can comment', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = ['addissuecomment'];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('r');
+      sinon.assert.calledOnce(element._jumpToEditForm);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.js b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.js
new file mode 100644
index 0000000..a5f9d7a
--- /dev/null
+++ b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.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} from 'lit-element';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-toggle/chops-toggle.js';
+import {logEvent} from 'monitoring/client-logger.js';
+
+/**
+ * `<mr-pref-toggle>`
+ *
+ * Toggle button for any user pref, including code font and
+ * rendering markdown.  For our purposes, pressing it causes
+ * issue description and comment text to switch either to
+ * monospace font or to render in markdown and the setting
+ * is saved in the user's preferences.
+ */
+export class MrPrefToggle extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+        <chops-toggle
+          ?checked=${this._checked}
+          ?disabled=${this._prefsInFlight}
+          @checked-change=${this._togglePref}
+          title=${this.title}
+        >${this.label}</chops-toggle>
+      `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      prefs: {type: Object},
+      userDisplayName: {type: String},
+      initialValue: {type: Boolean},
+      _prefsInFlight: {type: Boolean},
+      label: {type: String},
+      title: {type: String},
+      prefName: {type: String},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.prefs = userV0.prefs(state);
+    this._prefsInFlight = userV0.requests(state).fetchPrefs.requesting ||
+      userV0.requests(state).setPrefs.requesting;
+    this._projectName = projectV0.viewedProjectName(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.initialValue = false;
+    this.userDisplayName = '';
+    this.label = '';
+    this.title = '';
+    this.prefName = '';
+    this._projectName = '';
+  }
+
+  // Used by the legacy EZT page to interact with Redux.
+  fetchPrefs() {
+    store.dispatch(userV0.fetchPrefs());
+  }
+
+  get _checked() {
+    const {prefs, initialValue} = this;
+    if (prefs && prefs.has(this.prefName)) return prefs.get(this.prefName);
+    return initialValue;
+  }
+
+  /**
+   * Toggles the code font in response to the user activating the button.
+   * @param {Event} e
+   * @fires CustomEvent#font-toggle
+   * @private
+   */
+  _togglePref(e) {
+    const checked = e.detail.checked;
+    this.dispatchEvent(new CustomEvent('font-toggle', {detail: {checked}}));
+
+    const newPrefs = [{name: this.prefName, value: '' + checked}];
+    store.dispatch(userV0.setPrefs(newPrefs, !!this.userDisplayName));
+
+    logEvent('mr-pref-toggle', `${this.prefName}: ${checked}`, this._projectName);
+  }
+}
+customElements.define('mr-pref-toggle', MrPrefToggle);
diff --git a/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js
new file mode 100644
index 0000000..b6dbb41
--- /dev/null
+++ b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js
@@ -0,0 +1,86 @@
+// 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';
+import {assert} from 'chai';
+import {MrPrefToggle} from './mr-pref-toggle.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+
+describe('mr-pref-toggle', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-pref-toggle');
+    element.label = 'Code';
+    element.title = 'Code font';
+    element.prefName = 'code_font';
+    document.body.appendChild(element);
+    sinon.stub(prpcClient, 'call').returns(Promise.resolve({}));
+    window.ga = sinon.stub();
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrPrefToggle);
+  });
+
+  it('toggling does not save when user is not logged in', async () => {
+    element.userDisplayName = undefined;
+    element.prefs = new Map([]);
+
+    await element.updateComplete;
+
+    const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+    chopsToggle.click();
+    await element.updateComplete;
+
+    sinon.assert.notCalled(prpcClient.call);
+
+    assert.isTrue(element.prefs.get('code_font'));
+  });
+
+  it('toggling to true saves result', async () => {
+    element.userDisplayName = 'test@example.com';
+    element.prefs = new Map([['code_font', false]]);
+
+    await element.updateComplete;
+
+    const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+
+    chopsToggle.click(); // Toggle it on.
+    await element.updateComplete;
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Users',
+        'SetUserPrefs',
+        {prefs: [{name: 'code_font', value: 'true'}]});
+
+    assert.isTrue(element.prefs.get('code_font'));
+  });
+
+  it('toggling to false saves result', async () => {
+    element.userDisplayName = 'test@example.com';
+    element.prefs = new Map([['code_font', true]]);
+
+    await element.updateComplete;
+
+    const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+
+    chopsToggle.click(); // Toggle it off.
+    await element.updateComplete;
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Users',
+        'SetUserPrefs',
+        {prefs: [{name: 'code_font', value: 'false'}]});
+
+    assert.isFalse(element.prefs.get('code_font'));
+  });
+});
diff --git a/static_src/elements/framework/mr-site-banner/mr-site-banner.js b/static_src/elements/framework/mr-site-banner/mr-site-banner.js
new file mode 100644
index 0000000..2a98a5c
--- /dev/null
+++ b/static_src/elements/framework/mr-site-banner/mr-site-banner.js
@@ -0,0 +1,75 @@
+// 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 'elements/chops/chops-timestamp/chops-timestamp.js';
+import {connectStore} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+export class MrSiteBanner extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return css`
+      :host([hidden]) {
+        display: none;
+      }
+      :host {
+        display: block;
+        font-weight: bold;
+        color: var(--chops-field-error-color);
+        background: var(--chops-orange-50);
+        padding: 5px;
+        text-align: center;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      ${this.bannerMessage}
+      ${this.bannerTime ? html`
+        <chops-timestamp
+          .timestamp=${this.bannerTime}
+        ></chops-timestamp>
+      ` : ''}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      hidden: {
+        type: Boolean,
+        reflect: true,
+      },
+      bannerMessage: {type: String},
+      bannerTime: {type: Number},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.bannerMessage = '';
+    this.bannerTime = 0;
+    this.hidden = false;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.bannerMessage = sitewide.bannerMessage(state);
+    this.bannerTime = sitewide.bannerTime(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('bannerMessage')) {
+      this.hidden = !this.bannerMessage;
+    }
+  }
+}
+
+customElements.define('mr-site-banner', MrSiteBanner);
diff --git a/static_src/elements/framework/mr-site-banner/mr-site-banner.test.js b/static_src/elements/framework/mr-site-banner/mr-site-banner.test.js
new file mode 100644
index 0000000..527b942
--- /dev/null
+++ b/static_src/elements/framework/mr-site-banner/mr-site-banner.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 {FORMATTER}
+  from 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {MrSiteBanner} from './mr-site-banner.js';
+
+
+let element;
+
+describe('mr-site-banner', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-site-banner');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrSiteBanner);
+  });
+
+  it('displays a banner message', async () => {
+    element.bannerMessage = 'Message';
+    await element.updateComplete;
+    assert.equal(element.shadowRoot.textContent.trim(), 'Message');
+    assert.isNull(element.shadowRoot.querySelector('chops-timestamp'));
+  });
+
+  it('displays the banner timestamp', async () => {
+    const timestamp = 1560450600;
+
+    element.bannerMessage = 'Message';
+    element.bannerTime = timestamp;
+    await element.updateComplete;
+
+    const chopsTimestamp = element.shadowRoot.querySelector('chops-timestamp');
+
+    // The formatted date strings differ based on time zone and browser, so we
+    // can't use static strings for testing. We can't stub out the format method
+    // because it's native code and can't be modified. So just use the FORMATTER
+    // object.
+    assert.include(
+        chopsTimestamp.shadowRoot.textContent,
+        FORMATTER.format(new Date(timestamp * 1000)));
+  });
+
+  it('hides when there is no banner message', async () => {
+    await element.updateComplete;
+    assert.isTrue(element.hidden);
+  });
+});
diff --git a/static_src/elements/framework/mr-star/mr-issue-star.js b/static_src/elements/framework/mr-star/mr-issue-star.js
new file mode 100644
index 0000000..5255820
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-issue-star.js
@@ -0,0 +1,110 @@
+// Copyright 2020 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 {connectStore, store} from 'reducers/base.js';
+import * as users from 'reducers/users.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+import {MrStar} from './mr-star.js';
+
+
+/**
+ * `<mr-issue-star>`
+ *
+ * A button for starring an issue.
+ *
+ */
+export class MrIssueStar extends connectStore(MrStar) {
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * A reference to the issue that the star button interacts with.
+       */
+      issueRef: {type: Object},
+      /**
+       * Whether the issue is starred (used for accessing easily).
+       */
+      _starredIssues: {type: Set},
+      /**
+       * Whether the issue's star state is being fetched. This is taken from
+       * the component's parent, which is expected to handle fetching initial
+       * star state for an issue.
+       */
+      _fetchingIsStarred: {type: Boolean},
+      /**
+       * A Map of all issues currently being starred.
+       */
+      _starringIssues: {type: Object},
+      /**
+       * The currently logged in user. Required to determine if the user can
+       * star.
+       */
+      _currentUserName: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /**
+     * @type {IssueRef}
+     */
+    this.issueRef = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._currentUserName = users.currentUserName(state);
+
+    // TODO(crbug.com/monorail/7374): Remove references to issueV0 in
+    // <mr-star>.
+    this._starringIssues = issueV0.starringIssues(state);
+    this._starredIssues = issueV0.starredIssues(state);
+    this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
+  }
+
+  /** @override */
+  get type() {
+    return 'issue';
+  }
+
+  /**
+   * @return {boolean} Whether there's an in-flight star request.
+   */
+  get _isStarring() {
+    const requestKey = issueRefToString(this.issueRef);
+    if (this._starringIssues.has(requestKey)) {
+      return this._starringIssues.get(requestKey).requesting;
+    }
+    return false;
+  }
+
+  /** @override */
+  get isLoggedIn() {
+    return !!this._currentUserName;
+  }
+
+  /** @override */
+  get requesting() {
+    return this._fetchingIsStarred || this._isStarring;
+  }
+
+  /** @override */
+  get isStarred() {
+    return this._starredIssues.has(issueRefToString(this.issueRef));
+  }
+
+  /** @override */
+  star() {
+    store.dispatch(issueV0.star(this.issueRef, true));
+  }
+
+  /** @override */
+  unstar() {
+    store.dispatch(issueV0.star(this.issueRef, false));
+  }
+}
+
+customElements.define('mr-issue-star', MrIssueStar);
diff --git a/static_src/elements/framework/mr-star/mr-issue-star.test.js b/static_src/elements/framework/mr-star/mr-issue-star.test.js
new file mode 100644
index 0000000..bb618f7
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-issue-star.test.js
@@ -0,0 +1,85 @@
+// Copyright 2020 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 {MrIssueStar} from './mr-issue-star.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+import sinon from 'sinon';
+
+
+let element;
+
+describe('mr-issue-star', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-star');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueStar);
+  });
+
+  it('starring logins user when user is not logged in', async () => {
+    element._currentUserName = undefined;
+    sinon.stub(element, 'login');
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    star.click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  it('_isStarring true only when issue ref is being starred', async () => {
+    element._starringIssues = new Map([['chromium:22', {requesting: true}]]);
+    element.issueRef = {projectName: 'chromium', localId: 5};
+
+    assert.isFalse(element._isStarring);
+
+    element.issueRef = {projectName: 'chromium', localId: 22};
+
+    assert.isTrue(element._isStarring);
+
+    element._starringIssues = new Map([['chromium:22', {requesting: false}]]);
+
+    assert.isFalse(element._isStarring);
+  });
+
+  it('starring is disabled when _isStarring true', () => {
+    element._currentUserName = 'users/1234';
+    sinon.stub(element, '_isStarring').get(() => true);
+
+    assert.isFalse(element._starringEnabled);
+  });
+
+  it('starring is disabled when _fetchingIsStarred true', () => {
+    element._currentUserName = 'users/1234';
+    element._fetchingIsStarred = true;
+
+    assert.isFalse(element._starringEnabled);
+  });
+
+  it('_starredIssues changes displayed icon', async () => {
+    element.issueRef = {projectName: 'proj', localId: 1};
+
+    element._starredIssues = new Set([issueRefToString(element.issueRef)]);
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+    assert.equal(star.textContent.trim(), 'star');
+
+    element._starredIssues = new Set();
+
+    await element.updateComplete;
+
+    assert.equal(star.textContent.trim(), 'star_border');
+  });
+});
diff --git a/static_src/elements/framework/mr-star/mr-project-star.js b/static_src/elements/framework/mr-star/mr-project-star.js
new file mode 100644
index 0000000..14b2c73
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-project-star.js
@@ -0,0 +1,148 @@
+// Copyright 2020 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 {connectStore, store} from 'reducers/base.js';
+import * as users from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {projectAndUserToStarName} from 'shared/converters.js';
+import {MrStar} from './mr-star.js';
+import 'shared/typedef.js';
+
+
+/**
+ * `<mr-project-star>`
+ *
+ * A button for starring a project.
+ *
+ */
+export class MrProjectStar extends connectStore(MrStar) {
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Resource name of the project being starred.
+       */
+      name: {type: String},
+      /**
+       * List of all stars, indexed by star name.
+       */
+      _stars: {type: Object},
+      /**
+       * Whether project stars are currently being fetched.
+       */
+      _fetchingStars: {type: Boolean},
+      /**
+       * Request data for projects currently being starred.
+       */
+      _starringProjects: {type: Object},
+      /**
+       * Request data for projects currently being unstarred.
+       */
+      _unstarringProjects: {type: Object},
+      /**
+       * The currently logged in user. Required to determine if the user can
+       * star.
+       */
+      _currentUserName: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {string} */
+    this.name = undefined;
+
+    /** @type {boolean} */
+    this._fetchingStars = false;
+
+    /** @type {Object<ProjectStarName, ReduxRequestState>} */
+    this._starringProjects = {};
+
+    /** @type {Object<ProjectStarName, ReduxRequestState>} */
+    this._unstarringProjects = {};
+
+    /** @type {Object<StarName, Star>} */
+    this._stars = {};
+
+    /** @type {string} */
+    this._currentUserName = undefined;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._currentUserName = users.currentUserName(state);
+
+    this._stars = stars.byName(state);
+
+    const requests = stars.requests(state);
+    this._fetchingStars = requests.listProjects.requesting;
+    this._starringProjects = requests.starProject;
+    this._unstarringProjects = requests.unstarProject;
+  }
+
+  /** @override */
+  get type() {
+    return 'project';
+  }
+
+  /**
+   * @return {string} The resource name of the ProjectStar.
+   */
+  get _starName() {
+    return projectAndUserToStarName(this.name, this._currentUserName);
+  }
+
+  /**
+   * @return {ProjectStar} The ProjectStar object for the referenced project,
+   *   if one exists.
+   */
+  get _projectStar() {
+    const name = this._starName;
+    if (!(name in this._stars)) return {};
+    return this._stars[name];
+  }
+
+  /**
+   * @return {boolean} Whether there's an in-flight star request.
+   */
+  get _isStarring() {
+    const requestKey = this._starName;
+    if (requestKey in this._starringProjects &&
+        this._starringProjects[requestKey].requesting) {
+      return true;
+    }
+    if (requestKey in this._unstarringProjects &&
+        this._unstarringProjects[requestKey].requesting) {
+      return true;
+    }
+    return false;
+  }
+
+  /** @override */
+  get isLoggedIn() {
+    return !!this._currentUserName;
+  }
+
+  /** @override */
+  get requesting() {
+    return this._fetchingStars || this._isStarring;
+  }
+
+  /** @override */
+  get isStarred() {
+    return !!(this._projectStar && this._projectStar.name);
+  }
+
+  /** @override */
+  star() {
+    store.dispatch(stars.starProject(this.name, this._currentUserName));
+  }
+
+  /** @override */
+  unstar() {
+    store.dispatch(stars.unstarProject(this.name, this._currentUserName));
+  }
+}
+
+customElements.define('mr-project-star', MrProjectStar);
diff --git a/static_src/elements/framework/mr-star/mr-project-star.test.js b/static_src/elements/framework/mr-star/mr-project-star.test.js
new file mode 100644
index 0000000..6afd982
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-project-star.test.js
@@ -0,0 +1,181 @@
+// Copyright 2020 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 {MrProjectStar} from './mr-project-star.js';
+import {stars} from 'reducers/stars.js';
+
+let element;
+
+describe('mr-project-star (disconnected)', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-project-star');
+    document.body.appendChild(element);
+
+    sinon.stub(element, 'stateChanged');
+    sinon.spy(stars, 'starProject');
+    sinon.spy(stars, 'unstarProject');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    stars.starProject.restore();
+    stars.unstarProject.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrProjectStar);
+  });
+
+  it('clicking on star when logged out logs in user', async () => {
+    element._currentUserName = undefined;
+    sinon.stub(element, 'login');
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    star.click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  it('star dispatches star request', () => {
+    element._currentUserName = 'users/1234';
+    element.name = 'projects/monorail';
+
+    element.star();
+
+    sinon.assert.calledWith(stars.starProject,
+        'projects/monorail', 'users/1234');
+  });
+
+  it('unstar dispatches unstar request', () => {
+    element._currentUserName = 'users/1234';
+    element.name = 'projects/monorail';
+
+    element.unstar();
+
+    sinon.assert.calledWith(stars.unstarProject,
+        'projects/monorail', 'users/1234');
+  });
+
+  describe('isStarred', () => {
+    beforeEach(() => {
+      element._stars = {
+        'users/1234/projectStars/monorail':
+            {name: 'users/1234/projectStars/monorail'},
+        'users/5678/projectStars/chromium':
+            {name: 'users/5678/projectStars/chromium'},
+      };
+    });
+
+    it('false when no data', () => {
+      element._stars = {};
+      assert.isFalse(element.isStarred);
+    });
+
+    it('false when user is not logged in', () => {
+      element._currentUserName = '';
+      element.name = 'projects/monorail';
+
+      assert.isFalse(element.isStarred);
+    });
+
+    it('false when project is not starred', () => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/chromium';
+
+      assert.isFalse(element.isStarred);
+
+      element._currentUserName = 'users/5678';
+      element.name = 'projects/monorail';
+
+      assert.isFalse(element.isStarred);
+    });
+
+    it('true when user has starred project', () => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/monorail';
+
+      assert.isTrue(element.isStarred);
+
+      element._currentUserName = 'users/5678';
+      element.name = 'projects/chromium';
+
+      assert.isTrue(element.isStarred);
+    });
+  });
+
+  describe('_starringEnabled', () => {
+    beforeEach(() => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/monorail';
+    });
+
+    it('disabled when user is not logged in', () => {
+      element._currentUserName = '';
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when stars are being fetched', () => {
+      element._fetchingStars = true;
+      element._starringProjects = {};
+      element._unstarringProjects = {};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user is starring project', () => {
+      element._fetchingStars = false;
+      element._starringProjects =
+          {'users/1234/projectStars/monorail': {requesting: true}};
+      element._unstarringProjects = {};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user is unstarring project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects =
+          {'users/1234/projectStars/monorail': {requesting: true}};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('enabled when user is starring an unrelated project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {
+        'users/1234/projectStars/chromium': {requesting: true},
+        'users/1234/projectStars/monorail': {requesting: false},
+      };
+      element._unstarringProjects = {};
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('enabled when user is unstarring an unrelated project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects = {
+        'users/1234/projectStars/chromium': {requesting: true},
+        'users/1234/projectStars/monorail': {requesting: false},
+      };
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('enabled when no in-flight requests', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects = {};
+
+      assert.isTrue(element._starringEnabled);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-star/mr-star.js b/static_src/elements/framework/mr-star/mr-star.js
new file mode 100644
index 0000000..fe509be
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-star.js
@@ -0,0 +1,235 @@
+// 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';
+
+/**
+ * `<mr-star>`
+ *
+ * A button for starring a resource. Does not directly integrate with app
+ * state. Subclasses by <mr-issue-star> and <mr-project-star>, which add
+ * resource-specific logic for state management.
+ *
+ */
+export class MrStar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        --mr-star-size: var(--chops-icon-font-size);
+      }
+      button {
+        background: none;
+        border: none;
+        cursor: pointer;
+        padding: 0;
+        margin: 0;
+        display: flex;
+        align-items: center;
+      }
+      /* TODO(crbug.com/monorail/8008): Add nicer looking loading style. */
+      button.loading {
+        opacity: 0.5;
+        cursor: default;
+      }
+      i.material-icons {
+        font-size: var(--mr-star-size);
+        color: var(--chops-primary-icon-color);
+      }
+      i.material-icons.starred {
+        color: var(--chops-primary-accent-color);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    const {isStarred} = this;
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <button class="star-button"
+        @click=${this._loginOrStar}
+        title=${this._starToolTip}
+        role="checkbox"
+        aria-checked=${isStarred ? 'true' : 'false'}
+        class=${this.requesting ? 'loading' : ''}
+      >
+        ${isStarred ? html`
+          <i class="material-icons starred" role="presentation">
+            star
+          </i>
+        `: html`
+          <i class="material-icons" role="presentation">
+            star_border
+          </i>
+        `}
+      </button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Note: In order for re-renders to happen based on the getters defined
+       * in this class, those getters must have values based on properties.
+       * Subclasses of <mr-star> are not expected to inherit <mr-star>'s
+       * properties, but they should make sure their getter implementations
+       * are also backed by properties.
+       */
+      _isStarred: {type: Boolean},
+      _isLoggedIn: {type: Boolean},
+      _canStar: {type: Boolean},
+      _requesting: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /**
+     * @type {boolean} Whether the user has starred the resource or not.
+     */
+    this._isStarred = false;
+
+    /**
+     * @type {boolean} If the user is logged in.
+     */
+    this._isLoggedIn = false;
+
+    /**
+     * @return {boolean} Whether the user has permission to star the star.
+     */
+    this._canStar = true;
+
+    /**
+     * @return {boolean} Whether there's an in-flight request to star
+     * the resource.
+     */
+    this._requesting = false;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Prevent clicks on this element from causing navigation if the element
+    // is embedded inside a link.
+    this.addEventListener('click', (e) => e.preventDefault());
+  }
+
+  /**
+   * @return {boolean} If the user is logged in.
+   */
+  get isLoggedIn() {
+    return this._isLoggedIn;
+  }
+
+  /**
+   * @return {boolean} If there's an in-flight request that might affect the
+   *   star's data.
+   */
+  get requesting() {
+    return this._requesting;
+  }
+
+  /**
+   * @return {boolean} Whether the resource is starred or not.
+   */
+  get isStarred() {
+    return this._isStarred;
+  }
+
+  /**
+   * @return {boolean} If the user has permission to star.
+   */
+  get canStar() {
+    return this._canStar;
+  }
+
+  /**
+   * @return {boolean}
+   */
+  get _starringEnabled() {
+    return this.isLoggedIn && this.canStar && !this.requesting;
+  }
+
+  /**
+   * @return {string} The name of the resource kind being starred.
+   * ie: issue, project, etc.
+   */
+  get type() {
+    return 'resource';
+  }
+
+  /**
+   * @return {string} the title to display on the star button.
+   */
+  get _starToolTip() {
+    if (!this.isLoggedIn) {
+      return `Login to star this ${this.type}.`;
+    }
+    if (!this.canStar) {
+      return `You don't have permission to star this ${this.type}.`;
+    }
+    if (this.requesting) {
+      return `Loading star state for this ${this.type}.`;
+    }
+    return `${this.isStarred ? 'Unstar' : 'Star'} this ${this.type}.`;
+  }
+
+  /**
+   * Logins the user if they're not logged in. Otherwise, stars or
+   * unstars the resource based on star state.
+   */
+  _loginOrStar() {
+    if (!this.isLoggedIn) {
+      this.login();
+    } else {
+      this.toggleStar();
+    }
+  }
+
+  /**
+   * Logs in the user.
+   */
+  login() {
+    // TODO(crbug.com/monorail/6073): Replace this logic with a function call
+    // when moving authentication to frontend.
+    // HACK: In our current login implementation, login URLs can only be
+    // generated by the backend which makes piping a login URL into a component
+    // a <mr-star> complex. To get around this, we're using the
+    // legacy window.CS_env infrastructure.
+    window.location.href = window.CS_env.login_url;
+  }
+
+  /**
+   * Stars or unstars the resource based on the user's interaction.
+   */
+  toggleStar() {
+    if (!this._starringEnabled) return;
+    if (this.isStarred) {
+      this.unstar();
+    } else {
+      this.star();
+    }
+  }
+
+  /**
+   * Stars the given resource. To be implemented by a subclass.
+   */
+  star() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * Unstars the given resource. To be implemented by a subclass.
+   */
+  unstar() {
+    throw new Error('Method not implemented.');
+  }
+}
+
+customElements.define('mr-star', MrStar);
diff --git a/static_src/elements/framework/mr-star/mr-star.test.js b/static_src/elements/framework/mr-star/mr-star.test.js
new file mode 100644
index 0000000..4db7877
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-star.test.js
@@ -0,0 +1,302 @@
+// 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 {MrStar} from './mr-star.js';
+
+let element;
+
+describe('mr-star', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-star');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    if (document.body.contains(element)) {
+      document.body.removeChild(element);
+    }
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrStar);
+  });
+
+  it('unimplemented methods throw errors', () => {
+    assert.throws(element.star, 'Method not implemented.');
+    assert.throws(element.unstar, 'Method not implemented.');
+  });
+
+  describe('clicking star toggles star state', () => {
+    beforeEach(() => {
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+      element._isLoggedIn = true;
+      element._canStar = true;
+    });
+
+    it('unstarred star', async () => {
+      element._isStarred = false;
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.notCalled(element.unstar);
+
+      element.shadowRoot.querySelector('button').click();
+
+      sinon.assert.calledOnce(element.star);
+      sinon.assert.notCalled(element.unstar);
+    });
+
+    it('starred star', async () => {
+      element._isStarred = true;
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.notCalled(element.unstar);
+
+      element.shadowRoot.querySelector('button').click();
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.calledOnce(element.unstar);
+    });
+  });
+
+  it('clicking while logged out logs you in', async () => {
+    sinon.stub(element, 'login');
+    element._isLoggedIn = false;
+    element._canStar = true;
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.login);
+
+    element.shadowRoot.querySelector('button').click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  describe('toggleStar', () => {
+    beforeEach(() => {
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+    });
+
+    it('stars when unstarred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = false;
+
+      element.toggleStar();
+
+      sinon.assert.calledOnce(element.star);
+      sinon.assert.notCalled(element.unstar);
+    });
+
+    it('unstars when starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.calledOnce(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when user is not logged in', () => {
+      element._isLoggedIn = false;
+      element._canStar = true;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when user does not have permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when stars are being fetched', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+  });
+
+  describe('_starringEnabled', () => {
+    it('enabled when user is logged in and has permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = false;
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('disabled when user is logged out', () => {
+      element._isLoggedIn = false;
+      element._canStar = false;
+      element._isStarred = false;
+      element._requesting = false;
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user has no permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      element._isStarred = true;
+      element._requesting = false;
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when requesting star', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = true;
+
+      assert.isFalse(element._starringEnabled);
+    });
+  });
+
+  it('loading state shown when requesting', async () => {
+    element._requesting = true;
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    assert.isTrue(star.classList.contains('loading'));
+
+    element._requesting = false;
+    await element.updateComplete;
+
+    assert.isFalse(star.classList.contains('loading'));
+  });
+
+  it('isStarred changes displayed icon', async () => {
+    element._isStarred = true;
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+    assert.equal(star.textContent.trim(), 'star');
+
+    element._isStarred = false;
+    await element.updateComplete;
+
+    assert.equal(star.textContent.trim(), 'star_border');
+  });
+
+  describe('mr-star nested inside a link', () => {
+    let parent;
+    let oldHash;
+
+    beforeEach(() => {
+      parent = document.createElement('a');
+      parent.setAttribute('href', '#test-hash');
+      parent.appendChild(element);
+
+      oldHash = window.location.hash;
+
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+    });
+
+    afterEach(() => {
+      window.location.hash = oldHash;
+    });
+
+    it('clicking to star does not cause navigation', async () => {
+      sinon.spy(element, 'toggleStar');
+      element._isLoggedIn = true;
+      element._canStar = true;
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('button').click();
+
+      assert.notEqual(window.location.hash, '#test-hash');
+      sinon.assert.calledOnce(element.toggleStar);
+    });
+
+    it('clicking on disabled star does not cause navigation', async () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('button').click();
+
+      assert.notEqual(window.location.hash, '#test-hash');
+    });
+
+    it('clicking on link still navigates', async () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      await element.updateComplete;
+
+      parent.click();
+
+      assert.equal(window.location.hash, '#test-hash');
+    });
+  });
+
+  describe('_starToolTip', () => {
+    it('not logged in', () => {
+      element._isLoggedIn = false;
+      element._canStar = false;
+      assert.equal(element._starToolTip,
+          `Login to star this resource.`);
+    });
+
+    it('no permission to star', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      assert.equal(element._starToolTip,
+          `You don't have permission to star this resource.`);
+    });
+
+    it('star is loading', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._requesting = true;
+      assert.equal(element._starToolTip,
+          `Loading star state for this resource.`);
+    });
+
+    it('issue is not starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = false;
+      assert.equal(element._starToolTip,
+          `Star this resource.`);
+    });
+
+    it('issue is starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      assert.equal(element._starToolTip,
+          `Unstar this resource.`);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-tabs/mr-tabs.js b/static_src/elements/framework/mr-tabs/mr-tabs.js
new file mode 100644
index 0000000..d14688e
--- /dev/null
+++ b/static_src/elements/framework/mr-tabs/mr-tabs.js
@@ -0,0 +1,99 @@
+// Copyright 2020 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 'shared/typedef.js';
+
+/**
+ * `<mr-tabs>`
+ *
+ * A Material Design tabs strip. https://material.io/components/tabs/
+ *
+ */
+export class MrTabs extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      ul {
+        display: flex;
+        list-style: none;
+        margin: 0;
+        padding: 0;
+      }
+      li {
+        color: var(--chops-choice-color);
+      }
+      li.selected {
+        color: var(--chops-active-choice-color);
+      }
+      li:hover {
+        background: var(--chops-primary-accent-bg);
+        color: var(--chops-active-choice-color);
+      }
+      a {
+        color: inherit;
+        text-decoration: none;
+
+        display: inline-block;
+        line-height: 38px;
+        padding: 0 24px;
+      }
+      li.selected a {
+        border-bottom: solid 2px;
+      }
+      i.material-icons {
+        vertical-align: middle;
+        margin-right: 4px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <ul>
+        ${this.items.map(this._renderTab.bind(this))}
+      </ul>
+    `;
+  }
+
+  /**
+   * Renders one tab.
+   * @param {MenuItem} item
+   * @param {number} index
+   * @return {TemplateResult}
+   */
+  _renderTab(item, index) {
+    return html`
+      <li class=${index === this.selected ? 'selected' : ''}>
+        <a href=${item.url}>
+          <i class="material-icons" ?hidden=${!item.icon}>
+            ${item.icon}
+          </i>
+          ${item.text}
+        </a>
+      </li>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      items: {type: Array},
+      selected: {type: Number},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {Array<MenuItem>} */
+    this.items = [];
+    this.selected = 0;
+  }
+}
+
+customElements.define('mr-tabs', MrTabs);
diff --git a/static_src/elements/framework/mr-tabs/mr-tabs.test.js b/static_src/elements/framework/mr-tabs/mr-tabs.test.js
new file mode 100644
index 0000000..1d55c39
--- /dev/null
+++ b/static_src/elements/framework/mr-tabs/mr-tabs.test.js
@@ -0,0 +1,38 @@
+// Copyright 2020 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 {MrTabs} from './mr-tabs.js';
+
+/** @type {MrTabs} */
+let element;
+
+describe('mr-tabs', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-tabs');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrTabs);
+  });
+
+  it('renders tabs', async () => {
+    element.items = [
+      {text: 'Text 1'},
+      {text: 'Text 2', icon: 'done', url: 'https://url'},
+    ];
+    element.selected = 1;
+    await element.updateComplete;
+
+    const items = element.shadowRoot.querySelectorAll('li');
+    assert.equal(items[0].className, '');
+    assert.equal(items[1].className, 'selected');
+  });
+});
diff --git a/static_src/elements/framework/mr-upload/mr-upload.js b/static_src/elements/framework/mr-upload/mr-upload.js
new file mode 100644
index 0000000..5fee672
--- /dev/null
+++ b/static_src/elements/framework/mr-upload/mr-upload.js
@@ -0,0 +1,322 @@
+// 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 {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-upload>`
+ *
+ * A file uploading widget for use in adding attachments and similar things.
+ *
+ */
+export class MrUpload extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+          width: 100%;
+          padding: 0.25em 4px;
+          border: 1px dashed var(--chops-gray-300);
+          box-sizing: border-box;
+          border-radius: 8px;
+          transition: background 0.2s ease-in-out,
+            border-color 0.2s ease-in-out;
+        }
+        :host([hidden]) {
+          display: none;
+        }
+        :host([expanded]) {
+          /* Expand the drag and drop area when a file is being dragged. */
+          min-height: 120px;
+        }
+        :host([highlighted]) {
+          border-color: var(--chops-primary-accent-color);
+          background: var(--chops-active-choice-bg);
+        }
+        input[type="file"] {
+          /* We need the file uploader to be hidden but still accessible. */
+          opacity: 0;
+          width: 0;
+          height: 0;
+          position: absolute;
+          top: -9999;
+          left: -9999;
+        }
+        input[type="file"]:focus + label {
+          /* TODO(zhangtiff): Find a way to either mimic native browser focus
+           * styles or make focus styles more consistent. */
+          box-shadow: 0 0 3px 1px hsl(193, 82%, 63%);
+        }
+        label.button {
+          margin-right: 8px;
+          padding: 0.1em 4px;
+          display: inline-flex;
+          width: auto;
+          cursor: pointer;
+          border: var(--chops-normal-border);
+          margin-left: 0;
+        }
+        label.button i.material-icons {
+          font-size: var(--chops-icon-font-size);
+        }
+        ul {
+          display: flex;
+          align-items: flex-start;
+          justify-content: flex-start;
+          flex-direction: column;
+        }
+        ul[hidden] {
+          display: none;
+        }
+        li {
+          display: inline-flex;
+          align-items: center;
+        }
+        li i.material-icons {
+          font-size: 14px;
+          margin: 0;
+        }
+        /* TODO(zhangtiff): Create a shared Material icon button component. */
+        button {
+          border-radius: 50%;
+          cursor: pointer;
+          background: 0;
+          border: 0;
+          padding: 0.25em;
+          margin-left: 4px;
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          transition: background 0.2s ease-in-out;
+        }
+        button:hover {
+          background: var(--chops-gray-200);
+        }
+        .controls {
+          display: flex;
+          flex-direction: row;
+          align-items: center;
+          justify-content: flex-start;
+          width: 100%;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <div class="controls">
+        <input id="file-uploader" type="file" multiple @change=${this._filesChanged}>
+        <label class="button" for="file-uploader">
+          <i class="material-icons" role="presentation">attach_file</i>Add attachments
+        </label>
+        Drop files here to add them (Max: 10.0 MB per comment)
+      </div>
+      <ul ?hidden=${!this.files || !this.files.length}>
+        ${this.files.map((file, i) => html`
+          <li>
+            ${file.name}
+            <button data-index=${i} @click=${this._removeFile}>
+              <i class="material-icons">clear</i>
+            </button>
+          </li>
+        `)}
+      </ul>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      files: {type: Array},
+      highlighted: {
+        type: Boolean,
+        reflect: true,
+      },
+      expanded: {
+        type: Boolean,
+        reflect: true,
+      },
+      _boundOnDragIntoWindow: {type: Object},
+      _boundOnDragOutOfWindow: {type: Object},
+      _boundOnDragInto: {type: Object},
+      _boundOnDragLeave: {type: Object},
+      _boundOnDrop: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.expanded = false;
+    this.highlighted = false;
+    this.files = [];
+    this._boundOnDragIntoWindow = this._onDragIntoWindow.bind(this);
+    this._boundOnDragOutOfWindow = this._onDragOutOfWindow.bind(this);
+    this._boundOnDragInto = this._onDragInto.bind(this);
+    this._boundOnDragLeave = this._onDragLeave.bind(this);
+    this._boundOnDrop = this._onDrop.bind(this);
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this.addEventListener('dragenter', this._boundOnDragInto);
+    this.addEventListener('dragover', this._boundOnDragInto);
+
+    this.addEventListener('dragleave', this._boundOnDragLeave);
+    this.addEventListener('drop', this._boundOnDrop);
+
+    window.addEventListener('dragenter', this._boundOnDragIntoWindow);
+    window.addEventListener('dragover', this._boundOnDragIntoWindow);
+    window.addEventListener('dragleave', this._boundOnDragOutOfWindow);
+    window.addEventListener('drop', this._boundOnDragOutOfWindow);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener('dragenter', this._boundOnDragIntoWindow);
+    window.removeEventListener('dragover', this._boundOnDragIntoWindow);
+    window.removeEventListener('dragleave', this._boundOnDragOutOfWindow);
+    window.removeEventListener('drop', this._boundOnDragOutOfWindow);
+  }
+
+  reset() {
+    this.files = [];
+  }
+
+  get hasAttachments() {
+    return this.files.length !== 0;
+  }
+
+  async loadFiles() {
+    // TODO(zhangtiff): Add preloading of files on change.
+    if (!this.files || !this.files.length) return [];
+    const loads = this.files.map(this._loadLocalFile);
+    return await Promise.all(loads);
+  }
+
+  _onDragInto(e) {
+    // Combined event handler for dragenter and dragover.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.highlighted = true;
+  }
+
+  _onDragLeave(e) {
+    // Unhighlight the drop area when the user undrops the component.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.highlighted = false;
+  }
+
+  _onDrop(e) {
+    // Add the files the user is dragging when dragging into the component.
+    const files = this._eventGetFiles(e);
+    if (!files.length) return;
+    e.preventDefault();
+    this.highlighted = false;
+    this._addFiles(files);
+  }
+
+  _onDragIntoWindow(e) {
+    // Expand the drop area when any file is being dragged in the window.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.expanded = true;
+  }
+
+  _onDragOutOfWindow(e) {
+    // Unexpand the component when a file is no longer being dragged.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.expanded = false;
+  }
+
+  _eventGetFiles(e) {
+    if (!e || !e.dataTransfer) return [];
+    const dt = e.dataTransfer;
+
+    if (dt.items && dt.items.length) {
+      const filteredItems = [...dt.items].filter(
+          (item) => item.kind === 'file');
+      return filteredItems.map((item) => item.getAsFile());
+    }
+
+    return [...dt.files];
+  }
+
+  _loadLocalFile(f) {
+    // The FileReader API only accepts callbacks for asynchronous handling,
+    // so it's easier to use Promises here. But by wrapping this logic
+    // in a Promise, we can use async/await in outer code.
+    return new Promise((resolve, reject) => {
+      const r = new FileReader();
+      r.onloadend = () => {
+        resolve({filename: f.name, content: btoa(r.result)});
+      };
+      r.onerror = () => {
+        reject(r.error);
+      };
+
+      r.readAsBinaryString(f);
+    });
+  }
+
+  /**
+   * @param {Event} e
+   * @fires CustomEvent#change
+   * @private
+   */
+  _filesChanged(e) {
+    const input = e.currentTarget;
+    if (!input.files) return;
+    this._addFiles(input.files);
+    this.dispatchEvent(new CustomEvent('change'));
+  }
+
+  _addFiles(newFiles) {
+    if (!newFiles) return;
+    // Spread files to convert it from a FileList to an Array.
+    const files = [...newFiles].filter((f1) => {
+      const matchingFile = this.files.some((f2) => this._filesMatch(f1, f2));
+      return !matchingFile;
+    });
+
+    this.files = this.files.concat(files);
+  }
+
+  _filesMatch(a, b) {
+    // NOTE: This function could return a false positive if two files have the
+    // exact same name, lastModified time, size, and type but different
+    // content. This is extremely unlikely, however.
+    return a.name === b.name && a.lastModified === b.lastModified &&
+      a.size === b.size && a.type === b.type;
+  }
+
+  _removeFile(e) {
+    const target = e.currentTarget;
+
+    // This should always be an int.
+    const index = Number.parseInt(target.dataset.index);
+    if (index < 0 || index >= this.files.length) return;
+
+    this.files.splice(index, 1);
+
+    // Trigger an update.
+    this.files = [...this.files];
+  }
+}
+customElements.define('mr-upload', MrUpload);
diff --git a/static_src/elements/framework/mr-upload/mr-upload.test.js b/static_src/elements/framework/mr-upload/mr-upload.test.js
new file mode 100644
index 0000000..0a0b1e8
--- /dev/null
+++ b/static_src/elements/framework/mr-upload/mr-upload.test.js
@@ -0,0 +1,218 @@
+// 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 {MrUpload} from './mr-upload.js';
+
+let element;
+let preventDefault;
+let mockEvent;
+
+
+describe('mr-upload', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-upload');
+    document.body.appendChild(element);
+
+    preventDefault = sinon.stub();
+
+    mockEvent = (properties) => {
+      return Object.assign({
+        preventDefault: preventDefault,
+      }, properties);
+    };
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrUpload);
+  });
+
+  it('reset clears files', () => {
+    element.files = [new File([''], 'filename.txt'), new File([''], 'hello')];
+
+    element.reset();
+
+    assert.deepEqual(element.files, []);
+  });
+
+  it('editing file selector adds files', () => {
+    const files = [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ];
+    assert.deepEqual(element.files, []);
+
+    // NOTE: There is currently no way to use JavaScript to set the value of
+    // an HTML file input.
+
+    element._filesChanged({
+      currentTarget: {
+        files: files,
+      },
+    });
+
+    assert.deepEqual(element.files, files);
+  });
+
+  it('files are rendered', async () => {
+    element.files = [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+      new File([''], 'file.png'),
+    ];
+
+    await element.updateComplete;
+
+    const items = element.shadowRoot.querySelectorAll('li');
+
+    assert.equal(items.length, 3);
+
+    assert.include(items[0].textContent, 'filename.txt');
+    assert.include(items[1].textContent, 'hello');
+    assert.include(items[2].textContent, 'file.png');
+  });
+
+  it('clicking removes file', async () => {
+    element.files = [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+      new File([''], 'file.png'),
+    ];
+
+    await element.updateComplete;
+
+    let items = element.shadowRoot.querySelectorAll('li');
+
+    assert.equal(items.length, 3);
+
+    items[1].querySelector('button').click();
+
+    await element.updateComplete;
+
+    items = element.shadowRoot.querySelectorAll('li');
+
+    assert.equal(items.length, 2);
+
+    assert.include(items[0].textContent, 'filename.txt');
+    assert.include(items[1].textContent, 'file.png');
+
+    // Make sure clicking works even for children targets.
+    items[0].querySelector('i.material-icons').click();
+
+    await element.updateComplete;
+
+    items = element.shadowRoot.querySelectorAll('li');
+
+    assert.equal(items.length, 1);
+
+    assert.include(items[0].textContent, 'file.png');
+  });
+
+  it('duplicate files are ignored', () => {
+    const file1 = new File([''], 'filename.txt');
+    const file2 = new File([''], 'woahhh');
+    const file3 = new File([''], 'filename');
+
+    element.files = [file1, file2];
+
+    element._addFiles([file2, file3]);
+
+    assert.deepEqual(element.files, [file1, file2, file3]);
+  });
+
+  it('dragging file into window expands element', () => {
+    assert.isFalse(element.expanded);
+    assert.deepEqual(element.files, []);
+
+    element._onDragIntoWindow(mockEvent({dataTransfer: {files: [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ]}}));
+
+    assert.isTrue(element.expanded);
+    assert.deepEqual(element.files, []);
+    assert.isTrue(preventDefault.calledOnce);
+
+    element._onDragOutOfWindow(mockEvent({dataTransfer: {files: [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ]}}));
+
+    assert.isFalse(element.expanded);
+    assert.deepEqual(element.files, []);
+    assert.isTrue(preventDefault.calledTwice);
+  });
+
+  it('dragging non-file into window does not expands element', () => {
+    assert.isFalse(element.expanded);
+
+    element._onDragIntoWindow(mockEvent(
+        {dataTransfer: {files: [], items: [{kind: 'notFile'}]}},
+    ));
+
+    assert.isFalse(element.expanded);
+    assert.isFalse(preventDefault.called);
+
+    element._onDragOutOfWindow(mockEvent(
+        {dataTransfer: {files: [], items: [{kind: 'notFile'}]}},
+    ));
+
+    assert.isFalse(element.expanded);
+    assert.isFalse(preventDefault.called);
+  });
+
+  it('dragging file over element highlights it', () => {
+    assert.isFalse(element.highlighted);
+    assert.deepEqual(element.files, []);
+
+    element._onDragInto(mockEvent({dataTransfer: {files: [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ]}}));
+
+    assert.isTrue(element.highlighted);
+    assert.deepEqual(element.files, []);
+    assert.isTrue(preventDefault.calledOnce);
+
+    element._onDragLeave(mockEvent({dataTransfer: {files: [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ]}}));
+
+    assert.isFalse(element.highlighted);
+    assert.deepEqual(element.files, []);
+    assert.isTrue(preventDefault.calledTwice);
+  });
+
+  it('dropping file over element selects it', () => {
+    const files = [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ];
+    assert.deepEqual(element.files, []);
+
+    element._onDrop(mockEvent({dataTransfer: {files: files}}));
+
+    assert.isTrue(preventDefault.calledOnce);
+    assert.deepEqual(element.files, files);
+  });
+
+  it('loadFiles loads files', async () => {
+    element.files = [
+      new File(['some content'], 'filename.txt'),
+      new File([''], 'hello'),
+    ];
+
+    const uploads = await element.loadFiles();
+
+    assert.deepEqual(uploads, [
+      {content: 'c29tZSBjb250ZW50', filename: 'filename.txt'},
+      {content: '', filename: 'hello'},
+    ]);
+  });
+});
diff --git a/static_src/elements/framework/mr-warning/mr-warning.js b/static_src/elements/framework/mr-warning/mr-warning.js
new file mode 100644
index 0000000..51de376
--- /dev/null
+++ b/static_src/elements/framework/mr-warning/mr-warning.js
@@ -0,0 +1,51 @@
+// 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';
+
+
+/**
+ * `<mr-warning>`
+ *
+ * A container for showing warnings.
+ *
+ */
+export class MrWarning extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: flex;
+        align-items: center;
+        flex-direction: row;
+        justify-content: flex-start;
+        box-sizing: border-box;
+        width: 100%;
+        margin: 0.5em 0;
+        padding: 0.25em 8px;
+        border: 1px solid #FF6F00;
+        border-radius: 4px;
+        background: #FFF8E1;
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      i.material-icons {
+        color: #FF6F00;
+        margin-right: 4px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <i class="material-icons">warning</i>
+      <slot></slot>
+    `;
+  }
+}
+
+customElements.define('mr-warning', MrWarning);
diff --git a/static_src/elements/help/mr-click-throughs/mr-click-throughs.js b/static_src/elements/help/mr-click-throughs/mr-click-throughs.js
new file mode 100644
index 0000000..8b142f0
--- /dev/null
+++ b/static_src/elements/help/mr-click-throughs/mr-click-throughs.js
@@ -0,0 +1,185 @@
+// 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 {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-click-throughs>`
+ *
+ * An element that displays help dialogs that the user is required
+ * to click through before they can participate in the community.
+ *
+ */
+export class MrClickThroughs extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: {type: String},
+      prefs: {type: Object},
+      prefsLoaded: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [SHARED_STYLES, css`
+      :host {
+        --chops-dialog-max-width: 800px;
+      }
+      h2 {
+        margin-top: 0;
+        display: flex;
+        justify-content: space-between;
+        font-weight: normal;
+        border-bottom: 2px solid white;
+        font-size: var(--chops-large-font-size);
+        padding-bottom: 0.5em;
+      }
+      .edit-actions {
+        width: 100%;
+        margin: 0.5em 0;
+        text-align: right;
+      }
+    `];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <chops-dialog
+        id="privacyDialog"
+        ?opened=${this._showPrivacyDialog}
+        forced
+      >
+        <h2>Email display settings</h2>
+
+        <p>There is a <a href="/hosting/settings">setting</a> to control how
+        your email address appears on comments and issues that you post.</p>
+
+        <p>Project members will always see your full email address.  By
+        default, other users who visit the site will see an
+        abbreviated version of your email address.</p>
+
+        <p>If you do not wish your email address to be shared, there
+        are other ways to <a
+        href="http://www.chromium.org/getting-involved">get
+        involved</a> in the community.  To report a problem when using
+        the Chrome browser, you may use the "Report an issue..."  item
+        on the "Help" menu.</p>
+
+
+        <div class="edit-actions">
+          <chops-button @click=${this.dismissPrivacyDialog}>
+            Got it
+          </chops-button>
+        </div>
+      </chops-dialog>
+
+      <chops-dialog
+        id="corpModeDialog"
+        ?opened=${this._showCorpModeDialog}
+        forced
+      >
+        <h2>This site hosts public issues in public projects</h2>
+
+        <p>Unlike our internal issue tracker, this site makes most
+        issues public, unless the issue is labeled with a Restrict-View-*
+        label, such as Restrict-View-Google.</p>
+
+        <p>Components are not used for permissions.  And, regardless of
+        restriction labels, the issue reporter, owner,
+        and Cc&apos;d users may always view the issue.</p>
+
+        ${this.prefs.get('restrict_new_issues') ? html`
+          <p>Your account is a member of a user group that indicates that
+          you may have access to confidential information.  To help prevent
+          leaks when working in public projects, the issue tracker UX has
+          been altered for you:</p>
+
+          <ul>
+            <li>When you open a new issue, the form will initially have a
+            Restrict-View-Google label.  If you know that your issue does
+            not contain confidential information, please remove the label.</li>
+            <li>When you view public issues, a red banner is shown to remind
+            you that any comments or attachments you post will be public.</li>
+          </ul>
+        ` : ''}
+
+        <div class="edit-actions">
+          <chops-button @click=${this.dismissCorpModeDialog}>
+            Got it
+          </chops-button>
+        </div>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.prefs = userV0.prefs(state);
+    this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
+  }
+
+  /**
+   * Checks whether the user should see a dialogue telling them about
+   * Monorail's privacy settings.
+   */
+  get _showPrivacyDialog() {
+    if (!this.userDisplayName) return false;
+    if (!this.prefsLoaded) return false;
+    if (!this.prefs) return false;
+    if (this.prefs.get('privacy_click_through')) return false;
+    return true;
+  }
+
+  /**
+   * Computes whether the user should see the dialog telling them about corp mode.
+   */
+  get _showCorpModeDialog() {
+    // TODO(jrobbins): Replace this with a API call that gets the project.
+    if (window.CS_env.projectIsRestricted) return false;
+    if (!this.userDisplayName) return false;
+    if (!this.prefsLoaded) return false;
+    if (!this.prefs) return false;
+    if (!this.prefs.get('public_issue_notice')) return false;
+    if (this.prefs.get('corp_mode_click_through')) return false;
+    return true;
+  }
+
+  /**
+   * Event handler for dismissing Monorail's privacy notice.
+   */
+  dismissPrivacyDialog() {
+    this.dismissCue('privacy_click_through');
+  }
+
+  /**
+   * Event handler for dismissing corp mode.
+   */
+  dismissCorpModeDialog() {
+    this.dismissCue('corp_mode_click_through');
+  }
+
+  /**
+   * Dispatches a Redux action to tell Monorail's backend that the user
+   * clicked through a particular cue.
+   * @param {string} pref The pref to set to true.
+   */
+  dismissCue(pref) {
+    const newPrefs = [{name: pref, value: 'true'}];
+    store.dispatch(userV0.setPrefs(newPrefs, !!this.userDisplayName));
+  }
+}
+
+customElements.define('mr-click-throughs', MrClickThroughs);
diff --git a/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js b/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js
new file mode 100644
index 0000000..e735380
--- /dev/null
+++ b/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js
@@ -0,0 +1,120 @@
+// 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 {MrClickThroughs} from './mr-click-throughs.js';
+import page from 'page';
+
+let element;
+
+describe('mr-click-throughs', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-click-throughs');
+    document.body.appendChild(element);
+
+    sinon.stub(page, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    page.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrClickThroughs);
+  });
+
+  it('stateChanged', () => {
+    const state = {userV0: {currentUser:
+      {prefs: new Map(), prefsLoaded: false}}};
+    element.stateChanged(state);
+    assert.deepEqual(element.prefs, new Map([['render_markdown', false]]));
+    assert.isFalse(element.prefsLoaded);
+  });
+
+  it('anon does not see privacy dialog', () => {
+    assert.isFalse(element._showPrivacyDialog);
+  });
+
+  it('signed in user sees no privacy dialog before prefs load', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = false;
+    assert.isFalse(element._showPrivacyDialog);
+  });
+
+  it('signed in user sees no privacy dialog if dismissal pref set', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['privacy_click_through', true]]);
+    assert.isFalse(element._showPrivacyDialog);
+  });
+
+  it('signed in user sees privacy dialog if dismissal pref missing', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map();
+    assert.isTrue(element._showPrivacyDialog);
+  });
+
+  it('anon does not see corp mode dialog', () => {
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('signed in user sees no corp mode dialog before prefs load', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = false;
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('signed in user sees no corp mode dialog if dismissal pref set', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['corp_mode_click_through', true]]);
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('non-corp user sees no corp mode dialog', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map();
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('corp user sees corp mode dialog if dismissal pref missing', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['public_issue_notice', true]]);
+    assert.isTrue(element._showCorpModeDialog);
+  });
+
+  it('corp user sees no corp mode dialog in members-only project', () => {
+    window.CS_env = {projectIsRestricted: true};
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['public_issue_notice', true]]);
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('corp user sees corp mode dialog with no RVG warning', async () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['public_issue_notice', true]]);
+
+    await element.updateComplete;
+    assert.notInclude(element.shadowRoot.innerHTML, 'altered');
+  });
+
+  it('corp user sees corp mode dialog with RVG warning', async () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([
+      ['public_issue_notice', true],
+      ['restrict_new_issues', true],
+    ]);
+
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'altered');
+  });
+});
diff --git a/static_src/elements/help/mr-cue/cue-helpers.js b/static_src/elements/help/mr-cue/cue-helpers.js
new file mode 100644
index 0000000..4aa30d7
--- /dev/null
+++ b/static_src/elements/help/mr-cue/cue-helpers.js
@@ -0,0 +1,49 @@
+// 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.
+
+/**
+ * @fileoverview Shared helpers for dealing with how <mr-cue> element instances
+ * are used.
+ */
+
+export const cueNames = Object.freeze({
+  CODE_OF_CONDUCT: 'code_of_conduct',
+  AVAILABILITY_MSGS: 'availability_msgs',
+  SWITCH_TO_PARENT_ACCOUNT: 'switch_to_parent_account',
+  SEARCH_FOR_NUMBERS: 'search_for_numbers',
+});
+
+export const AVAILABLE_CUES = Object.freeze(new Set(Object.values(cueNames)));
+
+export const CUE_DISPLAY_PREFIX = 'cue.';
+
+/**
+ * Converts a cue name to the format expected by components like <mr-metadata>
+ * for the purpose of ordering fields.
+ *
+ * @param {string} cueName The name of the cue.
+ * @return {string} A "cue.cue_name" formatted String used in ordering cues
+ *   alongside field types (ie: Owner) in various field specs.
+ */
+export const cueNameToSpec = (cueName) => {
+  return CUE_DISPLAY_PREFIX + cueName;
+};
+
+/**
+ * Converts an issue field specifier to the name of the cue it references if
+ * it references a cue. ie: "cue.cue_name" would reference "cue_name".
+ *
+ * @param {string} spec A "cue.cue_name" format String specifying that a
+ *   specific cue should be mixed alongside issue fields in a component like
+ *   <mr-metadata>.
+ * @return {string} Name of the cue customized in the spec or an empty
+ *   String if the spec does not reference a cue.
+ */
+export const specToCueName = (spec) => {
+  spec = spec.toLowerCase();
+  if (spec.startsWith(CUE_DISPLAY_PREFIX)) {
+    return spec.substring(CUE_DISPLAY_PREFIX.length);
+  }
+  return '';
+};
diff --git a/static_src/elements/help/mr-cue/cue-helpers.test.js b/static_src/elements/help/mr-cue/cue-helpers.test.js
new file mode 100644
index 0000000..3bc084a
--- /dev/null
+++ b/static_src/elements/help/mr-cue/cue-helpers.test.js
@@ -0,0 +1,30 @@
+// 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 {cueNameToSpec, specToCueName} from './cue-helpers.js';
+
+
+describe('cue-helpers', () => {
+  describe('cueNameToSpec', () => {
+    it('appends cue prefix', () => {
+      assert.equal(cueNameToSpec('test'), 'cue.test');
+    });
+  });
+
+  describe('specToCueName', () => {
+    it('extracts cue name from matching spec', () => {
+      assert.equal(specToCueName('cue.test'), 'test');
+      assert.equal(specToCueName('cue.hello-world'), 'hello-world');
+      assert.equal(specToCueName('cue.under_score'), 'under_score');
+    });
+
+    it('does not extract cue name from non-matching spec', () => {
+      assert.equal(specToCueName('.cue.test'), '');
+      assert.equal(specToCueName('hello-world-cue.'), '');
+      assert.equal(specToCueName('cu.under_score'), '');
+      assert.equal(specToCueName('field'), '');
+    });
+  });
+});
diff --git a/static_src/elements/help/mr-cue/mr-cue.js b/static_src/elements/help/mr-cue/mr-cue.js
new file mode 100644
index 0000000..22b1290
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-cue.js
@@ -0,0 +1,282 @@
+// 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 {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {cueNames} from './cue-helpers.js';
+
+
+/**
+ * `<mr-cue>`
+ *
+ * An element that displays one of a set of predefined help messages
+ * iff that message is appropriate to the current user and page.
+ *
+ * TODO: Factor this class out into a base view component and separate
+ * usage-specific components, such as those for user prefs.
+ *
+ */
+export class MrCue extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+    this.prefs = new Map();
+    this.issue = null;
+    this.referencedUsers = new Map();
+    this.nondismissible = false;
+    this.cuePrefName = '';
+    this.loginUrl = '';
+    this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
+        this.cuePrefName, this.message);
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+      referencedUsers: {type: Object},
+      user: {type: Object},
+      cuePrefName: {type: String},
+      nondismissible: {type: Boolean},
+      prefs: {type: Object},
+      prefsLoaded: {type: Boolean},
+      jumpLocalId: {type: Number},
+      loginUrl: {type: String},
+      hidden: {
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [SHARED_STYLES, css`
+      :host {
+        display: block;
+        margin: 2px 0;
+        padding: 2px 4px 2px 8px;
+        background: var(--chops-notice-bubble-bg);
+        border: var(--chops-notice-border);
+        text-align: center;
+      }
+      :host([centered]) {
+        display: flex;
+        justify-content: center;
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      button[hidden] {
+        visibility: hidden;
+      }
+      i.material-icons {
+        font-size: 14px;
+      }
+      button {
+        background: none;
+        border: none;
+        float: right;
+        padding: 2px;
+        cursor: pointer;
+        border-radius: 50%;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+      }
+      button:hover {
+        background: rgba(0, 0, 0, .2);
+      }
+    `];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <button
+        @click=${this.dismiss}
+        title="Don't show this message again."
+        ?hidden=${this.nondismissible}>
+        <i class="material-icons">close</i>
+      </button>
+      <div id="message">${this.message}</div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult} lit-html template for the cue message a user
+   * should see.
+   */
+  get message() {
+    if (this.cuePrefName === cueNames.CODE_OF_CONDUCT) {
+      return html`
+        Please keep discussions respectful and constructive.
+        See our
+        <a href="${this.codeOfConductUrl}"
+           target="_blank">code of conduct</a>.
+        `;
+    } else if (this.cuePrefName === cueNames.AVAILABILITY_MSGS) {
+      if (this._availablityMsgsRelevant(this.issue)) {
+        return html`
+          <b>Note:</b>
+          Clock icons indicate that users may not be available.
+          Tooltips show the reason.
+          `;
+      }
+    } else if (this.cuePrefName === cueNames.SWITCH_TO_PARENT_ACCOUNT) {
+      if (this._switchToParentAccountRelevant()) {
+        return html`
+          You are signed in to a linked account.
+          <a href="${this.loginUrl}">
+             Switch to ${this.user.linkedParentRef.displayName}</a>.
+          `;
+      }
+    } else if (this.cuePrefName === cueNames.SEARCH_FOR_NUMBERS) {
+      if (this._searchForNumbersRelevant(this.jumpLocalId)) {
+        return html`
+          <b>Tip:</b>
+          To find issues containing "${this.jumpLocalId}", use quotes.
+          `;
+      }
+    }
+    return;
+  }
+
+  /**
+  * Conditionally returns a hardcoded code of conduct URL for
+  * different projects.
+  * @return {string} the URL for the code of conduct.
+   */
+  get codeOfConductUrl() {
+    // TODO(jrobbins): Store this in the DB and pass it via the API.
+    if (this.projectName === 'fuchsia') {
+      return 'https://fuchsia.dev/fuchsia-src/CODE_OF_CONDUCT';
+    }
+    return ('https://chromium.googlesource.com/' +
+            'chromium/src/+/main/CODE_OF_CONDUCT.md');
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    const hiddenWatchProps = ['prefsLoaded', 'cuePrefName', 'signedIn',
+      'prefs'];
+    const shouldUpdateHidden = Array.from(changedProperties.keys())
+        .some((propName) => hiddenWatchProps.includes(propName));
+    if (shouldUpdateHidden) {
+      this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
+          this.cuePrefName, this.message);
+    }
+  }
+
+  /**
+   * Checks if there are any unavailable users and only displays this cue if so.
+   * @param {Issue} issue
+   * @return {boolean} Whether the User Availability cue should be
+   *   displayed or not.
+   */
+  _availablityMsgsRelevant(issue) {
+    if (!issue) return false;
+    return (this._anyUnvailable([issue.ownerRef]) ||
+            this._anyUnvailable(issue.ccRefs));
+  }
+
+  /**
+   * Checks if a given list of users contains any unavailable users.
+   * @param {Array<UserRef>} userRefList
+   * @return {boolean} Whether there are unavailable users.
+   */
+  _anyUnvailable(userRefList) {
+    if (!userRefList) return false;
+    for (const userRef of userRefList) {
+      if (userRef) {
+        const participant = this.referencedUsers.get(userRef.displayName);
+        if (participant && participant.availability) return true;
+      }
+    }
+  }
+
+  /**
+   * Finds if the user has a linked parent account that's separate from the
+   * one they are logged into and conditionally hides the cue if so.
+   * @return {boolean} Whether to show the cue to switch to a parent account.
+   */
+  _switchToParentAccountRelevant() {
+    return this.user && this.user.linkedParentRef;
+  }
+
+  /**
+   * Determines whether the user should see a cue telling them how to avoid the
+   * "jump to issue" feature.
+   * @param {number} jumpLocalId the ID of the issue the user jumped to.
+   * @return {boolean} Whether the user jumped to a number or not.
+   */
+  _searchForNumbersRelevant(jumpLocalId) {
+    return !!jumpLocalId;
+  }
+
+  /**
+   * Checks the user's preferences to hide a particular cue if they have
+   * dismissed it.
+   * @param {boolean} signedIn Whether the user is signed in.
+   * @param {boolean} prefsLoaded Whether the user's prefs have been fetched
+   *   from the API.
+   * @param {string} cuePrefName The name of the cue being checked.
+   * @param {string} message
+   * @return {boolean} Whether the cue should be hidden.
+   */
+  _shouldBeHidden(signedIn, prefsLoaded, cuePrefName, message) {
+    if (signedIn && !prefsLoaded) return true;
+    if (this.alreadyDismissed(cuePrefName)) return true;
+    return !message;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.projectName = projectV0.viewedProjectName(state);
+    this.issue = issueV0.viewedIssue(state);
+    this.referencedUsers = issueV0.referencedUsers(state);
+    this.user = userV0.currentUser(state);
+    this.prefs = userV0.prefs(state);
+    this.signedIn = this.user && this.user.userId;
+    this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
+
+    const queryString = window.location.search.substring(1);
+    const queryParams = qs.parse(queryString);
+    const q = queryParams.q;
+    if (q && q.match(new RegExp('^\\d+$'))) {
+      this.jumpLocalId = Number(q);
+    }
+  }
+
+  /**
+   * Check whether a cue has already been dismissed in a user's
+   * preferences.
+   * @param {string} pref The name of the user preference to check.
+   * @return {boolean} Whether the cue was dismissed or not.
+   */
+  alreadyDismissed(pref) {
+    return this.prefs && this.prefs.get(pref);
+  }
+
+  /**
+   * Sends a request to the API to save that a user has dismissed a cue.
+   * The results of this request update Redux's state, which leads to
+   * the cue disappearing for the user after the request finishes.
+   * @return {void}
+   */
+  dismiss() {
+    const newPrefs = [{name: this.cuePrefName, value: 'true'}];
+    store.dispatch(userV0.setPrefs(newPrefs, this.signedIn));
+  }
+}
+
+customElements.define('mr-cue', MrCue);
diff --git a/static_src/elements/help/mr-cue/mr-cue.test.js b/static_src/elements/help/mr-cue/mr-cue.test.js
new file mode 100644
index 0000000..2722076
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-cue.test.js
@@ -0,0 +1,177 @@
+// 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 {MrCue} from './mr-cue.js';
+import page from 'page';
+import {rootReducer} from 'reducers/base.js';
+
+let element;
+
+describe('mr-cue', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-cue');
+    document.body.appendChild(element);
+
+    sinon.stub(page, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    page.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCue);
+  });
+
+  it('stateChanged', () => {
+    const state = rootReducer({
+      userV0: {currentUser: {prefs: new Map(), prefsLoaded: false}},
+    }, {});
+    element.stateChanged(state);
+    assert.deepEqual(element.prefs, new Map([['render_markdown', false]]));
+    assert.isFalse(element.prefsLoaded);
+  });
+
+  it('cues are hidden before prefs load', () => {
+    element.prefsLoaded = false;
+    assert.isTrue(element.hidden);
+  });
+
+  it('cue is hidden if user already dismissed it', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'code_of_conduct';
+    element.prefs = new Map([['code_of_conduct', true]]);
+    assert.isTrue(element.hidden);
+  });
+
+  it('cue is hidden if no relevent message', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'this_has_no_message';
+    assert.isTrue(element.hidden);
+  });
+
+  it('cue is shown if relevant message has not been dismissed', async () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'code_of_conduct';
+
+    await element.updateComplete;
+
+    assert.isFalse(element.hidden);
+    const messageEl = element.shadowRoot.querySelector('#message');
+    assert.include(messageEl.innerHTML, 'chromium.googlesource.com');
+  });
+
+  it('code of conduct is specific to the project', async () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'code_of_conduct';
+    element.projectName = 'fuchsia';
+
+    await element.updateComplete;
+
+    assert.isFalse(element.hidden);
+    const messageEl = element.shadowRoot.querySelector('#message');
+    assert.include(messageEl.innerHTML, 'fuchsia.dev');
+  });
+
+  it('availability cue is hidden if no relevent issue particpants', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'availability_msgs';
+    element.issue = {summary: 'no owners or cc'};
+    assert.isTrue(element.hidden);
+
+    element.issue = {
+      summary: 'owner and ccs have no availability msg',
+      ownerRef: {},
+      ccRefs: [{}, {}],
+    };
+    assert.isTrue(element.hidden);
+  });
+
+  it('availability cue is shown if issue particpants are unavailable',
+      async () => {
+        element.prefsLoaded = true;
+        element.cuePrefName = 'availability_msgs';
+        element.referencedUsers = new Map([
+          ['user@example.com', {availability: 'Never visited'}],
+        ]);
+
+        element.issue = {
+          summary: 'owner is unavailable',
+          ownerRef: {displayName: 'user@example.com'},
+          ccRefs: [{}, {}],
+        };
+        await element.updateComplete;
+
+        assert.isFalse(element.hidden);
+        const messageEl = element.shadowRoot.querySelector('#message');
+        assert.include(messageEl.innerText, 'Clock icons');
+
+        element.issue = {
+          summary: 'owner is unavailable',
+          ownerRef: {},
+          ccRefs: [
+            {displayName: 'ok@example.com'},
+            {displayName: 'user@example.com'}],
+        };
+        await element.updateComplete;
+        assert.isFalse(element.hidden);
+        assert.include(messageEl.innerText, 'Clock icons');
+      });
+
+  it('switch_to_parent_account cue is hidden if no linked account', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'switch_to_parent_account';
+
+    element.user = undefined;
+    assert.isTrue(element.hidden);
+
+    element.user = {groups: []};
+    assert.isTrue(element.hidden);
+  });
+
+  it('switch_to_parent_account is shown if user has parent account',
+      async () => {
+        element.prefsLoaded = true;
+        element.cuePrefName = 'switch_to_parent_account';
+        element.user = {linkedParentRef: {displayName: 'parent@example.com'}};
+
+        await element.updateComplete;
+        assert.isFalse(element.hidden);
+        const messageEl = element.shadowRoot.querySelector('#message');
+        assert.include(messageEl.innerText, 'a linked account');
+      });
+
+  it('search_for_numbers cue is hidden if no number was used', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'search_for_numbers';
+    element.issue = {};
+    element.jumpLocalId = null;
+    assert.isTrue(element.hidden);
+  });
+
+  it('search_for_numbers cue is shown if jumped to issue ID',
+      async () => {
+        element.prefsLoaded = true;
+        element.cuePrefName = 'search_for_numbers';
+        element.issue = {};
+        element.jumpLocalId = '123'.match(new RegExp('^\\d+$'));
+
+        await element.updateComplete;
+        assert.isFalse(element.hidden);
+        const messageEl = element.shadowRoot.querySelector('#message');
+        assert.include(messageEl.innerText, 'use quotes');
+      });
+
+  it('cue is dismissible unless there is attribute nondismissible',
+      async () => {
+        assert.isFalse(element.nondismissible);
+
+        element.setAttribute('nondismissible', '');
+        await element.updateComplete;
+        assert.isTrue(element.nondismissible);
+      });
+});
diff --git a/static_src/elements/help/mr-cue/mr-fed-ref-cue.js b/static_src/elements/help/mr-cue/mr-fed-ref-cue.js
new file mode 100644
index 0000000..8e8626f
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-fed-ref-cue.js
@@ -0,0 +1,83 @@
+// 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 {html, css} from 'lit-element';
+import * as userV0 from 'reducers/userV0.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {store} from 'reducers/base.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {fromShortlink, GoogleIssueTrackerIssue} from 'shared/federated.js';
+import {MrCue} from './mr-cue.js';
+
+/**
+ * `<mr-fed-ref-cue>`
+ *
+ * Displays information and login/logout links for the federated references
+ * info popup.
+ *
+ */
+export class MrFedRefCue extends MrCue {
+  /** @override */
+  static get properties() {
+    return {
+      ...MrCue.properties,
+      fedRefShortlink: {type: String},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      ...MrCue.styles,
+      css`
+        :host {
+          margin: 0;
+          width: 120px;
+          font-size: 11px;
+        }
+      `,
+    ];
+  }
+
+  get message() {
+    const fedRef = fromShortlink(this.fedRefShortlink);
+    if (fedRef && fedRef instanceof GoogleIssueTrackerIssue) {
+      let authLink;
+      if (this.user && this.user.gapiEmail) {
+        authLink = html`
+          <br /><br />
+          <a href="#"
+            @click=${() => store.dispatch(userV0.initGapiLogout())}
+          >Sign out</a>
+          <br />
+          (for references only)
+        `;
+      } else {
+        const clickLoginHandler = async () => {
+          await store.dispatch(userV0.initGapiLogin(this.issue));
+          // Re-fetch related issues.
+          store.dispatch(issueV0.fetchRelatedIssues(this.issue));
+        };
+        authLink = html`
+          <br /><br />
+          Googlers, to enable viewing status & title,
+          <a href="#"
+            @click=${clickLoginHandler}
+            >sign in here</a> with your Google email.
+        `;
+      }
+      return html`
+        This references an issue in the ${fedRef.trackerName} issue tracker.
+        ${authLink}
+      `;
+    } else {
+      return html`
+        This references an issue in another tracker. Status not displayed.
+      `;
+    }
+  }
+}
+
+customElements.define('mr-fed-ref-cue', MrFedRefCue);
diff --git a/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js
new file mode 100644
index 0000000..b7087a9
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js
@@ -0,0 +1,72 @@
+// Copyright 2020 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 'elements/framework/mr-tabs/mr-tabs.js';
+
+/** @type {readonly MenuItem[]} */
+const _MENU_ITEMS = Object.freeze([
+  {
+    icon: 'list',
+    text: 'Issues',
+    url: 'issues',
+  },
+  {
+    icon: 'people',
+    text: 'People',
+    url: 'people',
+  },
+  {
+    icon: 'settings',
+    text: 'Settings',
+    url: 'settings',
+  },
+]);
+
+// TODO(dtu): Put this inside <mr-header>. Currently, we can't do this because
+// the sticky table headers rely on having a fixed header height. We need to
+// add a scrolling context to the page in order to have a dynamic-height
+// sticky, and to do that the footer needs to be in the scrolling context. So,
+// the footer needs to be SPA-ified.
+/** Hotlist Issues page */
+export class MrHotlistHeader extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      h1 {
+        font-size: 20px;
+        font-weight: normal;
+        margin: 16px 24px;
+      }
+      nav {
+        border-bottom: solid #ddd 1px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <nav>
+        <mr-tabs .items=${_MENU_ITEMS} .selected=${this.selected}></mr-tabs>
+      </nav>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      selected: {type: Number},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {number} */
+    this.selected = 0;
+  }
+}
+
+customElements.define('mr-hotlist-header', MrHotlistHeader);
diff --git a/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js
new file mode 100644
index 0000000..9321d59
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js
@@ -0,0 +1,32 @@
+// Copyright 2020 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 {MrHotlistHeader} from './mr-hotlist-header.js';
+
+/** @type {MrHotlistHeader} */
+let element;
+
+describe('mr-hotlist-header', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-header');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrHotlistHeader);
+  });
+
+  it('renders', async () => {
+    element.selected = 2;
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelector('mr-tabs').selected, 2);
+  });
+});
diff --git a/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js
new file mode 100644
index 0000000..fa76477
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js
@@ -0,0 +1,361 @@
+// 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 {defaultMemoize} from 'reselect';
+
+import {relativeTime}
+  from 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {issueNameToRef, issueToName, userNameToId}
+  from 'shared/convertersV0.js';
+import {DEFAULT_ISSUE_FIELD_LIST} from 'shared/issue-fields.js';
+
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as ui from 'reducers/ui.js';
+
+import 'elements/chops/chops-filter-chips/chops-filter-chips.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-move-issue-hotlists-dialog.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-issue-list/mr-issue-list.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+const DEFAULT_HOTLIST_FIELDS = Object.freeze([
+  ...DEFAULT_ISSUE_FIELD_LIST,
+  'Added',
+  'Adder',
+  'Rank',
+]);
+
+/** Hotlist Issues page */
+export class _MrHotlistIssuesPage extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      section, p, div {
+        margin: 16px 24px;
+      }
+      div {
+        align-items: center;
+        display: flex;
+      }
+      chops-filter-chips {
+        margin-left: 6px;
+      }
+      mr-button-bar {
+        margin: 16px 24px 8px 24px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-hotlist-header selected=0></mr-hotlist-header>
+      ${this._renderPage()}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderPage() {
+    if (!this._hotlist) {
+      if (this._fetchError) {
+        return html`<section>${this._fetchError.description}</section>`;
+      } else {
+        return html`<section>Loading...</section>`;
+      }
+    }
+
+    // Memoize the issues passed to <mr-issue-list> so that
+    // out property updates don't cause it to re-render.
+    const items = _filterIssues(this._filter, this._items);
+
+    const allProjectNamesEqual = items.length && items.every(
+        (issue) => issue.projectName === items[0].projectName);
+    const projectName = allProjectNamesEqual ? items[0].projectName : null;
+
+    /** @type {HotlistV0} */
+    // Populates <mr-update-issue-hotlists-dialog>' issueHotlists property.
+    const hotlistV0 = {
+      ownerRef: {userId: userNameToId(this._hotlist.owner)},
+      name: this._hotlist.displayName,
+    };
+
+    const mayEdit = this._permissions.includes(hotlists.ADMINISTER) ||
+                    this._permissions.includes(hotlists.EDIT);
+    // TODO(https://crbug.com/monorail/7776): The UI to allow reranking of
+    // Issues should reflect user permissions.
+
+    return html`
+      <p>${this._hotlist.summary}</p>
+
+      <div>
+        Filter by Status
+        <chops-filter-chips
+            .options=${['Open', 'Closed']}
+            .selected=${this._filter}
+            @change=${this._onFilterChange}
+        ></chops-filter-chips>
+      </div>
+
+      <mr-button-bar .items=${this._buttonBarItems()}></mr-button-bar>
+
+      <mr-issue-list
+        .issues=${items}
+        .projectName=${projectName}
+        .columns=${this._columns}
+        .defaultFields=${DEFAULT_HOTLIST_FIELDS}
+        .extractFieldValues=${this._extractFieldValues.bind(this)}
+        .rerank=${mayEdit ? this._rerankItems.bind(this) : null}
+        ?selectionEnabled=${mayEdit}
+        @selectionChange=${this._onSelectionChange}
+      ></mr-issue-list>
+
+      <mr-change-columns .columns=${this._columns}></mr-change-columns>
+      <mr-update-issue-hotlists-dialog
+        .issueRefs=${this._selected.map(issueNameToRef)}
+        .issueHotlists=${[hotlistV0]}
+        @saveSuccess=${this._handleHotlistSaveSuccess}
+      ></mr-update-issue-hotlists-dialog>
+      <mr-move-issue-hotlists-dialog
+        .issueRefs=${this._selected.map(issueNameToRef)}
+        @saveSuccess=${this._handleHotlistSaveSuccess}
+      ><mr-move-issue-hotlists-dialog>
+    `;
+  }
+
+  /**
+   * @return {Array<MenuItem>}
+   */
+  _buttonBarItems() {
+    if (this._selected.length) {
+      return [
+        {
+          icon: 'remove_circle_outline',
+          text: 'Remove',
+          handler: this._removeItems.bind(this)},
+        {
+          icon: 'edit',
+          text: 'Update',
+          handler: this._openUpdateIssuesHotlistsDialog.bind(this),
+        },
+        {
+          icon: 'forward',
+          text: 'Move to...',
+          handler: this._openMoveToHotlistDialog.bind(this),
+        },
+      ];
+    } else {
+      return [
+        // TODO(dtu): Implement this action.
+        // {icon: 'add', text: 'Add issues'},
+        {
+          icon: 'table_chart',
+          text: 'Change columns',
+          handler: this._openColumnsDialog.bind(this),
+        },
+      ];
+    }
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // Populated from Redux.
+      _hotlist: {type: Object},
+      _permissions: {type: Array},
+      _items: {type: Array},
+      _columns: {type: Array},
+      _fetchError: {type: Object},
+      _extractFieldValuesFromIssue: {type: Object},
+
+      // Populated from events.
+      _filter: {type: Object},
+      _selected: {type: Array},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+
+    // Populated from Redux.
+    /** @type {?Hotlist} */
+    this._hotlist = null;
+    /** @type {Array<Permission>} */
+    this._permissions = [];
+    /** @type {Array<HotlistIssue>} */
+    this._items = [];
+    /** @type {Array<string>} */
+    this._columns = [];
+    /** @type {?Error} */
+    this._fetchError = null;
+    /**
+     * @param {Issue} _issue
+     * @param {string} _fieldName
+     * @return {Array<string>}
+     */
+    this._extractFieldValuesFromIssue = (_issue, _fieldName) => [];
+
+    // Populated from events.
+    /** @type {Object<string, boolean>} */
+    this._filter = {Open: true};
+    /**
+     * An array of selected Issue Names.
+     * TODO(https://crbug.com/monorail/7440): Update typedef.
+     * @type {Array<string>}
+     */
+    this._selected = [];
+  }
+
+  /**
+   * @param {HotlistIssue} hotlistIssue
+   * @param {string} fieldName
+   * @return {Array<string>}
+   */
+  _extractFieldValues(hotlistIssue, fieldName) {
+    switch (fieldName) {
+      case 'Added':
+        return [relativeTime(new Date(hotlistIssue.createTime))];
+      case 'Adder':
+        return [hotlistIssue.adder.displayName];
+      case 'Rank':
+        return [String(hotlistIssue.rank + 1)];
+      default:
+        return this._extractFieldValuesFromIssue(hotlistIssue, fieldName);
+    }
+  }
+
+  /**
+   * @param {Event} e A change event fired by <chops-filter-chips>.
+   */
+  _onFilterChange(e) {
+    this._filter = e.target.selected;
+  }
+
+  /**
+   * @param {CustomEvent} e A selectionChange event fired by <mr-issue-list>.
+   */
+  _onSelectionChange(e) {
+    this._selected = e.target.selectedIssues.map(issueToName);
+  }
+
+  /** Opens a dialog to change the columns shown in the issue list. */
+  _openColumnsDialog() {
+    this.shadowRoot.querySelector('mr-change-columns').open();
+  }
+
+  /** Handles successfully saved Hotlist changes. */
+  async _handleHotlistSaveSuccess() {}
+
+  /** Removes items from the hotlist, dispatching an action to Redux. */
+  async _removeItems() {}
+
+  /** Opens a dialog to update attached Hotlists for selected Issues. */
+  _openUpdateIssuesHotlistsDialog() {
+    this.shadowRoot.querySelector('mr-update-issue-hotlists-dialog').open();
+  }
+
+  /** Opens a dialog to move selected Issues to desired Hotlist. */
+  _openMoveToHotlistDialog() {
+    this.shadowRoot.querySelector('mr-move-issue-hotlists-dialog').open();
+  }
+  /**
+   * Reranks items in the hotlist, dispatching an action to Redux.
+   * @param {Array<String>} items The names of the HotlistItems to move.
+   * @param {number} index The index to insert the moved items.
+   * @return {Promise<void>}
+   */
+  async _rerankItems(items, index) {}
+};
+
+/** Redux-connected version of _MrHotlistIssuesPage. */
+export class MrHotlistIssuesPage extends connectStore(_MrHotlistIssuesPage) {
+  /** @override */
+  stateChanged(state) {
+    this._hotlist = hotlists.viewedHotlist(state);
+    this._permissions = hotlists.viewedHotlistPermissions(state);
+    this._items = hotlists.viewedHotlistIssues(state);
+    this._columns = hotlists.viewedHotlistColumns(state);
+    this._fetchError = hotlists.requests(state).fetch.error;
+    this._extractFieldValuesFromIssue =
+      projectV0.extractFieldValuesFromIssue(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('_hotlist') && this._hotlist) {
+      const pageTitle = `Issues - ${this._hotlist.displayName}`;
+      store.dispatch(sitewide.setPageTitle(pageTitle));
+      const headerTitle = `Hotlist ${this._hotlist.displayName}`;
+      store.dispatch(sitewide.setHeaderTitle(headerTitle));
+    }
+  }
+
+  /** @override */
+  async _handleHotlistSaveSuccess() {
+    const action = hotlists.fetchItems(this._hotlist.name);
+    await store.dispatch(action);
+    store.dispatch(ui.showSnackbar(ui.snackbarNames.UPDATE_HOTLISTS_SUCCESS,
+        'Hotlists updated.'));
+  }
+
+  /** @override */
+  async _removeItems() {
+    const action = hotlists.removeItems(this._hotlist.name, this._selected);
+    await store.dispatch(action);
+  }
+
+  /** @override */
+  async _rerankItems(items, index) {
+    // The index given from <mr-issue-list> includes only the items shown in
+    // the list and excludes the items that are being moved. So, we need to
+    // count the hidden items.
+    let shownItems = 0;
+    let hiddenItems = 0;
+    for (let i = 0; shownItems < index && i < this._items.length; ++i) {
+      const item = this._items[i];
+      const isShown = _isShown(this._filter, item);
+      if (!isShown) ++hiddenItems;
+      if (isShown && !items.includes(item.name)) ++shownItems;
+    }
+
+    await store.dispatch(hotlists.rerankItems(
+        this._hotlist.name, items, index + hiddenItems));
+  }
+};
+
+const _filterIssues = defaultMemoize(
+    /**
+     * Filters an array of HotlistIssues based on a filter condition. Memoized.
+     * @param {Object<string, boolean>} filter The types of issues to show.
+     * @param {Array<HotlistIssue>} items A HotlistIssue to check.
+     * @return {Array<HotlistIssue>}
+     */
+    (filter, items) => items.filter((item) => _isShown(filter, item)));
+
+/**
+ * Returns true iff the current filter includes the given HotlistIssue.
+ * @param {Object<string, boolean>} filter The types of issues to show.
+ * @param {HotlistIssue} item A HotlistIssue to check.
+ * @return {boolean}
+ */
+function _isShown(filter, item) {
+  return filter.Open && item.statusRef.meansOpen ||
+      filter.Closed && !item.statusRef.meansOpen;
+}
+
+customElements.define('mr-hotlist-issues-page-base', _MrHotlistIssuesPage);
+customElements.define('mr-hotlist-issues-page', MrHotlistIssuesPage);
diff --git a/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js
new file mode 100644
index 0000000..a651578
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js
@@ -0,0 +1,338 @@
+// 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 {store, resetState} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+import {PERMISSION_HOTLIST_EDIT} from 'shared/test/constants-permissions.js';
+
+import {MrHotlistIssuesPage} from './mr-hotlist-issues-page.js';
+
+/** @type {MrHotlistIssuesPage} */
+let element;
+
+describe('mr-hotlist-issues-page (unconnected)', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-issues-page-base');
+    element._extractFieldValuesFromIssue =
+      projectV0.extractFieldValuesFromIssue({});
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('shows hotlist fetch error', async () => {
+    element._fetchError = new Error('This is an important error');
+    element._fetchError.description = 'This is an important error';
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'important error');
+  });
+
+  it('shows loading message with null hotlist', async () => {
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'Loading');
+  });
+
+  it('renders hotlist items with one project', async () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.deepEqual(issueList.projectName, 'project-name');
+  });
+
+  it('renders hotlist items with multiple projects', async () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [
+      example.HOTLIST_ISSUE,
+      example.HOTLIST_ISSUE_OTHER_PROJECT,
+    ];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.isNull(issueList.projectName);
+  });
+
+  it('needs permissions to rerank', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.isNull(issueList.rerank);
+
+    element._permissions = [hotlists.EDIT];
+    await element.updateComplete;
+
+    assert.isNotNull(issueList.rerank);
+  });
+
+  it('memoizes issues', async () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    const issues = issueList.issues;
+
+    // Trigger a render without updating the issue list.
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    assert.strictEqual(issues, issueList.issues);
+
+    // Modify the issue list.
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    assert.notStrictEqual(issues, issueList.issues);
+  });
+
+  it('computes strings for HotlistIssue fields', async () => {
+    const clock = sinon.useFakeTimers(24 * 60 * 60 * 1000);
+
+    try {
+      element._hotlist = example.HOTLIST;
+      element._items = [{
+        ...example.HOTLIST_ISSUE,
+        summary: 'Summary',
+        rank: 52,
+        adder: exampleUsers.USER,
+        createTime: new Date(0).toISOString(),
+      }];
+      element._columns = ['Summary', 'Rank', 'Added', 'Adder'];
+      await element.updateComplete;
+
+      const issueList = element.shadowRoot.querySelector('mr-issue-list');
+      assert.include(issueList.shadowRoot.innerHTML, 'Summary');
+      assert.include(issueList.shadowRoot.innerHTML, '53');
+      assert.include(issueList.shadowRoot.innerHTML, 'a day ago');
+      assert.include(issueList.shadowRoot.innerHTML, exampleUsers.DISPLAY_NAME);
+    } finally {
+      clock.restore();
+    }
+  });
+
+  it('filters and shows closed issues', async () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE_CLOSED];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.equal(issueList.issues.length, 0);
+
+    element.shadowRoot.querySelector('chops-filter-chips').select('Closed');
+    await element.updateComplete;
+
+    assert.isTrue(element._filter.Closed);
+    assert.equal(issueList.issues.length, 1);
+  });
+
+  it('updates button bar on list selection', async () => {
+    element._permissions = PERMISSION_HOTLIST_EDIT;
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    const buttonBar = element.shadowRoot.querySelector('mr-button-bar');
+    assert.include(buttonBar.shadowRoot.innerHTML, 'Change columns');
+    assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Remove');
+    assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Update');
+    assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Move to...');
+    assert.deepEqual(element._selected, []);
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    issueList.shadowRoot.querySelector('input').click();
+    await element.updateComplete;
+
+    assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Change columns');
+    assert.include(buttonBar.shadowRoot.innerHTML, 'Remove');
+    assert.include(buttonBar.shadowRoot.innerHTML, 'Update');
+    assert.include(buttonBar.shadowRoot.innerHTML, 'Move to...');
+    assert.deepEqual(element._selected, [exampleIssues.NAME]);
+  });
+
+  it('hides issues checkboxes if the user cannot edit', async () => {
+    element._permissions = [];
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.notInclude(issueList.shadowRoot.innerHTML, 'input');
+  });
+
+  it('opens "Change columns" dialog', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    const dialog = element.shadowRoot.querySelector('mr-change-columns');
+    sinon.stub(dialog, 'open');
+    try {
+      element._openColumnsDialog();
+
+      sinon.assert.calledOnce(dialog.open);
+    } finally {
+      dialog.open.restore();
+    }
+  });
+
+  it('opens "Update" dialog', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    const dialog = element.shadowRoot.querySelector(
+        'mr-update-issue-hotlists-dialog');
+    sinon.stub(dialog, 'open');
+    try {
+      element._openUpdateIssuesHotlistsDialog();
+
+      sinon.assert.calledOnce(dialog.open);
+    } finally {
+      dialog.open.restore();
+    }
+  });
+
+  it('handles successful save from its update dialog', async () => {
+    sinon.stub(element, '_handleHotlistSaveSuccess');
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    try {
+      const dialog =
+          element.shadowRoot.querySelector('mr-update-issue-hotlists-dialog');
+      dialog.dispatchEvent(new Event('saveSuccess'));
+      sinon.assert.calledOnce(element._handleHotlistSaveSuccess);
+    } finally {
+      element._handleHotlistSaveSuccess.restore();
+    }
+  });
+
+  it('opens "Move to..." dialog', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    const dialog = element.shadowRoot.querySelector(
+        'mr-move-issue-hotlists-dialog');
+    sinon.stub(dialog, 'open');
+    try {
+      element._openMoveToHotlistDialog();
+
+      sinon.assert.calledOnce(dialog.open);
+    } finally {
+      dialog.open.restore();
+    }
+  });
+
+  it('handles successful save from its move dialog', async () => {
+    sinon.stub(element, '_handleHotlistSaveSuccess');
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    try {
+      const dialog =
+          element.shadowRoot.querySelector('mr-move-issue-hotlists-dialog');
+      dialog.dispatchEvent(new Event('saveSuccess'));
+      sinon.assert.calledOnce(element._handleHotlistSaveSuccess);
+    } finally {
+      element._handleHotlistSaveSuccess.restore();
+    }
+  });
+});
+
+describe('mr-hotlist-issues-page (connected)', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-issues-page');
+    element._extractFieldValuesFromIssue =
+      projectV0.extractFieldValuesFromIssue({});
+    document.body.appendChild(element);
+
+    // Stop Redux from overriding values being tested.
+    sinon.stub(element, 'stateChanged');
+  });
+
+  afterEach(() => {
+    element.stateChanged.restore();
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrHotlistIssuesPage);
+  });
+
+  it('updates page title and header', async () => {
+    element._hotlist = {...example.HOTLIST, displayName: 'Hotlist-Name'};
+    await element.updateComplete;
+
+    const state = store.getState();
+    assert.deepEqual(sitewide.pageTitle(state), 'Issues - Hotlist-Name');
+    assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+  });
+
+  it('removes items', () => {
+    element._hotlist = example.HOTLIST;
+    element._selected = [exampleIssues.NAME];
+
+    const removeItems = sinon.spy(hotlists, 'removeItems');
+    try {
+      element._removeItems();
+      sinon.assert.calledWith(removeItems, example.NAME, [exampleIssues.NAME]);
+    } finally {
+      removeItems.restore();
+    }
+  });
+
+  it('fetches a hotlist when handling a successful save', () => {
+    element._hotlist = example.HOTLIST;
+
+    const fetchItems = sinon.spy(hotlists, 'fetchItems');
+    try {
+      element._handleHotlistSaveSuccess();
+      sinon.assert.calledWith(fetchItems, example.NAME);
+    } finally {
+      fetchItems.restore();
+    }
+  });
+
+  it('reranks', () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [
+      example.HOTLIST_ISSUE,
+      example.HOTLIST_ISSUE_CLOSED,
+      example.HOTLIST_ISSUE_OTHER_PROJECT,
+    ];
+
+    const rerankItems = sinon.spy(hotlists, 'rerankItems');
+    try {
+      element._rerankItems([example.HOTLIST_ITEM_NAME], 1);
+
+      sinon.assert.calledWith(
+          rerankItems, example.NAME, [example.HOTLIST_ITEM_NAME], 2);
+    } finally {
+      rerankItems.restore();
+    }
+  });
+});
+
+it('mr-hotlist-issues-page (stateChanged)', () => {
+  // @ts-ignore
+  element = document.createElement('mr-hotlist-issues-page');
+  document.body.appendChild(element);
+  assert.instanceOf(element, MrHotlistIssuesPage);
+  document.body.removeChild(element);
+});
diff --git a/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js
new file mode 100644
index 0000000..c317d39
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js
@@ -0,0 +1,260 @@
+// 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 debounce from 'debounce';
+import {LitElement, html, css} from 'lit-element';
+
+import {userV3ToRef} from 'shared/convertersV0.js';
+
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as users from 'reducers/users.js';
+
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+/** Hotlist People page */
+class _MrHotlistPeoplePage extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      section {
+        margin: 16px 24px;
+      }
+      h2 {
+        font-weight: normal;
+      }
+
+      ul {
+        padding: 0;
+      }
+      li {
+        list-style-type: none;
+      }
+      p, li, form {
+        display: flex;
+      }
+      p, ul, li, form {
+        margin: 12px 0;
+      }
+
+      input {
+        margin-left: -6px;
+        padding: 4px;
+        width: 320px;
+      }
+
+      button {
+        align-items: center;
+        background-color: transparent;
+        border: 0;
+        cursor: pointer;
+        display: inline-flex;
+        margin: 0 4px;
+        padding: 0;
+      }
+      .material-icons {
+        font-size: 18px;
+      }
+
+      .placeholder::before {
+        animation: pulse 1s infinite ease-in-out;
+        border-radius: 3px;
+        content: " ";
+        height: 10px;
+        margin: 4px 0;
+        width: 200px;
+      }
+      @keyframes pulse {
+        0% {background-color: var(--chops-blue-50);}
+        50% {background-color: var(--chops-blue-75);}
+        100% {background-color: var(--chops-blue-50);}
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-hotlist-header selected=1></mr-hotlist-header>
+      ${this._renderPage()}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderPage() {
+    if (this._fetchError) {
+      return html`<section>${this._fetchError.description}</section>`;
+    }
+
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+
+      <section>
+        <h2>Owner</h2>
+        ${this._renderOwner(this._owner)}
+      </section>
+
+      <section>
+        <h2>Editors</h2>
+        ${this._renderEditors(this._editors)}
+
+        ${this._permissions.includes(hotlists.ADMINISTER) ? html`
+          <form @submit=${this._onAddEditors}>
+            <input id="add" placeholder="List of email addresses"></input>
+            <button><i class="material-icons">add</i></button>
+          </form>
+        ` : html``}
+      </section>
+    `;
+  }
+
+  /**
+   * @param {?User} owner
+   * @return {TemplateResult}
+   */
+  _renderOwner(owner) {
+    if (!owner) return html`<p class="placeholder"></p>`;
+    return html`
+      <p><mr-user-link .userRef=${userV3ToRef(owner)}></mr-user-link></p>
+    `;
+  }
+
+  /**
+   * @param {?Array<User>} editors
+   * @return {TemplateResult}
+   */
+  _renderEditors(editors) {
+    if (!editors) return html`<p class="placeholder"></p>`;
+    if (!editors.length) return html`<p>No editors.</p>`;
+
+    return html`
+      <ul>${editors.map((editor) => this._renderEditor(editor))}</ul>
+    `;
+  }
+
+  /**
+   * @param {?User} editor
+   * @return {TemplateResult}
+   */
+  _renderEditor(editor) {
+    if (!editor) return html`<li class="placeholder"></li>`;
+
+    const canRemove = this._permissions.includes(hotlists.ADMINISTER) ||
+        editor.name === this._currentUserName;
+
+    return html`
+      <li>
+        <mr-user-link .userRef=${userV3ToRef(editor)}></mr-user-link>
+        ${canRemove ? html`
+          <button @click=${this._removeEditor.bind(this, editor.name)}>
+            <i class="material-icons">clear</i>
+          </button>
+        ` : html``}
+      </li>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // Populated from Redux.
+      _hotlist: {type: Object},
+      _owner: {type: Object},
+      _editors: {type: Array},
+      _permissions: {type: Array},
+      _currentUserName: {type: String},
+      _fetchError: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    // Populated from Redux.
+    /** @type {?Hotlist} */ this._hotlist = null;
+    /** @type {?User} */ this._owner = null;
+    /** @type {Array<User>} */ this._editors = null;
+    /** @type {Array<Permission>} */ this._permissions = [];
+    /** @type {?String} */ this._currentUserName = null;
+    /** @type {?Error} */ this._fetchError = null;
+
+    this._debouncedAddEditors = debounce(this._addEditors, 400, true);
+  }
+
+  /** Adds hotlist editors.
+   * @param {Event} event
+   */
+  async _onAddEditors(event) {
+    event.preventDefault();
+
+    const input =
+      /** @type {HTMLInputElement} */ (this.shadowRoot.getElementById('add'));
+    const emails = input.value.split(/[\s,;]/).filter((e) => e);
+    if (!emails.length) return;
+    const editors = emails.map((email) => 'users/' + email);
+    try {
+      await this._debouncedAddEditors(editors);
+      input.value = '';
+    } catch (error) {
+      // The `hotlists.update()` call shows a snackbar on errors.
+    }
+  }
+
+  /** Adds hotlist editors.
+   * @param {Array<string>} editors An Array of User resource names.
+   */
+  async _addEditors(editors) {}
+
+  /**
+   * Removes a hotlist editor.
+   * @param {string} name A User resource name.
+  */
+  async _removeEditor(name) {}
+};
+
+/** Redux-connected version of _MrHotlistPeoplePage. */
+export class MrHotlistPeoplePage extends connectStore(_MrHotlistPeoplePage) {
+  /** @override */
+  stateChanged(state) {
+    this._hotlist = hotlists.viewedHotlist(state);
+    this._owner = hotlists.viewedHotlistOwner(state);
+    this._editors = hotlists.viewedHotlistEditors(state);
+    this._permissions = hotlists.viewedHotlistPermissions(state);
+    this._currentUserName = users.currentUserName(state);
+    this._fetchError = hotlists.requests(state).fetch.error;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('_hotlist') && this._hotlist) {
+      const pageTitle = 'People - ' + this._hotlist.displayName;
+      store.dispatch(sitewide.setPageTitle(pageTitle));
+      const headerTitle = 'Hotlist ' + this._hotlist.displayName;
+      store.dispatch(sitewide.setHeaderTitle(headerTitle));
+    }
+  }
+
+  /** @override */
+  async _addEditors(editors) {
+    await store.dispatch(hotlists.update(this._hotlist.name, {editors}));
+  }
+
+  /** @override */
+  async _removeEditor(name) {
+    await store.dispatch(hotlists.removeEditors(this._hotlist.name, [name]));
+  }
+}
+
+customElements.define('mr-hotlist-people-page-base', _MrHotlistPeoplePage);
+customElements.define('mr-hotlist-people-page', MrHotlistPeoplePage);
diff --git a/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js
new file mode 100644
index 0000000..b7dd6dc
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js
@@ -0,0 +1,176 @@
+// Copyright 2020 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 {store, resetState} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+
+import {MrHotlistPeoplePage} from './mr-hotlist-people-page.js';
+
+/** @type {MrHotlistPeoplePage} */
+let element;
+
+describe('mr-hotlist-people-page (unconnected)', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-people-page-base');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('shows hotlist fetch error', async () => {
+    element._fetchError = new Error('This is an important error');
+    element._fetchError.description = 'This is an important error';
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'important error');
+  });
+
+  it('renders placeholders with no data', async () => {
+    await element.updateComplete;
+
+    const placeholders = element.shadowRoot.querySelectorAll('.placeholder');
+    assert.equal(placeholders.length, 2);
+  });
+
+  it('renders placeholders with editors list but no user data', async () => {
+    element._editors = [null, null];
+    await element.updateComplete;
+
+    const placeholders = element.shadowRoot.querySelectorAll('.placeholder');
+    assert.equal(placeholders.length, 3);
+  });
+
+  it('renders "No editors"', async () => {
+    element._editors = [];
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.innerHTML, 'No editors');
+  });
+
+  it('renders hotlist', async () => {
+    element._hotlist = example.HOTLIST;
+    element._owner = exampleUsers.USER;
+    element._editors = [exampleUsers.USER_2];
+    await element.updateComplete;
+  });
+
+  it('shows controls iff user has admin permissions', async () => {
+    element._editors = [exampleUsers.USER_2];
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelectorAll('button').length, 0);
+
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelectorAll('button').length, 2);
+  });
+
+  it('shows remove button if user is editing themselves', async () => {
+    element._editors = [exampleUsers.USER, exampleUsers.USER_2];
+    element._currentUserName = exampleUsers.USER_2.name;
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelectorAll('button').length, 1);
+  });
+});
+
+describe('mr-hotlist-people-page (connected)', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-people-page');
+    document.body.appendChild(element);
+
+    // Stop Redux from overriding values being tested.
+    sinon.stub(element, 'stateChanged');
+  });
+
+  afterEach(() => {
+    element.stateChanged.restore();
+    document.body.removeChild(element);
+  });
+
+  it('initializes', async () => {
+    assert.instanceOf(element, MrHotlistPeoplePage);
+  });
+
+  it('updates page title and header', async () => {
+    element._hotlist = {...example.HOTLIST, displayName: 'Hotlist-Name'};
+    await element.updateComplete;
+
+    const state = store.getState();
+    assert.deepEqual(sitewide.pageTitle(state), 'People - Hotlist-Name');
+    assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+  });
+
+  it('adds editors', async () => {
+    element._hotlist = example.HOTLIST;
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+
+    const input = /** @type {HTMLInputElement} */
+        (element.shadowRoot.getElementById('add'));
+    input.value = 'test@example.com, test2@example.com';
+
+    const update = sinon.spy(hotlists, 'update');
+    try {
+      await element._onAddEditors(new Event('submit'));
+
+      const editors = ['users/test@example.com', 'users/test2@example.com'];
+      sinon.assert.calledWith(update, example.HOTLIST.name, {editors});
+    } finally {
+      update.restore();
+    }
+  });
+
+  it('_onAddEditors ignores empty input', async () => {
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+
+    const input = /** @type {HTMLInputElement} */
+        (element.shadowRoot.getElementById('add'));
+    input.value = '  ';
+
+    const update = sinon.spy(hotlists, 'update');
+    try {
+      await element._onAddEditors(new Event('submit'));
+      sinon.assert.notCalled(update);
+    } finally {
+      update.restore();
+    }
+  });
+
+  it('removes editors', async () => {
+    element._hotlist = example.HOTLIST;
+
+    const removeEditors = sinon.spy(hotlists, 'removeEditors');
+    try {
+      await element._removeEditor(exampleUsers.NAME_2);
+
+      sinon.assert.calledWith(
+          removeEditors, example.HOTLIST.name, [exampleUsers.NAME_2]);
+    } finally {
+      removeEditors.restore();
+    }
+  });
+});
+
+it('mr-hotlist-people-page (stateChanged)', () => {
+  // @ts-ignore
+  element = document.createElement('mr-hotlist-people-page');
+  document.body.appendChild(element);
+  assert.instanceOf(element, MrHotlistPeoplePage);
+  document.body.removeChild(element);
+});
diff --git a/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js
new file mode 100644
index 0000000..4f4d90d
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js
@@ -0,0 +1,310 @@
+// 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 'shared/typedef.js';
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as ui from 'reducers/ui.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+/**
+ * Supported Hotlist privacy options from feature_objects.proto.
+ * @enum {string}
+ */
+const HotlistPrivacy = {
+  PRIVATE: 'PRIVATE',
+  PUBLIC: 'PUBLIC',
+};
+
+/** Hotlist Settings page */
+class _MrHotlistSettingsPage extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+        }
+        h2 {
+          font-weight: normal;
+        }
+        section, dl, form {
+          margin: 16px 24px;
+        }
+        dt {
+          font-weight: bold;
+          text-align: right;
+          word-wrap: break-word;
+        }
+        dd {
+          margin-left: 0;
+        }
+        label {
+          display: flex;
+          flex-direction: column;
+        }
+        form input,
+        form select {
+          /* Match minimum size of header. */
+          min-width: 250px;
+        }
+        /* https://material.io/design/layout/responsive-layout-grid.html#breakpoints */
+        @media (min-width: 1024px) {
+          input,
+          select,
+          p,
+          dd {
+            max-width: 750px;
+          }
+        }
+        #save-hotlist {
+          background: var(--chops-primary-button-bg);
+          color: var(--chops-primary-button-color);
+        }
+     `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-hotlist-header selected=2></mr-hotlist-header>
+      ${this._renderPage()}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderPage() {
+    if (!this._hotlist) {
+      if (this._fetchError) {
+        return html`<section>${this._fetchError.description}</section>`;
+      } else {
+        return html`<section>Loading...</section>`;
+      }
+    }
+
+    const defaultColumns = this._hotlist.defaultColumns
+        .map((col) => col.column).join(' ');
+    if (this._permissions.includes(hotlists.ADMINISTER)) {
+      return this._renderEditableForm(defaultColumns);
+    }
+    return this._renderViewOnly(defaultColumns);
+  }
+
+  /**
+   * Render the editable form Settings page.
+   * @param {string} defaultColumns The default columns to be shown.
+   * @return {TemplateResult}
+   */
+  _renderEditableForm(defaultColumns) {
+    return html`
+      <form id="settingsForm" class="input-grid"
+        @change=${this._handleFormChange}>
+        <label>Name</label>
+        <input id="displayName" class="path"
+            value="${this._hotlist.displayName}">
+        <label>Summary</label>
+        <input id="summary" class="path" value="${this._hotlist.summary}">
+        <label>Default Issues columns</label>
+        <input id="defaultColumns" class="path" value="${defaultColumns}">
+        <label>Who can view this hotlist</label>
+        <select id="hotlistPrivacy" class="path">
+          <option
+            value="${HotlistPrivacy.PUBLIC}"
+            ?selected="${this._hotlist.hotlistPrivacy ===
+                        HotlistPrivacy.PUBLIC}">
+            Anyone on the Internet
+          </option>
+          <option
+            value="${HotlistPrivacy.PRIVATE}"
+            ?selected="${this._hotlist.hotlistPrivacy ===
+                        HotlistPrivacy.PRIVATE}">
+            Members only
+          </option>
+        </select>
+        <span><!-- grid spacer --></span>
+        <p>
+          Individual issues in the list can only be seen by users who can
+          normally see them. The privacy status of an issue is considered
+          when it is being displayed (or not displayed) in a hotlist.
+        </p>
+        <span><!-- grid spacer --></span>
+        <div>
+          <chops-button @click=${this._save} id="save-hotlist" disabled>
+            Save hotlist
+          </chops-button>
+          <chops-button @click=${this._delete} id="delete-hotlist">
+            Delete hotlist
+          </chops-button>
+        </div>
+      </form>
+    `;
+  }
+
+  /**
+   * Render the view-only Settings page.
+   * @param {string} defaultColumns The default columns to be shown.
+   * @return {TemplateResult}
+   */
+  _renderViewOnly(defaultColumns) {
+    return html`
+      <dl class="input-grid">
+        <dt>Name</dt>
+        <dd>${this._hotlist.displayName}</dd>
+        <dt>Summary</dt>
+        <dd>${this._hotlist.summary}</dd>
+        <dt>Default Issues columns</dt>
+        <dd>${defaultColumns}</dd>
+        <dt>Who can view this hotlist</dt>
+        <dd>
+          ${this._hotlist.hotlistPrivacy &&
+            this._hotlist.hotlistPrivacy === HotlistPrivacy.PUBLIC ?
+            'Anyone on the Internet' : 'Members only'}
+        </dd>
+        <dt></dt>
+        <dd>
+          Individual issues in the list can only be seen by users who can
+          normally see them. The privacy status of an issue is considered
+          when it is being displayed (or not displayed) in a hotlist.
+        </dd>
+      </dl>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // Populated from Redux.
+      _hotlist: {type: Object},
+      _permissions: {type: Array},
+      _currentUser: {type: Object},
+      _fetchError: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    // Populated from Redux.
+    /** @type {?Hotlist} */ this._hotlist = null;
+    /** @type {Array<Permission>} */ this._permissions = [];
+    /** @type {UserRef} */ this._currentUser = null;
+    /** @type {?Error} */ this._fetchError = null;
+
+    // Expose page.js for test stubbing.
+    this.page = page;
+  }
+
+  /**
+   * Handles changes to the editable form.
+   * @param {Event} e
+   */
+  _handleFormChange() {
+    const saveButton = this.shadowRoot.getElementById('save-hotlist');
+    if (saveButton.disabled) {
+      saveButton.disabled = false;
+    }
+  }
+
+  /** Saves the hotlist, dispatching an action to Redux. */
+  async _save() {}
+
+  /** Deletes the hotlist, dispatching an action to Redux. */
+  async _delete() {}
+};
+
+/** Redux-connected version of _MrHotlistSettingsPage. */
+export class MrHotlistSettingsPage
+  extends connectStore(_MrHotlistSettingsPage) {
+  /** @override */
+  stateChanged(state) {
+    this._hotlist = hotlists.viewedHotlist(state);
+    this._permissions = hotlists.viewedHotlistPermissions(state);
+    this._currentUser = userV0.currentUser(state);
+    this._fetchError = hotlists.requests(state).fetch.error;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('_hotlist') && this._hotlist) {
+      const pageTitle = 'Settings - ' + this._hotlist.displayName;
+      store.dispatch(sitewide.setPageTitle(pageTitle));
+      const headerTitle = 'Hotlist ' + this._hotlist.displayName;
+      store.dispatch(sitewide.setHeaderTitle(headerTitle));
+    }
+  }
+
+  /** @override */
+  async _save() {
+    const form = this.shadowRoot.getElementById('settingsForm');
+    if (!form) return;
+
+    // TODO(https://crbug.com/monorail/7475): Consider generalizing this logic.
+    const updatedHotlist = /** @type {Hotlist} */({});
+    // These are is an input or select elements.
+    const pathInputs = form.querySelectorAll('.path');
+    pathInputs.forEach((input) => {
+      const path = input.id;
+      const value = /** @type {HTMLInputElement} */(input).value;
+      switch (path) {
+        case 'defaultColumns':
+          const columnsValue = [];
+          value.trim().split(' ').forEach((column) => {
+            if (column) columnsValue.push({column});
+          });
+          if (JSON.stringify(columnsValue) !==
+              JSON.stringify(this._hotlist.defaultColumns)) {
+            updatedHotlist.defaultColumns = columnsValue;
+          }
+          break;
+        default:
+          if (value !== this._hotlist[path]) updatedHotlist[path] = value;
+          break;
+      };
+    });
+
+    const action = hotlists.update(this._hotlist.name, updatedHotlist);
+    await store.dispatch(action);
+    this._showHotlistSavedSnackbar();
+  }
+
+  /**
+   * Shows a snackbar informing the user about their save request.
+   */
+  async _showHotlistSavedSnackbar() {
+    await store.dispatch(ui.showSnackbar(
+        'SNACKBAR_ID_HOTLIST_SETTINGS_UPDATED', 'Hotlist Updated.'));
+  }
+
+  /** @override */
+  async _delete() {
+    if (confirm(
+        'Are you sure you want to delete this hotlist? This cannot be undone.')
+    ) {
+      const action = hotlists.deleteHotlist(this._hotlist.name);
+      await store.dispatch(action);
+
+      // TODO(crbug/monorail/7430): Handle an error and add <chops-snackbar>.
+      // Note that this will redirect regardless of an error.
+      this.page(`/u/${this._currentUser.displayName}/hotlists`);
+    }
+  }
+}
+
+customElements.define('mr-hotlist-settings-page-base', _MrHotlistSettingsPage);
+customElements.define('mr-hotlist-settings-page', MrHotlistSettingsPage);
diff --git a/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.test.js b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.test.js
new file mode 100644
index 0000000..987fff2
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.test.js
@@ -0,0 +1,167 @@
+// 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 {store, resetState} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+
+import {MrHotlistSettingsPage} from './mr-hotlist-settings-page.js';
+
+/** @type {MrHotlistSettingsPage} */
+let element;
+
+describe('mr-hotlist-settings-page (unconnected)', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-settings-page-base');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('shows hotlist fetch error', async () => {
+    element._fetchError = new Error('This is an important error');
+    element._fetchError.description = 'This is an important error';
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'important error');
+  });
+
+  it('shows loading message with null hotlist', async () => {
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'Loading');
+  });
+
+  it('renders hotlist', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+  });
+
+  it('renders a view only hotlist if no permissions', async () => {
+    element._hotlist = {...example.HOTLIST};
+    await element.updateComplete;
+    assert.notInclude(element.shadowRoot.innerHTML, 'form');
+  });
+
+  it('renders an editable hotlist if permission to administer', async () => {
+    element._hotlist = {...example.HOTLIST};
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'form');
+  });
+
+  it('renders private hotlist', async () => {
+    element._hotlist = {...example.HOTLIST, hotlistPrivacy: 'PRIVATE'};
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'Members only');
+  });
+});
+
+describe('mr-hotlist-settings-page (connected)', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-settings-page');
+    document.body.appendChild(element);
+
+    // Stop Redux from overriding values being tested.
+    sinon.stub(element, 'stateChanged');
+  });
+
+  afterEach(() => {
+    element.stateChanged.restore();
+    document.body.removeChild(element);
+  });
+
+  it('updates page title and header', async () => {
+    element._hotlist = {...example.HOTLIST, displayName: 'Hotlist-Name'};
+    await element.updateComplete;
+
+    const state = store.getState();
+    assert.deepEqual(sitewide.pageTitle(state), 'Settings - Hotlist-Name');
+    assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+  });
+
+  it('deletes hotlist', async () => {
+    element._hotlist = example.HOTLIST;
+    element._permissions = [hotlists.ADMINISTER];
+    element._currentUser = exampleUsers.USER;
+    await element.updateComplete;
+
+    const deleteButton = element.shadowRoot.getElementById('delete-hotlist');
+    assert.isNotNull(deleteButton);
+
+    // Auto confirm deletion of hotlist.
+    const confirmStub = sinon.stub(window, 'confirm');
+    confirmStub.returns(true);
+
+    const pageStub = sinon.stub(element, 'page');
+
+    const deleteHotlist = sinon.spy(hotlists, 'deleteHotlist');
+
+    try {
+      await element._delete();
+
+      sinon.assert.calledWith(deleteHotlist, example.NAME);
+      sinon.assert.calledWith(
+          element.page, `/u/${exampleUsers.DISPLAY_NAME}/hotlists`);
+    } finally {
+      deleteHotlist.restore();
+      pageStub.restore();
+      confirmStub.restore();
+    }
+  });
+
+  it('updates hotlist when there are changes', async () => {
+    element._hotlist = {...example.HOTLIST};
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+
+    const saveButton = element.shadowRoot.getElementById('save-hotlist');
+    assert.isNotNull(saveButton);
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+
+    const hlist = {
+      displayName: element._hotlist.displayName + 'foo',
+      summary: element._hotlist.summary + 'abc',
+    };
+
+    const summaryInput = element.shadowRoot.getElementById('summary');
+    /** @type {HTMLInputElement} */ (summaryInput).value += 'abc';
+    const nameInput =
+        element.shadowRoot.getElementById('displayName');
+    /** @type {HTMLInputElement} */ (nameInput).value += 'foo';
+
+    await element.shadowRoot.getElementById('settingsForm').dispatchEvent(
+        new Event('change'));
+    assert.isFalse(saveButton.hasAttribute('disabled'));
+
+    const snackbarStub = sinon.stub(element, '_showHotlistSavedSnackbar');
+    const update = sinon.stub(hotlists, 'update').returns(async () => {});
+    try {
+      await element._save();
+      sinon.assert.calledWith(update, example.HOTLIST.name, hlist);
+      sinon.assert.calledOnce(snackbarStub);
+    } finally {
+      update.restore();
+      snackbarStub.restore();
+    }
+  });
+});
+
+it('mr-hotlist-settings-page (stateChanged)', () => {
+  // @ts-ignore
+  element = document.createElement('mr-hotlist-settings-page');
+  document.body.appendChild(element);
+  assert.instanceOf(element, MrHotlistSettingsPage);
+  document.body.removeChild(element);
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js
new file mode 100644
index 0000000..8da3083
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js
@@ -0,0 +1,186 @@
+// 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 {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/framework/mr-error/mr-error.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+// TODO(zhangtiff): Make dialog components subclass chops-dialog instead of
+// using slots/containment once we switch to LitElement.
+/**
+ * `<mr-convert-issue>`
+ *
+ * This allows a user to update the structure of an issue to that of
+ * a chosen project template.
+ *
+ */
+export class MrConvertIssue extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        label {
+          font-weight: bold;
+          text-align: right;
+        }
+        form {
+          padding: 1em 8px;
+          display: block;
+          font-size: var(--chops-main-font-size);
+        }
+        textarea {
+          font-family: var(--mr-toggled-font-family);
+          min-height: 80px;
+          border: var(--chops-accessible-border);
+          padding: 0.5em 4px;
+        }
+        .edit-actions {
+          width: 100%;
+          margin: 0.5em 0;
+          text-align: right;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <chops-dialog closeOnOutsideClick>
+        <h3 class="medium-heading">Convert issue to new template structure</h3>
+        <form id="convertIssueForm">
+          <div class="input-grid">
+            <label for="templateInput">Pick a template: </label>
+            <select id="templateInput" @change=${this._templateInputChanged}>
+              <option value="">--Please choose a project template--</option>
+              ${this.projectTemplates.map((projTempl) => html`
+                <option value=${projTempl.templateName}>
+                  ${projTempl.templateName}
+                </option>`)}
+            </select>
+            <label for="commentContent">Comment: </label>
+            <textarea id="commentContent" placeholder="Add a comment"></textarea>
+            <span></span>
+            <chops-checkbox
+              @checked-change=${this._sendEmailChecked}
+              checked=${this.sendEmail}
+            >Send email</chops-checkbox>
+          </div>
+          <mr-error ?hidden=${!this.convertIssueError}>
+            ${this.convertIssueError && this.convertIssueError.description}
+          </mr-error>
+          <div class="edit-actions">
+            <chops-button @click=${this.close} class="de-emphasized discard-button">
+              Discard
+            </chops-button>
+            <chops-button @click=${this.save} class="emphasized" ?disabled=${!this.selectedTemplate}>
+              Convert issue
+            </chops-button>
+          </div>
+        </form>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      convertingIssue: {
+        type: Boolean,
+      },
+      convertIssueError: {
+        type: Object,
+      },
+      issuePermissions: {
+        type: Object,
+      },
+      issueRef: {
+        type: Object,
+      },
+      projectTemplates: {
+        type: Array,
+      },
+      selectedTemplate: {
+        type: String,
+      },
+      sendEmail: {
+        type: Boolean,
+      },
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.convertingIssue = issueV0.requests(state).convert.requesting;
+    this.convertIssueError = issueV0.requests(state).convert.error;
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.projectTemplates = projectV0.viewedTemplates(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.selectedTemplate = '';
+    this.sendEmail = true;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('convertingIssue')) {
+      if (!this.convertingIssue && !this.convertIssueError) {
+        this.close();
+      }
+    }
+  }
+
+  open() {
+    this.reset();
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.open();
+  }
+
+  close() {
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.close();
+  }
+
+  /**
+   * Resets the user's input.
+   */
+  reset() {
+    this.shadowRoot.querySelector('#convertIssueForm').reset();
+  }
+
+  /**
+   * Dispatches a Redux action to convert the issue to a new template.
+   */
+  save() {
+    const commentContent = this.shadowRoot.querySelector('#commentContent');
+    store.dispatch(issueV0.convert(this.issueRef, {
+      templateName: this.selectedTemplate,
+      commentContent: commentContent.value,
+      sendEmail: this.sendEmail,
+    }));
+  }
+
+  _sendEmailChecked(evt) {
+    this.sendEmail = evt.detail.checked;
+  }
+
+  _templateInputChanged() {
+    this.selectedTemplate = this.shadowRoot.querySelector(
+        '#templateInput').value;
+  }
+}
+
+customElements.define('mr-convert-issue', MrConvertIssue);
diff --git a/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.test.js b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.test.js
new file mode 100644
index 0000000..b68e274
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.test.js
@@ -0,0 +1,30 @@
+// 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 {MrConvertIssue} from './mr-convert-issue.js';
+
+let element;
+
+describe('mr-convert-issue', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-convert-issue');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrConvertIssue);
+  });
+
+  it('no template chosen', async () => {
+    await element.updateComplete;
+
+    const buttons = element.shadowRoot.querySelectorAll('chops-button');
+    assert.isTrue(buttons[buttons.length - 1].disabled);
+  });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js
new file mode 100644
index 0000000..2a34b8f
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js
@@ -0,0 +1,340 @@
+// 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 'elements/framework/mr-upload/mr-upload.js';
+import 'elements/framework/mr-error/mr-error.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+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 'elements/chops/chops-checkbox/chops-checkbox.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES, MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+
+
+/**
+ * `<mr-edit-description>`
+ *
+ * A dialog to edit descriptions.
+ *
+ */
+export class MrEditDescription extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+    this._editedDescription = '';
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      MD_PREVIEW_STYLES,
+      MD_STYLES,
+      css`
+        chops-dialog {
+          --chops-dialog-width: 800px;
+        }
+        textarea {
+          font-family: var(--mr-toggled-font-family);
+          min-height: 300px;
+          max-height: 500px;
+          border: var(--chops-accessible-border);
+          padding: 0.5em 4px;
+          margin: 0.5em 0;
+        }
+        .attachments {
+          margin: 0.5em 0;
+        }
+        .content {
+          padding: 0.5em 0.5em;
+          width: 100%;
+          box-sizing: border-box;
+        }
+        .edit-controls {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <chops-dialog aria-labelledby="editDialogTitle">
+        <h3 id="editDialogTitle" class="medium-heading">
+          Edit ${this._title}
+        </h3>
+        <textarea
+          id="description"
+          class="content"
+          @keyup=${this._setEditedDescription}
+          @change=${this._setEditedDescription}
+          .value=${this._editedDescription}
+        ></textarea>
+        ${this._renderMarkdown ? html`
+          <div class="markdown-preview preview-height-description">
+            <div class="markdown">
+              ${unsafeHTML(renderMarkdown(this._editedDescription))}
+            </div>
+          </div>`: ''}
+        <h3 class="medium-heading">
+          Add attachments
+        </h3>
+        <div class="attachments">
+          ${this._attachments && this._attachments.map((attachment) => html`
+            <label>
+              <chops-checkbox
+                type="checkbox"
+                checked="true"
+                class="kept-attachment"
+                data-attachment-id=${attachment.attachmentId}
+                @checked-change=${this._keptAttachmentIdsChanged}
+              />
+              <a href=${attachment.viewUrl} target="_blank">
+                ${attachment.filename}
+              </a>
+            </label>
+            <br>
+          `)}
+          <mr-upload></mr-upload>
+        </div>
+        <mr-error
+          ?hidden=${!this._attachmentError}
+        >${this._attachmentError}</mr-error>
+        <div class="edit-controls">
+          <chops-checkbox
+            id="sendEmail"
+            ?checked=${this._sendEmail}
+            @checked-change=${this._setSendEmail}
+          >Send email</chops-checkbox>
+          <div>
+            <chops-button id="discard" @click=${this.cancel} class="de-emphasized">
+              Discard
+            </chops-button>
+            <chops-button id="save" @click=${this.save} class="emphasized">
+              Save changes
+            </chops-button>
+          </div>
+        </div>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      commentsByApproval: {type: Array},
+      issueRef: {type: Object},
+      fieldName: {type: String},
+      projectName: {type: String},
+      _attachmentError: {type: String},
+      _attachments: {type: Array},
+      _boldLines: {type: Array},
+      _editedDescription: {type: String},
+      _title: {type: String},
+      _keptAttachmentIds: {type: Object},
+      _sendEmail: {type: Boolean},
+      _prefs: {type: Object},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.commentsByApproval = issueV0.commentsByApprovalName(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.projectName = projectV0.viewedProjectName(state);
+    this._prefs = userV0.prefs(state);
+  }
+
+  /**
+   * Public function to open the issue description editing dialog.
+   * @param {Event} e
+   */
+  async open(e) {
+    await this.updateComplete;
+    this.shadowRoot.querySelector('chops-dialog').open();
+    this.fieldName = e.detail.fieldName;
+    this.reset();
+  }
+
+  /**
+   * Resets edit form.
+   */
+  async reset() {
+    await this.updateComplete;
+    this._attachmentError = '';
+    this._attachments = [];
+    this._boldLines = [];
+    this._keptAttachmentIds = new Set();
+
+    const uploader = this.shadowRoot.querySelector('mr-upload');
+    if (uploader) {
+      uploader.reset();
+    }
+
+    // Sets _editedDescription and _title.
+    this._initializeView(this.commentsByApproval, this.fieldName);
+
+    this.shadowRoot.querySelectorAll('.kept-attachment').forEach((checkbox) => {
+      checkbox.checked = true;
+    });
+    this.shadowRoot.querySelector('#sendEmail').checked = true;
+
+    this._sendEmail = true;
+  }
+
+  /**
+   * Cancels in-flight edit data.
+   */
+  async cancel() {
+    await this.updateComplete;
+    this.shadowRoot.querySelector('chops-dialog').close();
+  }
+
+  /**
+   * Sends the user's edit to Monorail's backend to be saved.
+   */
+  async save() {
+    const commentContent = this._markupNewContent();
+    const sendEmail = this._sendEmail;
+    const keptAttachments = Array.from(this._keptAttachmentIds);
+    const message = {
+      issueRef: this.issueRef,
+      isDescription: true,
+      commentContent,
+      keptAttachments,
+      sendEmail,
+    };
+
+    try {
+      const uploader = this.shadowRoot.querySelector('mr-upload');
+      const uploads = await uploader.loadFiles();
+      if (uploads && uploads.length) {
+        message.uploads = uploads;
+      }
+
+      if (!this.fieldName) {
+        store.dispatch(issueV0.update(message));
+      } else {
+        // This is editing an approval if there is no field name.
+        message.fieldRef = {
+          type: fieldTypes.APPROVAL_TYPE,
+          fieldName: this.fieldName,
+        };
+        store.dispatch(issueV0.updateApproval(message));
+      }
+      this.shadowRoot.querySelector('chops-dialog').close();
+    } catch (e) {
+      this._attachmentError = `Error while loading file for attachment: ${
+        e.message}`;
+    }
+  }
+
+  /**
+   * Getter for checking if the user has Markdown enabled.
+   * @return {boolean} Whether Markdown preview should be rendered or not.
+   */
+   get _renderMarkdown() {
+    const enabled = this._prefs.get('render_markdown');
+    return shouldRenderMarkdown({project: this.projectName, enabled});
+  }
+
+  /**
+   * Event handler for keeping <mr-edit-description>'s copy of
+   * _editedDescription in sync.
+   * @param {Event} e
+   */
+  _setEditedDescription(e) {
+    const target = e.target;
+    this._editedDescription = target.value;
+  }
+
+  /**
+   * Event handler for keeping attachment state in sync.
+   * @param {Event} e
+   */
+  _keptAttachmentIdsChanged(e) {
+    e.target.checked = e.detail.checked;
+    const attachmentId = Number.parseInt(e.target.dataset.attachmentId);
+    if (e.target.checked) {
+      this._keptAttachmentIds.add(attachmentId);
+    } else {
+      this._keptAttachmentIds.delete(attachmentId);
+    }
+  }
+
+  _initializeView(commentsByApproval, fieldName) {
+    this._title = fieldName ? `${fieldName} Survey` : 'Description';
+    const key = fieldName || '';
+    if (!commentsByApproval || !commentsByApproval.has(key)) return;
+    const comments = commentListToDescriptionList(commentsByApproval.get(key));
+
+    const comment = comments[comments.length - 1];
+
+    if (comment.attachments) {
+      this._keptAttachmentIds = new Set(comment.attachments.map(
+          (attachment) => Number.parseInt(attachment.attachmentId)));
+      this._attachments = comment.attachments;
+    }
+
+    this._processRawContent(comment.content);
+  }
+
+  _processRawContent(content) {
+    const chunks = content.trim().split(/(<b>[^<\n]+<\/b>)/m);
+    const boldLines = [];
+    let cleanContent = '';
+    chunks.forEach((chunk) => {
+      if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
+        const cleanChunk = chunk.slice(3, -4).trim();
+        cleanContent += cleanChunk;
+        // Don't add whitespace to boldLines.
+        if (/\S/.test(cleanChunk)) {
+          boldLines.push(cleanChunk);
+        }
+      } else {
+        cleanContent += chunk;
+      }
+    });
+
+    this._boldLines = boldLines;
+    this._editedDescription = cleanContent;
+  }
+
+  _markupNewContent() {
+    const lines = this._editedDescription.trim().split('\n');
+    const markedLines = lines.map((line) => {
+      let markedLine = line;
+      const matchingBoldLine = this._boldLines.find(
+          (boldLine) => (line.startsWith(boldLine)));
+      if (matchingBoldLine) {
+        markedLine =
+          `<b>${matchingBoldLine}</b>${line.slice(matchingBoldLine.length)}`;
+      }
+      return markedLine;
+    });
+    return markedLines.join('\n');
+  }
+
+  /**
+   * Event handler for keeping email state in sync.
+   * @param {Event} e
+   */
+  _setSendEmail(e) {
+    this._sendEmail = e.detail.checked;
+  }
+}
+
+customElements.define('mr-edit-description', MrEditDescription);
diff --git a/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js
new file mode 100644
index 0000000..e3fe9d2
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js
@@ -0,0 +1,136 @@
+// 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 {MrEditDescription} from './mr-edit-description.js';
+
+let element;
+
+describe('mr-edit-description', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-edit-description');
+
+    document.body.appendChild(element);
+    element.commentsByApproval = new Map([
+      ['', [
+        {
+          descriptionNum: 1,
+          content: 'first description',
+        },
+        {
+          content: 'first comment',
+        },
+        {
+          descriptionNum: 2,
+          content: '<b>last</b> description',
+        },
+        {
+          content: 'second comment',
+        },
+        {
+          content: 'third comment',
+        },
+      ]], ['foo', [
+        {
+          descriptionNum: 1,
+          content: 'first foo survey',
+          approvalRef: {
+            fieldName: 'foo',
+          },
+        },
+        {
+          descriptionNum: 2,
+          content: 'last foo survey',
+          approvalRef: {
+            fieldName: 'foo',
+          },
+        },
+      ]], ['bar', [
+        {
+          descriptionNum: 1,
+          content: 'bar survey',
+          approvalRef: {
+            fieldName: 'bar',
+          },
+        },
+      ]],
+    ]);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrEditDescription);
+  });
+
+  it('selects last issue description', async () => {
+    element.fieldName = '';
+    element.reset();
+
+    await element.updateComplete;
+
+    assert.equal(element._editedDescription, 'last description');
+    assert.equal(element._title, 'Description');
+  });
+
+  it('selects last survey', async () => {
+    element.fieldName = 'foo';
+    element.reset();
+
+    await element.updateComplete;
+
+    assert.equal(element._editedDescription, 'last foo survey');
+    assert.equal(element._title, 'foo Survey');
+  });
+
+  it('toggle sendEmail', async () => {
+    element.reset();
+    await element.updateComplete;
+
+    const sendEmail = element.shadowRoot.querySelector('#sendEmail');
+
+    await sendEmail.updateComplete;
+
+    sendEmail.click();
+    await element.updateComplete;
+    assert.isFalse(element._sendEmail);
+
+    sendEmail.click();
+    await element.updateComplete;
+    assert.isTrue(element._sendEmail);
+
+    sendEmail.click();
+    await element.updateComplete;
+    assert.isFalse(element._sendEmail);
+  });
+
+  it('renders valid markdown description with preview class', async () => {
+    element.projectName = 'monkeyrail';
+    element._prefs = new Map([['render_markdown', true]]);
+    element.reset();
+
+    element._editedDescription = '# h1';
+
+    await element.updateComplete;
+
+    const previewMarkdown = element.shadowRoot.querySelector('.markdown-preview');
+    assert.isNotNull(previewMarkdown);
+
+    const headerText = previewMarkdown.querySelector('h1').textContent;
+    assert.equal(headerText, 'h1');
+  });
+
+  it('does not show preview when markdown is disabled', async () => {
+    element.projectName = 'disabled_project';
+    element._prefs = new Map([['render_markdown', true]]);
+    element.reset();
+
+    await element.updateComplete;
+
+    const previewMarkdown = element.shadowRoot.querySelector('.markdown-preview');
+    assert.isNull(previewMarkdown);
+  });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js
new file mode 100644
index 0000000..e97f203
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js
@@ -0,0 +1,129 @@
+// 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 {LitElement, html, css} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import 'elements/framework/mr-autocomplete/mr-autocomplete.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+export class MrMoveCopyIssue extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        .target-project-dialog {
+          display: block;
+          font-size: var(--chops-main-font-size);
+        }
+        .error {
+          max-width: 100%;
+          color: red;
+          margin-bottom: 1em;
+        }
+        .edit-actions {
+          width: 100%;
+          margin: 0.5em 0;
+          text-align: right;
+        }
+        input {
+          box-sizing: border-box;
+          width: 95%;
+          padding: 0.25em 4px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <chops-dialog closeOnOutsideClick>
+        <div class="target-project-dialog">
+          <h3 class="medium-heading">${this._action} issue</h3>
+          <div class="input-grid">
+            <label for="targetProjectInput">Target project:</label>
+            <div>
+              <input id="targetProjectInput" />
+              <mr-autocomplete
+                vocabularyName="project"
+                for="targetProjectInput"
+              ></mr-autocomplete>
+            </div>
+          </div>
+
+          ${this._targetProjectError ? html`
+            <div class="error">
+              ${this._targetProjectError}
+            </div>
+          ` : ''}
+
+          <div class="edit-actions">
+            <chops-button @click=${this.cancel} class="de-emphasized">
+              Cancel
+            </chops-button>
+            <chops-button @click=${this.save} class="emphasized">
+              ${this._action} issue
+            </chops-button>
+          </div>
+        </div>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issueRef: {type: Object},
+      _action: {type: String},
+      _targetProjectError: {type: String},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issueRef = issueV0.viewedIssueRef(state);
+  }
+
+  open(e) {
+    this.shadowRoot.querySelector('chops-dialog').open();
+    this._action = e.detail.action;
+    this.reset();
+  }
+
+  reset() {
+    this.shadowRoot.querySelector('#targetProjectInput').value = '';
+    this._targetProjectError = '';
+  }
+
+  cancel() {
+    this.shadowRoot.querySelector('chops-dialog').close();
+  }
+
+  save() {
+    const method = this._action + 'Issue';
+    prpcClient.call('monorail.Issues', method, {
+      issueRef: this.issueRef,
+      targetProjectName: this.shadowRoot.querySelector(
+          '#targetProjectInput').value,
+    }).then((response) => {
+      const projectName = response.newIssueRef.projectName;
+      const localId = response.newIssueRef.localId;
+      page(`/p/${projectName}/issues/detail?id=${localId}`);
+      this.cancel();
+    }, (error) => {
+      this._targetProjectError = error;
+    });
+  }
+}
+
+customElements.define('mr-move-copy-issue', MrMoveCopyIssue);
diff --git a/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js
new file mode 100644
index 0000000..5fdfb39
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js
@@ -0,0 +1,23 @@
+// 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 {MrMoveCopyIssue} from './mr-move-copy-issue.js';
+
+let element;
+
+describe('mr-move-copy-issue', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-move-copy-issue');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrMoveCopyIssue);
+  });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js
new file mode 100644
index 0000000..e859bef
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js
@@ -0,0 +1,316 @@
+// 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 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {ISSUE_EDIT_PERMISSION} from 'shared/consts/permissions';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/**
+ * `<mr-related-issues>`
+ *
+ * Component for showing a mini list view of blocking issues to users.
+ */
+export class MrRelatedIssues extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+          font-size: var(--chops-main-font-size);
+        }
+        table {
+          word-wrap: break-word;
+          width: 100%;
+        }
+        tr {
+          font-weight: normal;
+          text-align: left;
+          margin: 0 auto;
+          padding: 2em 1em;
+          height: 20px;
+        }
+        td {
+          background: #f8f8f8;
+          padding: 4px;
+          padding-left: 8px;
+          text-overflow: ellipsis;
+        }
+        th {
+          text-decoration: none;
+          margin-right: 0;
+          padding-right: 0;
+          padding-left: 8px;
+          white-space: nowrap;
+          background: #e3e9ff;
+          text-align: left;
+          border-right: 1px solid #fff;
+          border-top: 1px solid #fff;
+        }
+        tr.dragged td {
+          background: #eee;
+        }
+        h3.medium-heading {
+          display: flex;
+          justify-content: space-between;
+          align-items: flex-end;
+        }
+        button {
+          background: none;
+          border: none;
+          color: inherit;
+          cursor: pointer;
+          margin: 0;
+          padding: 0;
+        }
+        i.material-icons {
+          font-size: var(--chops-icon-font-size);
+          color: var(--chops-primary-icon-color);
+        }
+        .draggable {
+          cursor: grab;
+        }
+        .error {
+          max-width: 100%;
+          color: red;
+          margin-bottom: 1em;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const rerankEnabled = (this.issuePermissions ||
+      []).includes(ISSUE_EDIT_PERMISSION);
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <chops-dialog closeOnOutsideClick>
+        <h3 class="medium-heading">
+          <span>Blocked on issues</span>
+          <button aria-label="close" @click=${this.close}>
+            <i class="material-icons">close</i>
+          </button>
+        </h3>
+        ${this.error ? html`
+          <div class="error">${this.error}</div>
+        ` : ''}
+        <table><tbody>
+          <tr>
+            ${rerankEnabled ? html`<th></th>` : ''}
+            ${this.columns.map((column) => html`
+              <th>${column}</th>
+            `)}
+          </tr>
+
+          ${this._renderedRows.map((row, index) => html`
+            <tr
+              class=${index === this.srcIndex ? 'dragged' : ''}
+              draggable=${rerankEnabled && row.draggable}
+              data-index=${index}
+              @dragstart=${this._dragstart}
+              @dragend=${this._dragend}
+              @dragover=${this._dragover}
+              @drop=${this._dragdrop}
+            >
+              ${rerankEnabled ? html`
+                <td>
+                  ${rerankEnabled && row.draggable ? html`
+                    <i class="material-icons draggable">drag_indicator</i>
+                  ` : ''}
+                </td>
+              ` : ''}
+
+              ${row.cells.map((cell) => html`
+                <td>
+                  ${cell.type === 'issue' ? html`
+                    <mr-issue-link
+                      .projectName=${this.issueRef.projectName}
+                      .issue=${cell.issue}
+                    ></mr-issue-link>
+                  ` : ''}
+                  ${cell.type === 'text' ? cell.content : ''}
+                </td>
+              `)}
+            </tr>
+          `)}
+        </tbody></table>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      columns: {type: Array},
+      error: {type: String},
+      srcIndex: {type: Number},
+      issueRef: {type: Object},
+      issuePermissions: {type: Array},
+      sortedBlockedOn: {type: Array},
+      _renderedRows: {type: Array},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.sortedBlockedOn = issueV0.sortedBlockedOn(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.columns = ['Issue', 'Summary'];
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('sortedBlockedOn')) {
+      this.reset();
+    }
+    super.update(changedProperties);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('issueRef')) {
+      this.close();
+    }
+  }
+
+  get _rows() {
+    const blockedOn = this.sortedBlockedOn;
+    if (!blockedOn) return [];
+    return blockedOn.map((issue) => {
+      const isClosed = issue.statusRef ? !issue.statusRef.meansOpen : false;
+      let summary = issue.summary;
+      if (issue.extIdentifier) {
+        // Some federated references will have summaries.
+        summary = issue.summary || '(not available)';
+      }
+      const row = {
+        // Disallow reranking FedRefs/DanglingIssueRelations.
+        draggable: !isClosed && !issue.extIdentifier,
+        cells: [
+          {
+            type: 'issue',
+            issue: issue,
+            isClosed: Boolean(isClosed),
+          },
+          {
+            type: 'text',
+            content: summary,
+          },
+        ],
+      };
+      return row;
+    });
+  }
+
+  async open() {
+    await this.updateComplete;
+    this.reset();
+    this.shadowRoot.querySelector('chops-dialog').open();
+  }
+
+  close() {
+    this.shadowRoot.querySelector('chops-dialog').close();
+  }
+
+  reset() {
+    this.error = null;
+    this.srcIndex = null;
+    this._renderedRows = this._rows.slice();
+  }
+
+  _dragstart(e) {
+    if (e.currentTarget.draggable) {
+      this.srcIndex = Number(e.currentTarget.dataset.index);
+      e.dataTransfer.setDragImage(new Image(), 0, 0);
+    }
+  }
+
+  _dragover(e) {
+    if (e.currentTarget.draggable && this.srcIndex !== null) {
+      e.preventDefault();
+      const targetIndex = Number(e.currentTarget.dataset.index);
+      this._reorderRows(this.srcIndex, targetIndex);
+      this.srcIndex = targetIndex;
+    }
+  }
+
+  _dragend(e) {
+    if (this.srcIndex !== null) {
+      this.reset();
+    }
+  }
+
+  _dragdrop(e) {
+    if (e.currentTarget.draggable && this.srcIndex !== null) {
+      const src = this._renderedRows[this.srcIndex];
+      if (this.srcIndex > 0) {
+        const target = this._renderedRows[this.srcIndex - 1];
+        const above = false;
+        this._reorderBlockedOn(src, target, above);
+      } else if (this.srcIndex === 0 &&
+                 this._renderedRows[1] && this._renderedRows[1].draggable) {
+        const target = this._renderedRows[1];
+        const above = true;
+        this._reorderBlockedOn(src, target, above);
+      }
+      this.srcIndex = null;
+    }
+  }
+
+  _reorderBlockedOn(srcArg, targetArg, above) {
+    const src = srcArg.cells[0].issue;
+    const target = targetArg.cells[0].issue;
+
+    const reorderRequest = prpcClient.call(
+        'monorail.Issues', 'RerankBlockedOnIssues', {
+          issueRef: this.issueRef,
+          movedRef: {
+            projectName: src.projectName,
+            localId: src.localId,
+          },
+          targetRef: {
+            projectName: target.projectName,
+            localId: target.localId,
+          },
+          splitAbove: above,
+        });
+
+    reorderRequest.then((response) => {
+      store.dispatch(issueV0.fetch(this.issueRef));
+    }, (error) => {
+      this.reset();
+      this.error = error.description;
+    });
+  }
+
+  _reorderRows(srcIndex, toIndex) {
+    if (srcIndex <= toIndex) {
+      this._renderedRows = this._renderedRows.slice(0, srcIndex).concat(
+          this._renderedRows.slice(srcIndex + 1, toIndex + 1),
+          [this._renderedRows[srcIndex]],
+          this._renderedRows.slice(toIndex + 1));
+    } else {
+      this._renderedRows = this._renderedRows.slice(0, toIndex).concat(
+          [this._renderedRows[srcIndex]],
+          this._renderedRows.slice(toIndex, srcIndex),
+          this._renderedRows.slice(srcIndex + 1));
+    }
+  }
+}
+
+customElements.define('mr-related-issues', MrRelatedIssues);
diff --git a/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js
new file mode 100644
index 0000000..69ce7ee
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js
@@ -0,0 +1,191 @@
+// 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 {MrRelatedIssues} from './mr-related-issues.js';
+
+
+let element;
+
+describe('mr-related-issues', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-related-issues');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrRelatedIssues);
+  });
+
+  it('dialog closes when issueRef changes', async () => {
+    element.issueRef = {projectName: 'chromium', localId: 22};
+    await element.updateComplete;
+
+    const dialog = element.shadowRoot.querySelector('chops-dialog');
+
+    element.open();
+    await element.updateComplete;
+
+    assert.isTrue(dialog.opened);
+
+    element.issueRef = {projectName: 'chromium', localId: 23};
+    await element.updateComplete;
+
+    assert.isFalse(dialog.opened);
+  });
+
+  it('computes blocked on table rows', () => {
+    element.projectName = 'proj';
+    element.sortedBlockedOn = [
+      {projectName: 'proj', localId: 1, statusRef: {meansOpen: true},
+        summary: 'Issue 1'},
+      {projectName: 'proj', localId: 2, statusRef: {meansOpen: true},
+        summary: 'Issue 2'},
+      {projectName: 'proj', localId: 3,
+        summary: 'Issue 3'},
+      {projectName: 'proj2', localId: 4,
+        summary: 'Issue 4 on another project'},
+      {extIdentifier: 'b/123456', statusRef: {meansOpen: true}},
+      {extIdentifier: 'b/987654', statusRef: {meansOpen: false},
+        summary: 'FedRef with a summary'},
+      {projectName: 'proj', localId: 5, statusRef: {meansOpen: false},
+        summary: 'Issue 5'},
+      {projectName: 'proj2', localId: 6, statusRef: {meansOpen: false},
+        summary: 'Issue 6 on another project'},
+    ];
+    assert.deepEqual(element._rows, [
+      {
+        draggable: true,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj', localId: 1, statusRef: {meansOpen: true},
+              summary: 'Issue 1'},
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: 'Issue 1',
+          },
+        ],
+      },
+      {
+        draggable: true,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj', localId: 2, statusRef: {meansOpen: true},
+              summary: 'Issue 2'},
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: 'Issue 2',
+          },
+        ],
+      },
+      {
+        draggable: true,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj', localId: 3,
+              summary: 'Issue 3'},
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: 'Issue 3',
+          },
+        ],
+      },
+      {
+        draggable: true,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj2', localId: 4,
+              summary: 'Issue 4 on another project'},
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: 'Issue 4 on another project',
+          },
+        ],
+      },
+      {
+        draggable: false,
+        cells: [
+          {
+            type: 'issue',
+            issue: {
+              extIdentifier: 'b/123456',
+              statusRef: {meansOpen: true},
+            },
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: '(not available)',
+          },
+        ],
+      },
+      {
+        draggable: false,
+        cells: [
+          {
+            type: 'issue',
+            issue: {
+              extIdentifier: 'b/987654',
+              statusRef: {meansOpen: false},
+              summary: 'FedRef with a summary',
+            },
+            isClosed: true,
+          },
+          {
+            type: 'text',
+            content: 'FedRef with a summary',
+          },
+        ],
+      },
+      {
+        draggable: false,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj', localId: 5,
+              statusRef: {meansOpen: false},
+              summary: 'Issue 5'},
+            isClosed: true,
+          },
+          {
+            type: 'text',
+            content: 'Issue 5',
+          },
+        ],
+      },
+      {
+        draggable: false,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj2', localId: 6,
+              statusRef: {meansOpen: false},
+              summary: 'Issue 6 on another project'},
+            isClosed: true,
+          },
+          {
+            type: 'text',
+            content: 'Issue 6 on another project',
+          },
+        ],
+      },
+    ]);
+  });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js
new file mode 100644
index 0000000..18bd963
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js
@@ -0,0 +1,288 @@
+// 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} from 'lit-element';
+
+import deepEqual from 'deep-equal';
+import {fieldTypes, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {arrayDifference, equalsIgnoreCase} from 'shared/helpers.js';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+
+import './mr-multi-checkbox.js';
+import 'react/mr-react-autocomplete.tsx';
+
+const AUTOCOMPLETE_INPUT = 'AUTOCOMPLETE_INPUT';
+const CHECKBOX_INPUT = 'CHECKBOX_INPUT';
+const SELECT_INPUT = 'SELECT_INPUT';
+
+/**
+ * `<mr-edit-field>`
+ *
+ * A single edit input for a fieldDef + the values of the field.
+ *
+ */
+export class MrEditField extends LitElement {
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <style>
+        mr-edit-field {
+          display: block;
+        }
+        mr-edit-field[hidden] {
+          display: none;
+        }
+        mr-edit-field input,
+        mr-edit-field select {
+          width: var(--mr-edit-field-width);
+          padding: var(--mr-edit-field-padding);
+        }
+      </style>
+      ${this._renderInput()}
+    `;
+  }
+
+  /**
+   * Renders a single input field.
+   * @return {TemplateResult}
+   */
+  _renderInput() {
+    switch (this._widgetType) {
+      case CHECKBOX_INPUT:
+        return html`
+          <mr-multi-checkbox
+            .options=${this.options}
+            .values=${[...this.values]}
+            @change=${this._changeHandler}
+          ></mr-multi-checkbox>
+        `;
+      case SELECT_INPUT:
+        return html`
+          <select
+            id="${this.label}"
+            class="editSelect"
+            aria-label=${this.name}
+            @change=${this._changeHandler}
+          >
+            <option value="">${EMPTY_FIELD_VALUE}</option>
+            ${this.options.map((option) => html`
+              <option
+                value=${option.optionName}
+                .selected=${this.value === option.optionName}
+              >
+                ${option.optionName}
+                ${option.docstring ? ' = ' + option.docstring : ''}
+              </option>
+            `)}
+          </select>
+        `;
+      case AUTOCOMPLETE_INPUT:
+        return html`
+          <mr-react-autocomplete
+            .label=${this.label}
+            .vocabularyName=${this.acType || ''}
+            .inputType=${this._html5InputType}
+            .fixedValues=${this.derivedValues}
+            .value=${this.multi ? this.values : this.value}
+            .multiple=${this.multi}
+            .onChange=${this._changeHandlerReact.bind(this)}
+          ></mr-react-autocomplete>
+        `;
+      default:
+        return '';
+    }
+  }
+
+
+  /** @override */
+  static get properties() {
+    return {
+      // TODO(zhangtiff): Redesign this a bit so we don't need two separate
+      // ways of specifying "type" for a field. Right now, "type" is mapped to
+      // the Monorail custom field types whereas "acType" includes additional
+      // data types such as components, and labels.
+      // String specifying what kind of autocomplete to add to this field.
+      acType: {type: String},
+      // "type" is based on the various custom field types available in
+      // Monorail.
+      type: {type: String},
+      label: {type: String},
+      multi: {type: Boolean},
+      name: {type: String},
+      // Only used for basic, non-repeated fields.
+      placeholder: {type: String},
+      initialValues: {
+        type: Array,
+        hasChanged(newVal, oldVal) {
+          // Prevent extra recomputations of the same initial value causing
+          // values to be reset.
+          return !deepEqual(newVal, oldVal);
+        },
+      },
+      // The current user-inputted values for a field.
+      values: {type: Array},
+      derivedValues: {type: Array},
+      // For enum fields, the possible options that you have. Each entry is a
+      // label type with an additional optionName field added.
+      options: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.initialValues = [];
+    this.values = [];
+    this.derivedValues = [];
+    this.options = [];
+    this.multi = false;
+
+    this.actType = '';
+    this.placeholder = '';
+    this.type = '';
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('initialValues')) {
+      // Assume we always want to reset the user's input when initial
+      // values change.
+      this.reset();
+    }
+    super.update(changedProperties);
+  }
+
+  /**
+   * @return {string}
+   */
+  get value() {
+    return _getSingleValue(this.values);
+  }
+
+  /**
+   * @return {string}
+   */
+  get _widgetType() {
+    const type = this.type;
+    const multi = this.multi;
+    if (type === fieldTypes.ENUM_TYPE) {
+      if (multi) {
+        return CHECKBOX_INPUT;
+      }
+      return SELECT_INPUT;
+    } else {
+      return AUTOCOMPLETE_INPUT;
+    }
+  }
+
+  /**
+   * @return {string} HTML type for the input.
+   */
+  get _html5InputType() {
+    const type = this.type;
+    if (type === fieldTypes.INT_TYPE) {
+      return 'number';
+    } else if (type === fieldTypes.DATE_TYPE) {
+      return 'date';
+    }
+    return 'text';
+  }
+
+  /**
+   * Reset form values to initial state.
+   */
+  reset() {
+    this.values = _wrapInArray(this.initialValues);
+  }
+
+  /**
+   * Return the values that the user added to this input.
+   * @return {Array<string>}åß
+   */
+  getValuesAdded() {
+    if (!this.values || !this.values.length) return [];
+    return arrayDifference(
+        this.values, this.initialValues, equalsIgnoreCase);
+  }
+
+  /**
+   * Return the values that the userremoved from this input.
+   * @return {Array<string>}
+   */
+  getValuesRemoved() {
+    if (!this.multi && (!this.values || this.values.length > 0)) return [];
+    return arrayDifference(
+        this.initialValues, this.values, equalsIgnoreCase);
+  }
+
+  /**
+   * Syncs form values and fires a change event as the user edits the form.
+   * @param {Event} e
+   * @fires Event#change
+   * @private
+   */
+  _changeHandler(e) {
+    if (e instanceof KeyboardEvent) {
+      if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+    }
+    const input = e.target;
+
+    if (input.getValues) {
+      // <mr-multi-checkbox> support.
+      this.values = input.getValues();
+    } else {
+      // Is a native input element.
+      const value = input.value.trim();
+      this.values = _wrapInArray(value);
+    }
+
+    this.dispatchEvent(new Event('change'));
+  }
+
+  /**
+   * Syncs form values and fires a change event as the user edits the form.
+   * @param {React.SyntheticEvent} _e
+   * @param {string|Array<string>|null} value React autcoomplete form value.
+   * @fires Event#change
+   * @private
+   */
+  _changeHandlerReact(_e, value) {
+    this.values = _wrapInArray(value);
+
+    this.dispatchEvent(new Event('change'));
+  }
+}
+
+/**
+ * Returns the string value for a single field.
+ * @param {Array<string>} arr
+ * @return {string}
+ */
+function _getSingleValue(arr) {
+  return (arr && arr.length) ? arr[0] : '';
+}
+
+/**
+ * Returns the string value for a single field.
+ * @param {Array<string>|string} v
+ * @return {string}
+ */
+function _wrapInArray(v) {
+  if (!v) return [];
+
+  let values = v;
+  if (!Array.isArray(v)) {
+    values = !!v ? [v] : [];
+  }
+  return [...values];
+}
+
+customElements.define('mr-edit-field', MrEditField);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js
new file mode 100644
index 0000000..a718203
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js
@@ -0,0 +1,215 @@
+// 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 userEvent from '@testing-library/user-event';
+
+import {MrEditField} from './mr-edit-field.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+
+import {enterInput} from 'shared/test/helpers.js';
+
+
+let element;
+let input;
+
+xdescribe('mr-edit-field', () => {
+  beforeEach(async () => {
+    element = document.createElement('mr-edit-field');
+    document.body.appendChild(element);
+
+    element.label = 'testInput';
+    await element.updateComplete;
+
+    input = element.querySelector('#testInput');
+  });
+
+  afterEach(async () => {
+    userEvent.clear(input);
+
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrEditField);
+  });
+
+  it('reset input value', async () => {
+    element.initialValues = [];
+    await element.updateComplete;
+
+    enterInput(input, 'jackalope');
+    await element.updateComplete;
+
+    assert.equal(element.value, 'jackalope');
+
+    element.reset();
+    await element.updateComplete;
+
+    assert.equal(element.value, '');
+  });
+
+  it('input updates when initialValues change', async () => {
+    element.initialValues = ['hello'];
+
+    await element.updateComplete;
+
+    assert.equal(element.value, 'hello');
+  });
+
+  it('initial value does not change after value set', async () => {
+    element.initialValues = ['hello'];
+    element.label = 'testInput';
+    await element.updateComplete;
+
+    input = element.querySelector('#testInput');
+
+    enterInput(input, 'world');
+    await element.updateComplete;
+
+    assert.deepEqual(element.initialValues, ['hello']);
+    assert.equal(element.value, 'world');
+  });
+
+  it('value updates when input is updated', async () => {
+    element.initialValues = ['hello'];
+    await element.updateComplete;
+
+    enterInput(input, 'world');
+    await element.updateComplete;
+
+    assert.equal(element.value, 'world');
+  });
+
+  it('initial value does not change after user input', async () => {
+    element.initialValues = ['hello'];
+    await element.updateComplete;
+
+    enterInput(input, 'jackalope');
+    await element.updateComplete;
+
+    assert.deepEqual(element.initialValues, ['hello']);
+    assert.equal(element.value, 'jackalope');
+  });
+
+  it('get value after user input', async () => {
+    element.initialValues = ['hello'];
+    await element.updateComplete;
+
+    enterInput(input, 'jackalope');
+    await element.updateComplete;
+
+    assert.equal(element.value, 'jackalope');
+  });
+
+  it('input value was added', async () => {
+    // Simulate user input.
+    await element.updateComplete;
+
+    enterInput(input, 'jackalope');
+    await element.updateComplete;
+
+    assert.deepEqual(element.getValuesAdded(), ['jackalope']);
+    assert.deepEqual(element.getValuesRemoved(), []);
+  });
+
+  it('input value was removed', async () => {
+    await element.updateComplete;
+
+    element.initialValues = ['hello'];
+    await element.updateComplete;
+
+    enterInput(input, '');
+    await element.updateComplete;
+
+    assert.deepEqual(element.getValuesAdded(), []);
+    assert.deepEqual(element.getValuesRemoved(), ['hello']);
+  });
+
+  it('input value was changed', async () => {
+    element.initialValues = ['hello'];
+    await element.updateComplete;
+
+    enterInput(input, 'world');
+    await element.updateComplete;
+
+    assert.deepEqual(element.getValuesAdded(), ['world']);
+  });
+
+  it('edit select updates value when initialValues change', async () => {
+    element.multi = false;
+    element.type = fieldTypes.ENUM_TYPE;
+
+    element.options = [
+      {optionName: 'hello'},
+      {optionName: 'jackalope'},
+      {optionName: 'text'},
+    ];
+
+    element.initialValues = ['hello'];
+
+    await element.updateComplete;
+
+    assert.equal(element.value, 'hello');
+
+    const select = element.querySelector('select');
+    userEvent.selectOptions(select, 'jackalope');
+
+    // User input should not be overridden by the initialValue variable.
+    assert.equal(element.value, 'jackalope');
+    // Initial values should not change based on user input.
+    assert.deepEqual(element.initialValues, ['hello']);
+
+    element.initialValues = ['text'];
+    await element.updateComplete;
+
+    assert.equal(element.value, 'text');
+
+    element.initialValues = [];
+    await element.updateComplete;
+
+    assert.deepEqual(element.value, '');
+  });
+
+  it('multi enum updates value on reset', async () => {
+    element.multi = true;
+    element.type = fieldTypes.ENUM_TYPE;
+    element.options = [
+      {optionName: 'hello'},
+      {optionName: 'world'},
+      {optionName: 'fake'},
+    ];
+
+    await element.updateComplete;
+
+    element.initialValues = ['hello'];
+    element.reset();
+    await element.updateComplete;
+
+    assert.deepEqual(element.values, ['hello']);
+
+    const checkboxes = element.querySelector('mr-multi-checkbox');
+
+    // User checks all boxes.
+    checkboxes._inputRefs.forEach(
+        (checkbox) => {
+          checkbox.checked = true;
+        },
+    );
+    checkboxes._changeHandler();
+
+    await element.updateComplete;
+
+    // User input should not be overridden by the initialValues variable.
+    assert.deepEqual(element.values, ['hello', 'world', 'fake']);
+    // Initial values should not change based on user input.
+    assert.deepEqual(element.initialValues, ['hello']);
+
+    element.initialValues = ['hello', 'world'];
+    element.reset();
+    await element.updateComplete;
+
+    assert.deepEqual(element.values, ['hello', 'world']);
+  });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js
new file mode 100644
index 0000000..5303c57
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js
@@ -0,0 +1,183 @@
+// 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 {SHARED_STYLES} from 'shared/shared-styles';
+import './mr-edit-field.js';
+
+/**
+ * `<mr-edit-status>`
+ *
+ * Editing form for either an approval or the overall issue.
+ *
+ */
+export class MrEditStatus extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          width: 100%;
+        }
+        select {
+          width: var(--mr-edit-field-width);
+          padding: var(--mr-edit-field-padding);
+        }
+        .grid-input {
+          margin-top: 8px;
+          display: grid;
+          grid-gap: var(--mr-input-grid-gap);
+          grid-template-columns: auto 1fr;
+        }
+        .grid-input[hidden] {
+          display: none;
+        }
+        label {
+          font-weight: bold;
+          word-wrap: break-word;
+          text-align: left;
+        }
+        #mergedIntoInput {
+          width: 160px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <select
+        @change=${this._selectChangeHandler}
+        aria-label="Status"
+        id="statusInput"
+      >
+        ${this._statusesGrouped.map((group) => html`
+          <optgroup label=${group.name} ?hidden=${!group.name}>
+            ${group.statuses.map((item) => html`
+              <option
+                value=${item.status}
+                .selected=${this.status === item.status}
+              >
+                ${item.status}
+                ${item.docstring ? `= ${item.docstring}` : ''}
+              </option>
+            `)}
+          </optgroup>
+
+          ${!group.name ? html`
+            ${group.statuses.map((item) => html`
+              <option
+                value=${item.status}
+                .selected=${this.status === item.status}
+              >
+                ${item.status}
+                ${item.docstring ? `= ${item.docstring}` : ''}
+              </option>
+            `)}
+          ` : ''}
+        `)}
+      </select>
+
+      <div class="grid-input" ?hidden=${!this._showMergedInto}>
+        <label for="mergedIntoInput" id="mergedIntoLabel">Merged into:</label>
+        <input
+          id="mergedIntoInput"
+          value=${this.mergedInto || ''}
+          @change=${this._changeHandler}
+        ></input>
+      </div>`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      initialStatus: {type: String},
+      status: {type: String},
+      statuses: {type: Array},
+      isApproval: {type: Boolean},
+      mergedInto: {type: String},
+    };
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('initialStatus')) {
+      this.status = this.initialStatus;
+    }
+    super.update(changedProperties);
+  }
+
+  get _showMergedInto() {
+    const status = this.status || this.initialStatus;
+    return (status === 'Duplicate');
+  }
+
+  get _statusesGrouped() {
+    const statuses = this.statuses;
+    const isApproval = this.isApproval;
+    if (!statuses) return [];
+    if (isApproval) {
+      return [{statuses: statuses}];
+    }
+    return [
+      {
+        name: 'Open',
+        statuses: statuses.filter((s) => s.meansOpen),
+      },
+      {
+        name: 'Closed',
+        statuses: statuses.filter((s) => !s.meansOpen),
+      },
+    ];
+  }
+
+  async reset() {
+    await this.updateComplete;
+    const mergedIntoInput = this.shadowRoot.querySelector('#mergedIntoInput');
+    if (mergedIntoInput) {
+      mergedIntoInput.value = this.mergedInto || '';
+    }
+    this.status = this.initialStatus;
+  }
+
+  get delta() {
+    const result = {};
+
+    if (this.status !== this.initialStatus) {
+      result['status'] = this.status;
+    }
+
+    if (this._showMergedInto) {
+      const newMergedInto = this.shadowRoot.querySelector(
+          '#mergedIntoInput').value;
+      if (newMergedInto !== this.mergedInto) {
+        result['mergedInto'] = newMergedInto;
+      }
+    } else if (this.initialStatus === 'Duplicate') {
+      result['mergedInto'] = '';
+    }
+
+    return result;
+  }
+
+  _selectChangeHandler(e) {
+    const statusInput = e.target;
+    this.status = statusInput.value;
+    this._changeHandler(e);
+  }
+
+  /**
+   * @param {Event} e
+   * @fires CustomEvent#change
+   * @private
+   */
+  _changeHandler(e) {
+    this.dispatchEvent(new CustomEvent('change'));
+  }
+}
+
+customElements.define('mr-edit-status', MrEditStatus);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.js
new file mode 100644
index 0000000..ffa25e5
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.js
@@ -0,0 +1,83 @@
+// 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 {MrEditStatus} from './mr-edit-status.js';
+
+
+let element;
+
+describe('mr-edit-status', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-edit-status');
+    element.statuses = [
+      {'status': 'New'},
+      {'status': 'Old'},
+      {'status': 'Duplicate'},
+    ];
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrEditStatus);
+  });
+
+  it('delta empty when no changes', () => {
+    assert.deepEqual(element.delta, {});
+  });
+
+  it('change status', async () => {
+    element.initialStatus = 'New';
+
+    await element.updateComplete;
+
+    const statusInput = element.shadowRoot.querySelector('select');
+    statusInput.value = 'Old';
+    statusInput.dispatchEvent(new Event('change'));
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {status: 'Old'});
+  });
+
+  it('mark as duplicate', async () => {
+    element.initialStatus = 'New';
+
+    await element.updateComplete;
+
+    const statusInput = element.shadowRoot.querySelector('select');
+    statusInput.value = 'Duplicate';
+    statusInput.dispatchEvent(new Event('change'));
+
+    await element.updateComplete;
+
+    element.shadowRoot.querySelector('#mergedIntoInput').value = 'proj:123';
+    assert.deepEqual(element.delta, {
+      status: 'Duplicate',
+      mergedInto: 'proj:123',
+    });
+  });
+
+  it('remove mark as duplicate', async () => {
+    element.initialStatus = 'Duplicate';
+    element.mergedInto = 'chromium:1234';
+
+    await element.updateComplete;
+
+    const statusInput = element.shadowRoot.querySelector('select');
+    statusInput.value = 'New';
+    statusInput.dispatchEvent(new Event('change'));
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      status: 'New',
+      mergedInto: '',
+    });
+  });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js
new file mode 100644
index 0000000..881cced
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js
@@ -0,0 +1,96 @@
+// 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';
+
+/**
+ * `<mr-multi-checkbox>`
+ *
+ * A web component for managing values in a set of checkboxes.
+ *
+ */
+export class MrMultiCheckbox extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      input[type="checkbox"] {
+        width: auto;
+        height: auto;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      ${this.options.map((option) => html`
+        <label title=${option.docstring}>
+          <input
+            type="checkbox"
+            name=${this.name}
+            value=${option.optionName}
+            ?checked=${this.values.includes(option.optionName)}
+            @change=${this._changeHandler}
+          />
+          ${option.optionName}
+        </label>
+      `)}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      values: {type: Array},
+      options: {type: Array},
+      _inputRefs: {type: Object},
+    };
+  }
+
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('options')) {
+      this._inputRefs = this.shadowRoot.querySelectorAll('input');
+    }
+
+    if (changedProperties.has('values')) {
+      this.reset();
+    }
+  }
+
+  reset() {
+    this.setValues(this.values);
+  }
+
+  getValues() {
+    if (!this._inputRefs) return;
+    const valueList = [];
+    this._inputRefs.forEach((c) => {
+      if (c.checked) {
+        valueList.push(c.value.trim());
+      }
+    });
+    return valueList;
+  }
+
+  setValues(values) {
+    if (!this._inputRefs) return;
+    this._inputRefs.forEach(
+        (checkbox) => {
+          checkbox.checked = values.includes(checkbox.value);
+        },
+    );
+  }
+
+  /**
+   * @fires CustomEvent#change
+   * @private
+   */
+  _changeHandler() {
+    this.dispatchEvent(new CustomEvent('change'));
+  }
+}
+
+customElements.define('mr-multi-checkbox', MrMultiCheckbox);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.test.js
new file mode 100644
index 0000000..33cce9e
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.test.js
@@ -0,0 +1,23 @@
+// 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 {MrMultiCheckbox} from './mr-multi-checkbox.js';
+
+let element;
+
+describe('mr-multi-checkbox', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-multi-checkbox');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrMultiCheckbox);
+  });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js
new file mode 100644
index 0000000..69ef43f
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js
@@ -0,0 +1,360 @@
+// 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} from 'lit-element';
+import debounce from 'debounce';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as ui from 'reducers/ui.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+import './mr-edit-metadata.js';
+import 'shared/typedef.js';
+
+import ClientLogger from 'monitoring/client-logger.js';
+
+const DEBOUNCED_PRESUBMIT_TIME_OUT = 400;
+
+/**
+ * `<mr-edit-issue>`
+ *
+ * Edit form for a single issue. Wraps <mr-edit-metadata>.
+ *
+ */
+export class MrEditIssue extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    const issue = this.issue || {};
+    let blockedOnRefs = issue.blockedOnIssueRefs || [];
+    if (issue.danglingBlockedOnRefs && issue.danglingBlockedOnRefs.length) {
+      blockedOnRefs = blockedOnRefs.concat(issue.danglingBlockedOnRefs);
+    }
+
+    let blockingRefs = issue.blockingIssueRefs || [];
+    if (issue.danglingBlockingRefs && issue.danglingBlockingRefs.length) {
+      blockingRefs = blockingRefs.concat(issue.danglingBlockingRefs);
+    }
+
+    return html`
+      <h2 id="makechanges" class="medium-heading">
+        <a href="#makechanges">Add a comment and make changes</a>
+      </h2>
+      <mr-edit-metadata
+        formName="Issue Edit"
+        .ownerName=${this._ownerDisplayName(this.issue.ownerRef)}
+        .cc=${issue.ccRefs}
+        .status=${issue.statusRef && issue.statusRef.status}
+        .statuses=${this._availableStatuses(this.projectConfig.statusDefs, this.issue.statusRef)}
+        .summary=${issue.summary}
+        .components=${issue.componentRefs}
+        .fieldDefs=${this._fieldDefs}
+        .fieldValues=${issue.fieldValues}
+        .blockedOn=${blockedOnRefs}
+        .blocking=${blockingRefs}
+        .mergedInto=${issue.mergedIntoIssueRef}
+        .labelNames=${this._labelNames}
+        .derivedLabels=${this._derivedLabels}
+        .error=${this.updateError}
+        ?saving=${this.updatingIssue}
+        @save=${this.save}
+        @discard=${this.reset}
+        @change=${this._onChange}
+      ></mr-edit-metadata>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * All comments, including descriptions.
+       */
+      comments: {
+        type: Array,
+      },
+      /**
+       * The issue being updated.
+       */
+      issue: {
+        type: Object,
+      },
+      /**
+       * The issueRef for the currently viewed issue.
+       */
+      issueRef: {
+        type: Object,
+      },
+      /**
+       * The config of the currently viewed project.
+       */
+      projectConfig: {
+        type: Object,
+      },
+      /**
+       * Whether the issue is currently being updated.
+       */
+      updatingIssue: {
+        type: Boolean,
+      },
+      /**
+       * An error response, if one exists.
+       */
+      updateError: {
+        type: String,
+      },
+      /**
+       * Hash from the URL, used to support the 'r' hot key for making changes.
+       */
+      focusId: {
+        type: String,
+      },
+      _fieldDefs: {
+        type: Array,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.clientLogger = new ClientLogger('issues');
+    this.updateError = '';
+
+    this.presubmitDebounceTimeOut = DEBOUNCED_PRESUBMIT_TIME_OUT;
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    // Prevent debounced logic from running after the component has been
+    // removed from the UI.
+    if (this._debouncedPresubmit) {
+      this._debouncedPresubmit.clear();
+    }
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issue = issueV0.viewedIssue(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.comments = issueV0.comments(state);
+    this.projectConfig = projectV0.viewedConfig(state);
+    this.updatingIssue = issueV0.requests(state).update.requesting;
+
+    const error = issueV0.requests(state).update.error;
+    this.updateError = error && (error.description || error.message);
+    this.focusId = ui.focusId(state);
+    this._fieldDefs = issueV0.fieldDefs(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (this.focusId && changedProperties.has('focusId')) {
+      // TODO(zhangtiff): Generalize logic to focus elements based on ID
+      // to a reuseable class mixin.
+      if (this.focusId.toLowerCase() === 'makechanges') {
+        this.focus();
+      }
+    }
+
+    if (changedProperties.has('updatingIssue')) {
+      const isUpdating = this.updatingIssue;
+      const wasUpdating = changedProperties.get('updatingIssue');
+
+      // When an issue finishes updating, we want to show a snackbar, record
+      // issue update time metrics, and reset the edit form.
+      if (!isUpdating && wasUpdating) {
+        if (!this.updateError) {
+          this._showCommentAddedSnackbar();
+          // Reset the edit form when a user's action finishes.
+          this.reset();
+        }
+
+        // Record metrics on when the issue editing event finished.
+        if (this.clientLogger.started('issue-update')) {
+          this.clientLogger.logEnd('issue-update', 'computer-time', 120 * 1000);
+        }
+      }
+    }
+  }
+
+  // TODO(crbug.com/monorail/6933): Remove the need for this wrapper.
+  /**
+   * Snows a snackbar telling the user they added a comment to the issue.
+   */
+  _showCommentAddedSnackbar() {
+    store.dispatch(ui.showSnackbar(ui.snackbarNames.ISSUE_COMMENT_ADDED,
+        'Your comment was added.'));
+  }
+
+  /**
+   * Resets all form fields to their initial values.
+   */
+  reset() {
+    const form = this.querySelector('mr-edit-metadata');
+    if (!form) return;
+    form.reset();
+  }
+
+  /**
+   * Dispatches an action to save issue changes on the server.
+   */
+  async save() {
+    const form = this.querySelector('mr-edit-metadata');
+    if (!form) return;
+
+    const delta = form.delta;
+    if (!allowRemovedRestrictions(delta.labelRefsRemove)) {
+      return;
+    }
+
+    const message = {
+      issueRef: this.issueRef,
+      delta: delta,
+      commentContent: form.getCommentContent(),
+      sendEmail: form.sendEmail,
+    };
+
+    // Add files to message.
+    const uploads = await form.getAttachments();
+
+    if (uploads && uploads.length) {
+      message.uploads = uploads;
+    }
+
+    if (message.commentContent || message.delta || message.uploads) {
+      this.clientLogger.logStart('issue-update', 'computer-time');
+
+      store.dispatch(issueV0.update(message));
+    }
+  }
+
+  /**
+   * Focuses the edit form in response to the 'r' hotkey.
+   */
+  focus() {
+    const editHeader = this.querySelector('#makechanges');
+    editHeader.scrollIntoView();
+
+    const editForm = this.querySelector('mr-edit-metadata');
+    editForm.focus();
+  }
+
+  /**
+   * Turns all LabelRef Objects attached to an issue into an Array of strings
+   * containing only the names of those labels that aren't derived.
+   * @return {Array<string>} Array of label names.
+   */
+  get _labelNames() {
+    if (!this.issue || !this.issue.labelRefs) return [];
+    const labels = this.issue.labelRefs;
+    return labels.filter((l) => !l.isDerived).map((l) => l.label);
+  }
+
+  /**
+   * Finds only the derived labels attached to an issue and returns only
+   * their names.
+   * @return {Array<string>} Array of label names.
+   */
+  get _derivedLabels() {
+    if (!this.issue || !this.issue.labelRefs) return [];
+    const labels = this.issue.labelRefs;
+    return labels.filter((l) => l.isDerived).map((l) => l.label);
+  }
+
+  /**
+   * Gets the displayName of the owner. Only uses the displayName if a
+   * userId also exists in the ref.
+   * @param {UserRef} ownerRef The owner of the issue.
+   * @return {string} The name of the owner for the edited issue.
+   */
+  _ownerDisplayName(ownerRef) {
+    return (ownerRef && ownerRef.userId) ? ownerRef.displayName : '';
+  }
+
+  /**
+   * Dispatches an action against the server to run "issue presubmit", a feature
+   * that warns the user about issue changes that violate configured rules.
+   * @param {Object=} issueDelta Changes currently present in the edit form.
+   * @param {string} commentContent Text the user is inputting for a comment.
+   */
+  _presubmitIssue(issueDelta = {}, commentContent) {
+    // Don't run this functionality if the element has disconnected. Important
+    // for preventing debounced code from running after an element no longer
+    // exists.
+    if (!this.isConnected) return;
+
+    if (Object.keys(issueDelta).length || commentContent) {
+      // TODO(crbug.com/monorail/8638): Make filter rules actually process
+      // the text for comments on the backend.
+      store.dispatch(issueV0.presubmit(this.issueRef, issueDelta));
+    }
+  }
+
+  /**
+   * Form change handler that runs presubmit on the form.
+   * @param {CustomEvent} evt
+   */
+  _onChange(evt) {
+    const {delta, commentContent} = evt.detail || {};
+
+    if (!this._debouncedPresubmit) {
+      this._debouncedPresubmit = debounce(
+          (delta, commentContent) => this._presubmitIssue(delta, commentContent),
+          this.presubmitDebounceTimeOut);
+    }
+    this._debouncedPresubmit(delta, commentContent);
+  }
+
+  /**
+   * Creates the list of statuses that the user sees in the status dropdown.
+   * @param {Array<StatusDef>} statusDefsArg The project configured StatusDefs.
+   * @param {StatusRef} currentStatusRef The status that the issue currently
+   *   uses. Note that Monorail supports free text statuses that do not exist in
+   *   a project config. Because of this, currentStatusRef may not exist in
+   *   statusDefsArg.
+   * @return {Array<StatusRef|StatusDef>} Array of statuses a user can edit this
+   *   issue to have.
+   */
+  _availableStatuses(statusDefsArg, currentStatusRef) {
+    let statusDefs = statusDefsArg || [];
+    statusDefs = statusDefs.filter((status) => !status.deprecated);
+    if (!currentStatusRef || statusDefs.find(
+        (status) => status.status === currentStatusRef.status)) {
+      return statusDefs;
+    }
+    return [currentStatusRef, ...statusDefs];
+  }
+}
+
+/**
+ * Asks the user for confirmation when they try to remove retriction labels.
+ * eg. Restrict-View-Google.
+ * @param {Array<LabelRef>} labelRefsRemoved The labels a user is removing
+ *   from this issue.
+ * @return {boolean} Whether removing these labels is okay. ie: true if there
+ *   are either no restrictions being removed or if the user approved the
+ *   removal of the restrictions.
+ */
+export function allowRemovedRestrictions(labelRefsRemoved) {
+  if (!labelRefsRemoved) return true;
+  const removedRestrictions = labelRefsRemoved
+      .map(({label}) => label)
+      .filter((label) => label.toLowerCase().startsWith('restrict-'));
+  const removeRestrictionsMessage =
+    'You are removing these restrictions:\n' +
+    arrayToEnglish(removedRestrictions) + '\n' +
+    'This might allow more people to access this issue. Are you sure?';
+  return !removedRestrictions.length || confirm(removeRestrictionsMessage);
+}
+
+customElements.define('mr-edit-issue', MrEditIssue);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js
new file mode 100644
index 0000000..a3216ca
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js
@@ -0,0 +1,298 @@
+// 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 {MrEditIssue, allowRemovedRestrictions} from './mr-edit-issue.js';
+import {clientLoggerFake} from 'shared/test/fakes.js';
+
+let element;
+let clock;
+
+describe('mr-edit-issue', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-edit-issue');
+    document.body.appendChild(element);
+    sinon.stub(prpcClient, 'call');
+
+    element.clientLogger = clientLoggerFake();
+    clock = sinon.useFakeTimers();
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+
+    clock.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrEditIssue);
+  });
+
+  it('scrolls into view on #makechanges hash', async () => {
+    await element.updateComplete;
+
+    const header = element.querySelector('#makechanges');
+    sinon.stub(header, 'scrollIntoView');
+
+    element.focusId = 'makechanges';
+    await element.updateComplete;
+
+    assert.isTrue(header.scrollIntoView.calledOnce);
+
+    header.scrollIntoView.restore();
+  });
+
+  it('shows snackbar and resets form when editing finishes', async () => {
+    sinon.stub(element, 'reset');
+    sinon.stub(element, '_showCommentAddedSnackbar');
+
+    element.updatingIssue = true;
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element._showCommentAddedSnackbar);
+    sinon.assert.notCalled(element.reset);
+
+    element.updatingIssue = false;
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element._showCommentAddedSnackbar);
+    sinon.assert.calledOnce(element.reset);
+  });
+
+  it('does not show snackbar or reset form on edit error', async () => {
+    sinon.stub(element, 'reset');
+    sinon.stub(element, '_showCommentAddedSnackbar');
+
+    element.updatingIssue = true;
+    await element.updateComplete;
+
+    element.updateError = 'The save failed';
+    element.updatingIssue = false;
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element._showCommentAddedSnackbar);
+    sinon.assert.notCalled(element.reset);
+  });
+
+  it('shows current status even if not defined for project', async () => {
+    await element.updateComplete;
+
+    const editMetadata = element.querySelector('mr-edit-metadata');
+    assert.deepEqual(editMetadata.statuses, []);
+
+    element.projectConfig = {statusDefs: [
+      {status: 'hello'},
+      {status: 'world'},
+    ]};
+
+    await editMetadata.updateComplete;
+
+    assert.deepEqual(editMetadata.statuses, [
+      {status: 'hello'},
+      {status: 'world'},
+    ]);
+
+    element.issue = {
+      statusRef: {status: 'hello'},
+    };
+
+    await editMetadata.updateComplete;
+
+    assert.deepEqual(editMetadata.statuses, [
+      {status: 'hello'},
+      {status: 'world'},
+    ]);
+
+    element.issue = {
+      statusRef: {status: 'weirdStatus'},
+    };
+
+    await editMetadata.updateComplete;
+
+    assert.deepEqual(editMetadata.statuses, [
+      {status: 'weirdStatus'},
+      {status: 'hello'},
+      {status: 'world'},
+    ]);
+  });
+
+  it('ignores deprecated statuses, unless used on current issue', async () => {
+    await element.updateComplete;
+
+    const editMetadata = element.querySelector('mr-edit-metadata');
+    assert.deepEqual(editMetadata.statuses, []);
+
+    element.projectConfig = {statusDefs: [
+      {status: 'new'},
+      {status: 'accepted', deprecated: false},
+      {status: 'compiling', deprecated: true},
+    ]};
+
+    await editMetadata.updateComplete;
+
+    assert.deepEqual(editMetadata.statuses, [
+      {status: 'new'},
+      {status: 'accepted', deprecated: false},
+    ]);
+
+
+    element.issue = {
+      statusRef: {status: 'compiling'},
+    };
+
+    await editMetadata.updateComplete;
+
+    assert.deepEqual(editMetadata.statuses, [
+      {status: 'compiling'},
+      {status: 'new'},
+      {status: 'accepted', deprecated: false},
+    ]);
+  });
+
+  it('filter out empty or deleted user owners', () => {
+    assert.equal(
+        element._ownerDisplayName({displayName: 'a_deleted_user'}),
+        '');
+    assert.equal(
+        element._ownerDisplayName({
+          displayName: 'test@example.com',
+          userId: '1234',
+        }),
+        'test@example.com');
+  });
+
+  it('logs issue-update metrics', async () => {
+    await element.updateComplete;
+
+    const editMetadata = element.querySelector('mr-edit-metadata');
+
+    sinon.stub(editMetadata, 'delta').get(() => ({summary: 'test'}));
+
+    await element.save();
+
+    sinon.assert.calledOnce(element.clientLogger.logStart);
+    sinon.assert.calledWith(element.clientLogger.logStart,
+        'issue-update', 'computer-time');
+
+    // Simulate a response updating the UI.
+    element.issue = {summary: 'test'};
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.clientLogger.logEnd);
+    sinon.assert.calledWith(element.clientLogger.logEnd,
+        'issue-update', 'computer-time', 120 * 1000);
+  });
+
+  it('presubmits issue on metadata change', async () => {
+    element.issueRef = {};
+
+    await element.updateComplete;
+    const editMetadata = element.querySelector('mr-edit-metadata');
+    editMetadata.dispatchEvent(new CustomEvent('change', {
+      detail: {
+        delta: {
+          summary: 'Summary',
+        },
+      },
+    }));
+
+    // Wait for debouncer.
+    clock.tick(element.presubmitDebounceTimeOut + 1);
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+        'PresubmitIssue',
+        {issueDelta: {summary: 'Summary'}, issueRef: {}});
+  });
+
+  it('presubmits issue on comment change', async () => {
+    element.issueRef = {};
+
+    await element.updateComplete;
+    const editMetadata = element.querySelector('mr-edit-metadata');
+    editMetadata.dispatchEvent(new CustomEvent('change', {
+      detail: {
+        delta: {},
+        commentContent: 'test',
+      },
+    }));
+
+    // Wait for debouncer.
+    clock.tick(element.presubmitDebounceTimeOut + 1);
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+        'PresubmitIssue',
+        {issueDelta: {}, issueRef: {}});
+  });
+
+
+  it('does not presubmit issue when no changes', () => {
+    element._presubmitIssue({});
+
+    sinon.assert.notCalled(prpcClient.call);
+  });
+
+  it('editing form runs _presubmitIssue debounced', async () => {
+    sinon.stub(element, '_presubmitIssue');
+
+    await element.updateComplete;
+
+    // User makes some changes.
+    const comment = element.querySelector('#commentText');
+    comment.value = 'Value';
+    comment.dispatchEvent(new Event('keyup'));
+
+    clock.tick(5);
+
+    // User makes more changes before debouncer timeout is done.
+    comment.value = 'more changes';
+    comment.dispatchEvent(new Event('keyup'));
+
+    clock.tick(10);
+
+    sinon.assert.notCalled(element._presubmitIssue);
+
+    // Wait for debouncer.
+    clock.tick(element.presubmitDebounceTimeOut + 1);
+
+    sinon.assert.calledOnce(element._presubmitIssue);
+  });
+});
+
+describe('allowRemovedRestrictions', () => {
+  beforeEach(() => {
+    sinon.stub(window, 'confirm');
+  });
+
+  afterEach(() => {
+    window.confirm.restore();
+  });
+
+  it('returns true if no restrictions removed', () => {
+    assert.isTrue(allowRemovedRestrictions([
+      {label: 'not-restricted'},
+      {label: 'fine'},
+    ]));
+  });
+
+  it('returns false if restrictions removed and confirmation denied', () => {
+    window.confirm.returns(false);
+    assert.isFalse(allowRemovedRestrictions([
+      {label: 'not-restricted'},
+      {label: 'restrict-view-people'},
+    ]));
+  });
+
+  it('returns true if restrictions removed and confirmation accepted', () => {
+    window.confirm.returns(true);
+    assert.isTrue(allowRemovedRestrictions([
+      {label: 'not-restricted'},
+      {label: 'restrict-view-people'},
+    ]));
+  });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js
new file mode 100644
index 0000000..804c8d1
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js
@@ -0,0 +1,1188 @@
+// 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} from 'lit-element';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/framework/mr-upload/mr-upload.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import 'elements/chops/chops-checkbox/chops-checkbox.js';
+import 'elements/chops/chops-chip/chops-chip.js';
+import 'elements/framework/mr-error/mr-error.js';
+import 'elements/framework/mr-warning/mr-warning.js';
+import 'elements/help/mr-cue/mr-cue.js';
+import 'react/mr-react-autocomplete.tsx';
+import {cueNames} from 'elements/help/mr-cue/cue-helpers.js';
+import {store, connectStore} from 'reducers/base.js';
+import {UserInputError} from 'shared/errors.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import {displayNameToUserRef, labelStringToRef, componentStringToRef,
+  componentRefsToStrings, issueStringToRef, issueStringToBlockingRef,
+  issueRefToString, issueRefsToStrings, filteredUserDisplayNames,
+  valueToFieldValue, fieldDefToName,
+} from 'shared/convertersV0.js';
+import {arrayDifference, isEmptyObject, equalsIgnoreCase} from 'shared/helpers.js';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as permissions from 'reducers/permissions.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import '../mr-edit-field/mr-edit-field.js';
+import '../mr-edit-field/mr-edit-status.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_EDIT_SUMMARY_PERMISSION,
+  ISSUE_EDIT_STATUS_PERMISSION, ISSUE_EDIT_OWNER_PERMISSION,
+  ISSUE_EDIT_CC_PERMISSION,
+} from 'shared/consts/permissions.js';
+import {fieldDefsWithGroup, fieldDefsWithoutGroup, valuesForField,
+  HARDCODED_FIELD_GROUPS} from 'shared/metadata-helpers.js';
+import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+import {MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+
+
+
+/**
+ * `<mr-edit-metadata>`
+ *
+ * Editing form for either an approval or the overall issue.
+ *
+ */
+export class MrEditMetadata extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <style>
+        ${MD_PREVIEW_STYLES}
+        ${MD_STYLES}
+        mr-edit-metadata {
+          display: block;
+          font-size: var(--chops-main-font-size);
+        }
+        mr-edit-metadata.edit-actions-right .edit-actions {
+          flex-direction: row-reverse;
+          text-align: right;
+        }
+        mr-edit-metadata.edit-actions-right .edit-actions chops-checkbox {
+          text-align: left;
+        }
+        .edit-actions chops-checkbox {
+          max-width: 200px;
+          margin-top: 2px;
+          flex-grow: 2;
+          text-align: right;
+        }
+        .edit-actions {
+          width: 100%;
+          max-width: 500px;
+          margin: 0.5em 0;
+          text-align: left;
+          display: flex;
+          flex-direction: row;
+          align-items: center;
+        }
+        .edit-actions chops-button {
+          flex-grow: 0;
+          flex-shrink: 0;
+        }
+        .edit-actions .emphasized {
+          margin-left: 0;
+        }
+        input {
+          box-sizing: border-box;
+          width: var(--mr-edit-field-width);
+          padding: var(--mr-edit-field-padding);
+          font-size: var(--chops-main-font-size);
+        }
+        mr-upload {
+          margin-bottom: 0.25em;
+        }
+        textarea {
+          font-family: var(--mr-toggled-font-family);
+          width: 100%;
+          margin: 0.25em 0;
+          box-sizing: border-box;
+          border: var(--chops-accessible-border);
+          height: 8em;
+          transition: height 0.1s ease-in-out;
+          padding: 0.5em 4px;
+          grid-column-start: 1;
+          grid-column-end: 2;
+        }
+        button.toggle {
+          background: none;
+          color: var(--chops-link-color);
+          border: 0;
+          width: 100%;
+          padding: 0.25em 0;
+          text-align: left;
+        }
+        button.toggle:hover {
+          cursor: pointer;
+          text-decoration: underline;
+        }
+        .presubmit-derived {
+          color: gray;
+          font-style: italic;
+          text-decoration-line: underline;
+          text-decoration-style: dotted;
+        }
+        .presubmit-derived-header {
+          color: gray;
+          font-weight: bold;
+        }
+        .discard-button {
+          margin-right: 16px;
+          margin-left: 16px;
+        }
+        .group {
+          width: 100%;
+          border: 1px solid hsl(0, 0%, 83%);
+          grid-column: 1 / -1;
+          margin: 0;
+          margin-bottom: 0.5em;
+          padding: 0;
+          padding-bottom: 0.5em;
+        }
+        .group legend {
+          margin-left: 130px;
+        }
+        .group-title {
+          text-align: center;
+          font-style: oblique;
+          margin-top: 4px;
+          margin-bottom: -8px;
+        }
+        .star-line {
+          display: flex;
+          align-items: center;
+          background: var(--chops-notice-bubble-bg);
+          border: var(--chops-notice-border);
+          justify-content: flex-start;
+          margin-top: 4px;
+          padding: 2px 4px 2px 8px;
+        }
+        mr-issue-star {
+          margin-right: 4px;
+        }
+      </style>
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <form id="editForm"
+        @submit=${this._save}
+        @keydown=${this._saveOnCtrlEnter}
+      >
+        <mr-cue cuePrefName=${cueNames.CODE_OF_CONDUCT}></mr-cue>
+        ${this._renderStarLine()}
+        <textarea
+          id="commentText"
+          placeholder="Add a comment"
+          @keyup=${this._processChanges}
+          aria-label="Comment"
+        ></textarea>
+        ${(this._renderMarkdown)
+           ? html`
+          <div class="markdown-preview preview-height-comment">
+            <div class="markdown">
+              ${unsafeHTML(renderMarkdown(this.getCommentContent()))}
+            </div>
+          </div>`: ''}
+        <mr-upload
+          ?hidden=${this.disableAttachments}
+          @change=${this._processChanges}
+        ></mr-upload>
+        <div class="input-grid">
+          ${this._renderEditFields()}
+          ${this._renderErrorsAndWarnings()}
+
+          <span></span>
+          <div class="edit-actions">
+            <chops-button
+              @click=${this._save}
+              class="save-changes emphasized"
+              ?disabled=${this.disabled}
+              title="Save changes (Ctrl+Enter / \u2318+Enter)"
+            >
+              Save changes
+            </chops-button>
+            <chops-button
+              @click=${this.discard}
+              class="de-emphasized discard-button"
+              ?disabled=${this.disabled}
+            >
+              Discard
+            </chops-button>
+
+            <chops-checkbox
+              id="sendEmail"
+              @checked-change=${this._sendEmailChecked}
+              ?checked=${this.sendEmail}
+            >Send email</chops-checkbox>
+          </div>
+
+          ${!this.isApproval ? this._renderPresubmitChanges() : ''}
+        </div>
+      </form>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderStarLine() {
+    if (this._canEditIssue || this.isApproval) return '';
+
+    return html`
+      <div class="star-line">
+        <mr-issue-star
+          .issueRef=${this.issueRef}
+        ></mr-issue-star>
+        <span>
+          ${this.isStarred ? `
+            You have voted for this issue and will receive notifications.
+          ` : `
+            Star this issue instead of commenting "+1 Me too!" to add a vote
+            and get notifications.`}
+        </span>
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderPresubmitChanges() {
+    const {derivedCcs, derivedLabels} = this.presubmitResponse || {};
+    const hasCcs = derivedCcs && derivedCcs.length;
+    const hasLabels = derivedLabels && derivedLabels.length;
+    const hasDerivedValues = hasCcs || hasLabels;
+    return html`
+      ${hasDerivedValues ? html`
+        <span></span>
+        <div class="presubmit-derived-header">
+          Filter rules and components will add
+        </div>
+        ` : ''}
+
+      ${hasCcs? html`
+        <label
+          for="derived-ccs"
+          class="presubmit-derived-header"
+        >CC:</label>
+        <div id="derived-ccs">
+          ${derivedCcs.map((cc) => html`
+            <span
+              title=${cc.why}
+              class="presubmit-derived"
+            >${cc.value}</span>
+          `)}
+        </div>
+        ` : ''}
+
+      ${hasLabels ? html`
+        <label
+          for="derived-labels"
+          class="presubmit-derived-header"
+        >Labels:</label>
+        <div id="derived-labels">
+          ${derivedLabels.map((label) => html`
+            <span
+              title=${label.why}
+              class="presubmit-derived"
+            >${label.value}</span>
+          `)}
+        </div>
+        ` : ''}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderErrorsAndWarnings() {
+    const presubmitResponse = this.presubmitResponse || {};
+    const presubmitWarnings = presubmitResponse.warnings || [];
+    const presubmitErrors = presubmitResponse.errors || [];
+    return (this.error || presubmitWarnings.length || presubmitErrors.length) ?
+      html`
+        <span></span>
+        <div>
+          ${presubmitWarnings.map((warning) => html`
+            <mr-warning title=${warning.why}>${warning.value}</mr-warning>
+          `)}
+          <!-- TODO(ehmaldonado): Look into blocking submission on presubmit
+          -->
+          ${presubmitErrors.map((error) => html`
+            <mr-error title=${error.why}>${error.value}</mr-error>
+          `)}
+          ${this.error ? html`
+            <mr-error>${this.error}</mr-error>` : ''}
+        </div>
+      ` : '';
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderEditFields() {
+    if (this.isApproval) {
+      return html`
+        ${this._renderStatus()}
+        ${this._renderApprovers()}
+        ${this._renderFieldDefs()}
+
+        ${this._renderNicheFieldToggle()}
+      `;
+    }
+
+    return html`
+      ${this._canEditSummary ? this._renderSummary() : ''}
+      ${this._canEditStatus ? this._renderStatus() : ''}
+      ${this._canEditOwner ? this._renderOwner() : ''}
+      ${this._canEditCC ? this._renderCC() : ''}
+      ${this._canEditIssue ? html`
+        ${this._renderComponents()}
+
+        ${this._renderFieldDefs()}
+        ${this._renderRelatedIssues()}
+        ${this._renderLabels()}
+
+        ${this._renderNicheFieldToggle()}
+      ` : ''}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderSummary() {
+    return html`
+      <label for="summaryInput">Summary:</label>
+      <input
+        id="summaryInput"
+        value=${this.summary}
+        @keyup=${this._processChanges}
+      />
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderOwner() {
+    const ownerPresubmit = this._ownerPresubmit;
+    return html`
+      <label for="ownerInput">
+        ${ownerPresubmit.message ? html`
+          <i
+            class=${`material-icons inline-${ownerPresubmit.icon}`}
+            title=${ownerPresubmit.message}
+          >${ownerPresubmit.icon}</i>
+        ` : ''}
+        Owner:
+      </label>
+      <mr-react-autocomplete
+        label="ownerInput"
+        vocabularyName="owner"
+        .placeholder=${ownerPresubmit.placeholder}
+        .value=${this._values.owner}
+        .onChange=${this._changeHandlers.owner}
+      ></mr-react-autocomplete>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderCC() {
+    return html`
+      <label for="ccInput">CC:</label>
+      <mr-react-autocomplete
+        label="ccInput"
+        vocabularyName="member"
+        .multiple=${true}
+        .fixedValues=${this._derivedCCs}
+        .value=${this._values.cc}
+        .onChange=${this._changeHandlers.cc}
+      ></mr-react-autocomplete>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderComponents() {
+    return html`
+      <label for="componentsInput">Components:</label>
+      <mr-react-autocomplete
+        label="componentsInput"
+        vocabularyName="component"
+        .multiple=${true}
+        .value=${this._values.components}
+        .onChange=${this._changeHandlers.components}
+      ></mr-react-autocomplete>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderApprovers() {
+    return this.hasApproverPrivileges && this.isApproval ? html`
+      <label for="approversInput_react">Approvers:</label>
+      <mr-edit-field
+        id="approversInput"
+        label="approversInput_react"
+        .type=${'USER_TYPE'}
+        .initialValues=${filteredUserDisplayNames(this.approvers)}
+        .name=${'approver'}
+        .acType=${'member'}
+        @change=${this._processChanges}
+        multi
+      ></mr-edit-field>
+    ` : '';
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderStatus() {
+    return this.statuses && this.statuses.length ? html`
+      <label for="statusInput">Status:</label>
+
+      <mr-edit-status
+        id="statusInput"
+        .initialStatus=${this.status}
+        .statuses=${this.statuses}
+        .mergedInto=${issueRefToString(this.mergedInto, this.projectName)}
+        ?isApproval=${this.isApproval}
+        @change=${this._processChanges}
+      ></mr-edit-status>
+    ` : '';
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderFieldDefs() {
+    return html`
+      ${fieldDefsWithGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((group) => html`
+        <fieldset class="group">
+          <legend>${group.groupName}</legend>
+          <div class="input-grid">
+            ${group.fieldDefs.map((field) => this._renderCustomField(field))}
+          </div>
+        </fieldset>
+      `)}
+
+      ${fieldDefsWithoutGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((field) => this._renderCustomField(field))}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderRelatedIssues() {
+    return html`
+      <label for="blockedOnInput">BlockedOn:</label>
+      <mr-react-autocomplete
+        label="blockedOnInput"
+        vocabularyName="component"
+        .multiple=${true}
+        .value=${this._values.blockedOn}
+        .onChange=${this._changeHandlers.blockedOn}
+      ></mr-react-autocomplete>
+
+      <label for="blockingInput">Blocking:</label>
+      <mr-react-autocomplete
+        label="blockingInput"
+        vocabularyName="component"
+        .multiple=${true}
+        .value=${this._values.blocking}
+        .onChange=${this._changeHandlers.blocking}
+      ></mr-react-autocomplete>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderLabels() {
+    return html`
+      <label for="labelsInput">Labels:</label>
+      <mr-react-autocomplete
+        label="labelsInput"
+        vocabularyName="label"
+        .multiple=${true}
+        .fixedValues=${this.derivedLabels}
+        .value=${this._values.labels}
+        .onChange=${this._changeHandlers.labels}
+      ></mr-react-autocomplete>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @param {FieldDef} field The custom field beinf rendered.
+   * @private
+   */
+  _renderCustomField(field) {
+    if (!field || !field.fieldRef) return '';
+    const userCanEdit = this._userCanEdit(field);
+    const {fieldRef, isNiche, docstring, isMultivalued} = field;
+    const isHidden = (!this.showNicheFields && isNiche) || !userCanEdit;
+
+    let acType;
+    if (fieldRef.type === fieldTypes.USER_TYPE) {
+      acType = isMultivalued ? 'member' : 'owner';
+    }
+    return html`
+      <label
+        ?hidden=${isHidden}
+        for=${this._idForField(fieldRef.fieldName) + '_react'}
+        title=${docstring}
+      >
+        ${fieldRef.fieldName}:
+      </label>
+      <mr-edit-field
+        ?hidden=${isHidden}
+        id=${this._idForField(fieldRef.fieldName)}
+        .label=${this._idForField(fieldRef.fieldName) + '_react'}
+        .name=${fieldRef.fieldName}
+        .type=${fieldRef.type}
+        .options=${this._optionsForField(this.optionsPerEnumField, this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
+        .initialValues=${valuesForField(this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
+        .acType=${acType}
+        ?multi=${isMultivalued}
+        @change=${this._processChanges}
+      ></mr-edit-field>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderNicheFieldToggle() {
+    return this._nicheFieldCount ? html`
+      <span></span>
+      <button type="button" class="toggle" @click=${this.toggleNicheFields}>
+        <span ?hidden=${this.showNicheFields}>
+          Show all fields (${this._nicheFieldCount} currently hidden)
+        </span>
+        <span ?hidden=${!this.showNicheFields}>
+          Hide niche fields (${this._nicheFieldCount} currently shown)
+        </span>
+      </button>
+    ` : '';
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      fieldDefs: {type: Array},
+      formName: {type: String},
+      approvers: {type: Array},
+      setter: {type: Object},
+      summary: {type: String},
+      cc: {type: Array},
+      components: {type: Array},
+      status: {type: String},
+      statuses: {type: Array},
+      blockedOn: {type: Array},
+      blocking: {type: Array},
+      mergedInto: {type: Object},
+      ownerName: {type: String},
+      labelNames: {type: Array},
+      derivedLabels: {type: Array},
+      _permissions: {type: Array},
+      phaseName: {type: String},
+      projectConfig: {type: Object},
+      projectName: {type: String},
+      isApproval: {type: Boolean},
+      isStarred: {type: Boolean},
+      issuePermissions: {type: Object},
+      issueRef: {type: Object},
+      hasApproverPrivileges: {type: Boolean},
+      showNicheFields: {type: Boolean},
+      disableAttachments: {type: Boolean},
+      error: {type: String},
+      sendEmail: {type: Boolean},
+      presubmitResponse: {type: Object},
+      fieldValueMap: {type: Object},
+      issueType: {type: String},
+      optionsPerEnumField: {type: String},
+      fieldGroups: {type: Object},
+      prefs: {type: Object},
+      saving: {type: Boolean},
+      isDirty: {type: Boolean},
+      _values: {type: Object},
+      _initialValues: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.summary = '';
+    this.ownerName = '';
+    this.sendEmail = true;
+    this.mergedInto = {};
+    this.issueRef = {};
+    this.fieldGroups = HARDCODED_FIELD_GROUPS;
+
+    this._permissions = {};
+    this.saving = false;
+    this.isDirty = false;
+    this.prefs = {};
+    this._values = {};
+    this._initialValues = {};
+
+    // Memoize change handlers so property updates don't cause excess rerenders.
+    this._changeHandlers = {
+      owner: this._onChange.bind(this, 'owner'),
+      cc: this._onChange.bind(this, 'cc'),
+      components: this._onChange.bind(this, 'components'),
+      labels: this._onChange.bind(this, 'labels'),
+      blockedOn: this._onChange.bind(this, 'blockedOn'),
+      blocking: this._onChange.bind(this, 'blocking'),
+    };
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  firstUpdated() {
+    this.hasRendered = true;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('ownerName') || changedProperties.has('cc')
+        || changedProperties.has('components')
+        || changedProperties.has('labelNames')
+        || changedProperties.has('blockedOn')
+        || changedProperties.has('blocking')
+        || changedProperties.has('projectName')) {
+      this._initialValues.owner = this.ownerName;
+      this._initialValues.cc = this._ccNames;
+      this._initialValues.components = componentRefsToStrings(this.components);
+      this._initialValues.labels = this.labelNames;
+      this._initialValues.blockedOn = issueRefsToStrings(this.blockedOn, this.projectName);
+      this._initialValues.blocking = issueRefsToStrings(this.blocking, this.projectName);
+
+      this._values = {...this._initialValues};
+    }
+  }
+
+  /**
+   * Getter for checking if the user has Markdown enabled.
+   * @return {boolean} Whether Markdown preview should be rendered or not.
+   */
+  get _renderMarkdown() {
+    if (!this.getCommentContent()) {
+      return false;
+    }
+    const enabled = this.prefs.get('render_markdown');
+    return shouldRenderMarkdown({project: this.projectName, enabled});
+  }
+
+  /**
+   * @return {boolean} Whether the "Save changes" button is disabled.
+   */
+  get disabled() {
+    return !this.isDirty || this.saving;
+  }
+
+  /**
+   * Set isDirty to a property instead of only using a getter to cause
+   * lit-element to re-render when dirty state change.
+   */
+  _updateIsDirty() {
+    if (!this.hasRendered) return;
+
+    const commentContent = this.getCommentContent();
+    const attachmentsElement = this.querySelector('mr-upload');
+    this.isDirty = !isEmptyObject(this.delta) || Boolean(commentContent) ||
+      attachmentsElement.hasAttachments;
+  }
+
+  get _nicheFieldCount() {
+    const fieldDefs = this.fieldDefs || [];
+    return fieldDefs.reduce((acc, fd) => acc + (fd.isNiche | 0), 0);
+  }
+
+  get _canEditIssue() {
+    const issuePermissions = this.issuePermissions || [];
+    return issuePermissions.includes(ISSUE_EDIT_PERMISSION);
+  }
+
+  get _canEditSummary() {
+    const issuePermissions = this.issuePermissions || [];
+    return this._canEditIssue ||
+      issuePermissions.includes(ISSUE_EDIT_SUMMARY_PERMISSION);
+  }
+
+  get _canEditStatus() {
+    const issuePermissions = this.issuePermissions || [];
+    return this._canEditIssue ||
+      issuePermissions.includes(ISSUE_EDIT_STATUS_PERMISSION);
+  }
+
+  get _canEditOwner() {
+    const issuePermissions = this.issuePermissions || [];
+    return this._canEditIssue ||
+      issuePermissions.includes(ISSUE_EDIT_OWNER_PERMISSION);
+  }
+
+  get _canEditCC() {
+    const issuePermissions = this.issuePermissions || [];
+    return this._canEditIssue ||
+      issuePermissions.includes(ISSUE_EDIT_CC_PERMISSION);
+  }
+
+  /**
+   * @return {Array<string>}
+   */
+  get _ccNames() {
+    const users = this.cc || [];
+    return filteredUserDisplayNames(users.filter((u) => !u.isDerived));
+  }
+
+  get _derivedCCs() {
+    const users = this.cc || [];
+    return filteredUserDisplayNames(users.filter((u) => u.isDerived));
+  }
+
+  get _ownerPresubmit() {
+    const response = this.presubmitResponse;
+    if (!response) return {};
+
+    const ownerView = {message: '', placeholder: '', icon: ''};
+
+    if (response.ownerAvailability) {
+      ownerView.message = response.ownerAvailability;
+      ownerView.icon = 'warning';
+    } else if (response.derivedOwners && response.derivedOwners.length) {
+      ownerView.placeholder = response.derivedOwners[0].value;
+      ownerView.message = response.derivedOwners[0].why;
+      ownerView.icon = 'info';
+    }
+    return ownerView;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.fieldValueMap = issueV0.fieldValueMap(state);
+    this.issueType = issueV0.type(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this._permissions = permissions.byName(state);
+    this.presubmitResponse = issueV0.presubmitResponse(state);
+    this.projectConfig = projectV0.viewedConfig(state);
+    this.projectName = issueV0.viewedIssueRef(state).projectName;
+    this.issuePermissions = issueV0.permissions(state);
+    this.optionsPerEnumField = projectV0.optionsPerEnumField(state);
+    // Access boolean value from allStarredIssues
+    const starredIssues = issueV0.starredIssues(state);
+    this.isStarred = starredIssues.has(issueRefToString(this.issueRef));
+    this.prefs = userV0.prefs(state);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    store.dispatch(ui.reportDirtyForm(this.formName, false));
+  }
+
+  /**
+   * Resets the edit form values to their default values.
+   */
+  async reset() {
+    this._values = {...this._initialValues};
+
+    const form = this.querySelector('#editForm');
+    if (!form) return;
+
+    form.reset();
+    const statusInput = this.querySelector('#statusInput');
+    if (statusInput) {
+      statusInput.reset();
+    }
+
+    // Since custom elements containing <input> elements have the inputs
+    // wrapped in ShadowDOM, those inputs don't get reset with the rest of
+    // the form. Haven't been able to figure out a way to replicate form reset
+    // behavior with custom input elements.
+    if (this.isApproval) {
+      if (this.hasApproverPrivileges) {
+        const approversInput = this.querySelector(
+            '#approversInput');
+        if (approversInput) {
+          approversInput.reset();
+        }
+      }
+    }
+    this.querySelectorAll('mr-edit-field').forEach((el) => {
+      el.reset();
+    });
+
+    const uploader = this.querySelector('mr-upload');
+    if (uploader) {
+      uploader.reset();
+    }
+
+    // TODO(dtu, zhangtiff): Remove once all form fields are controlled.
+    await this.updateComplete;
+
+    this._processChanges();
+  }
+
+  /**
+   * @param {MouseEvent|SubmitEvent} event
+   * @private
+   */
+  _save(event) {
+    event.preventDefault();
+    this.save();
+  }
+
+  /**
+   * Users may use either Ctrl+Enter or Command+Enter to save an issue edit
+   * while the issue edit form is focused.
+   * @param {KeyboardEvent} event
+   * @private
+   */
+  _saveOnCtrlEnter(event) {
+    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+      event.preventDefault();
+      this.save();
+    }
+  }
+
+  /**
+   * Tells the parent to save the current edited values in the form.
+   * @fires CustomEvent#save
+   */
+  save() {
+    this.dispatchEvent(new CustomEvent('save'));
+  }
+
+  /**
+   * Tells the parent component that the user is trying to discard the form,
+   * if they confirm that that's what they're doing. The parent decides what
+   * to do in order to quit the editing session.
+   * @fires CustomEvent#discard
+   */
+  discard() {
+    const isDirty = this.isDirty;
+    if (!isDirty || confirm('Discard your changes?')) {
+      this.dispatchEvent(new CustomEvent('discard'));
+    }
+  }
+
+  /**
+   * Focuses the comment form.
+   */
+  async focus() {
+    await this.updateComplete;
+    this.querySelector('#commentText').focus();
+  }
+
+  /**
+   * Retrieves the value of the comment that the user added from the DOM.
+   * @return {string}
+   */
+  getCommentContent() {
+    if (!this.querySelector('#commentText')) {
+      return '';
+    }
+    return this.querySelector('#commentText').value;
+  }
+
+  async getAttachments() {
+    try {
+      return await this.querySelector('mr-upload').loadFiles();
+    } catch (e) {
+      this.error = `Error while loading file for attachment: ${e.message}`;
+    }
+  }
+
+  /**
+   * @param {FieldDef} field
+   * @return {boolean}
+   * @private
+   */
+  _userCanEdit(field) {
+    const fieldName = fieldDefToName(this.projectName, field);
+    if (!this._permissions[fieldName] ||
+        !this._permissions[fieldName].permissions) return false;
+    const userPerms = this._permissions[fieldName].permissions;
+    return userPerms.includes(permissions.FIELD_DEF_VALUE_EDIT);
+  }
+
+  /**
+   * Shows or hides custom fields with the "isNiche" attribute set to true.
+   */
+  toggleNicheFields() {
+    this.showNicheFields = !this.showNicheFields;
+  }
+
+  /**
+   * @return {IssueDelta}
+   * @throws {UserInputError}
+   */
+  get delta() {
+    try {
+      this.error = '';
+      return this._getDelta();
+    } catch (e) {
+      if (!(e instanceof UserInputError)) throw e;
+      this.error = e.message;
+      return {};
+    }
+  }
+
+  /**
+   * Generates a change between the initial Issue state and what the user
+   * inputted.
+   * @return {IssueDelta}
+   */
+  _getDelta() {
+    let result = {};
+
+    const {projectName, localId} = this.issueRef;
+
+    const statusInput = this.querySelector('#statusInput');
+    if (this._canEditStatus && statusInput) {
+      const statusDelta = statusInput.delta;
+      if (statusDelta.mergedInto) {
+        result.mergedIntoRef = issueStringToBlockingRef(
+            {projectName, localId}, statusDelta.mergedInto);
+      }
+      if (statusDelta.status) {
+        result.status = statusDelta.status;
+      }
+    }
+
+    if (this.isApproval) {
+      if (this._canEditIssue && this.hasApproverPrivileges) {
+        result = {
+          ...result,
+          ...this._changedValuesDom(
+            'approvers', 'approverRefs', displayNameToUserRef),
+        };
+      }
+    } else {
+      // TODO(zhangtiff): Consider representing baked-in fields such as owner,
+      // cc, and status similarly to custom fields to reduce repeated code.
+
+      if (this._canEditSummary) {
+        const summaryInput = this.querySelector('#summaryInput');
+        if (summaryInput) {
+          const newSummary = summaryInput.value;
+          if (newSummary !== this.summary) {
+            result.summary = newSummary;
+          }
+        }
+      }
+
+      if (this._values.owner !== this._initialValues.owner) {
+        result.ownerRef = displayNameToUserRef(this._values.owner);
+      }
+
+      const blockerAddFn = (refString) =>
+        issueStringToBlockingRef({projectName, localId}, refString);
+      const blockerRemoveFn = (refString) =>
+        issueStringToRef(refString, projectName);
+
+      result = {
+        ...result,
+        ...this._changedValuesControlled(
+          'cc', 'ccRefs', displayNameToUserRef),
+        ...this._changedValuesControlled(
+          'components', 'compRefs', componentStringToRef),
+        ...this._changedValuesControlled(
+          'labels', 'labelRefs', labelStringToRef),
+        ...this._changedValuesControlled(
+          'blockedOn', 'blockedOnRefs', blockerAddFn, blockerRemoveFn),
+        ...this._changedValuesControlled(
+          'blocking', 'blockingRefs', blockerAddFn, blockerRemoveFn),
+      };
+    }
+
+    if (this._canEditIssue) {
+      const fieldDefs = this.fieldDefs || [];
+      fieldDefs.forEach(({fieldRef}) => {
+        const {fieldValsAdd = [], fieldValsRemove = []} =
+          this._changedValuesDom(fieldRef.fieldName, 'fieldVals',
+            valueToFieldValue.bind(null, fieldRef));
+
+        // Because multiple custom fields share the same "fieldVals" key in
+        // delta, we hav to make sure to concatenate updated delta values with
+        // old delta values.
+        if (fieldValsAdd.length) {
+          result.fieldValsAdd = [...(result.fieldValsAdd || []),
+            ...fieldValsAdd];
+        }
+
+        if (fieldValsRemove.length) {
+          result.fieldValsRemove = [...(result.fieldValsRemove || []),
+            ...fieldValsRemove];
+        }
+      });
+    }
+
+    return result;
+  }
+
+  /**
+   * Computes delta values for a controlled input.
+   * @param {string} fieldName The key in the values property to retrieve data.
+   *   from.
+   * @param {string} responseKey The key in the delta Object that changes will be
+   *   saved in.
+   * @param {function(string): any} addFn A function to specify how to format
+   *   the message for a given added field.
+   * @param {function(string): any} removeFn A function to specify how to format
+   *   the message for a given removed field.
+   * @return {Object} delta fragment for added and removed values.
+   */
+  _changedValuesControlled(fieldName, responseKey, addFn, removeFn) {
+    const values = this._values[fieldName];
+    const initialValues = this._initialValues[fieldName];
+
+    const valuesAdd = arrayDifference(values, initialValues, equalsIgnoreCase);
+    const valuesRemove =
+      arrayDifference(initialValues, values, equalsIgnoreCase);
+
+    return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
+  }
+
+  /**
+   * Gets changes values when reading from a legacy <mr-edit-field> element.
+   * @param {string} fieldName Name of the form input we're checking values on.
+   * @param {string} responseKey The key in the delta Object that changes will be
+   *   saved in.
+   * @param {function(string): any} addFn A function to specify how to format
+   *   the message for a given added field.
+   * @param {function(string): any} removeFn A function to specify how to format
+   *   the message for a given removed field.
+   * @return {Object} delta fragment for added and removed values.
+   */
+  _changedValuesDom(fieldName, responseKey, addFn, removeFn) {
+    const input = this.querySelector(`#${this._idForField(fieldName)}`);
+    if (!input) return;
+
+    const valuesAdd = input.getValuesAdded();
+    const valuesRemove = input.getValuesRemoved();
+
+    return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
+  }
+
+  /**
+   * Shared helper function for computing added and removed values for a
+   * single field in a delta.
+   * @param {Array<string>} valuesAdd The added values. For example, new CCed
+   *   users.
+   * @param {Array<string>} valuesRemove Values that were removed in this edit.
+   * @param {string} responseKey The key in the delta Object that changes will be
+   *   saved in.
+   * @param {function(string): any} addFn A function to specify how to format
+   *   the message for a given added field.
+   * @param {function(string): any} removeFn A function to specify how to format
+   *   the message for a given removed field.
+   * @return {Object} delta fragment for added and removed values.
+   */
+  _changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn) {
+    const delta = {};
+
+    if (valuesAdd && valuesAdd.length) {
+      delta[responseKey + 'Add'] = valuesAdd.map(addFn);
+    }
+
+    if (valuesRemove && valuesRemove.length) {
+      delta[responseKey + 'Remove'] = valuesRemove.map(removeFn || addFn);
+    }
+
+    return delta;
+  }
+
+  /**
+   * Generic onChange handler to be bound to each form field.
+   * @param {string} key Unique name for the form field we're binding this
+   *   handler to. For example, 'owner', 'cc', or the name of a custom field.
+   * @param {Event} event
+   * @param {string|Array<string>} value The new form value.
+   * @param {*} _reason
+   */
+  _onChange(key, event, value, _reason) {
+    this._values = {...this._values, [key]: value};
+    this._processChanges(event);
+  }
+
+  /**
+   * Event handler for running filter rules presubmit logic.
+   * @param {Event} e
+   */
+  _processChanges(e) {
+    if (e instanceof KeyboardEvent) {
+      if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+    }
+    this._updateIsDirty();
+
+    store.dispatch(ui.reportDirtyForm(this.formName, this.isDirty));
+
+    this.dispatchEvent(new CustomEvent('change', {
+      detail: {
+        delta: this.delta,
+        commentContent: this.getCommentContent(),
+      },
+    }));
+  }
+
+  _idForField(name) {
+    return `${name}Input`;
+  }
+
+  _optionsForField(optionsPerEnumField, fieldValueMap, fieldName, phaseName) {
+    if (!optionsPerEnumField || !fieldName) return [];
+    const key = fieldName.toLowerCase();
+    if (!optionsPerEnumField.has(key)) return [];
+    const options = [...optionsPerEnumField.get(key)];
+    const values = valuesForField(fieldValueMap, fieldName, phaseName);
+    values.forEach((v) => {
+      const optionExists = options.find(
+          (opt) => equalsIgnoreCase(opt.optionName, v));
+      if (!optionExists) {
+        // Note that enum fields which are not explicitly defined can be set,
+        // such as in the case when an issue is moved.
+        options.push({optionName: v});
+      }
+    });
+    return options;
+  }
+
+  _sendEmailChecked(evt) {
+    this.sendEmail = evt.detail.checked;
+  }
+}
+
+customElements.define('mr-edit-metadata', MrEditMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js
new file mode 100644
index 0000000..2e4554f
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js
@@ -0,0 +1,1078 @@
+// 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 {fireEvent} from '@testing-library/react';
+
+import {MrEditMetadata} from './mr-edit-metadata.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_EDIT_SUMMARY_PERMISSION,
+  ISSUE_EDIT_STATUS_PERMISSION, ISSUE_EDIT_OWNER_PERMISSION,
+  ISSUE_EDIT_CC_PERMISSION,
+} from 'shared/consts/permissions.js';
+import {FIELD_DEF_VALUE_EDIT} from 'reducers/permissions.js';
+import {store, resetState} from 'reducers/base.js';
+import {enterInput} from 'shared/test/helpers.js';
+
+let element;
+
+xdescribe('mr-edit-metadata', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+    element = document.createElement('mr-edit-metadata');
+    document.body.appendChild(element);
+
+    element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+    sinon.stub(store, 'dispatch');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    store.dispatch.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrEditMetadata);
+  });
+
+  describe('updated sets initial values', () => {
+    it('updates owner', async () => {
+      element.ownerName = 'goose@bird.org';
+      await element.updateComplete;
+
+      assert.equal(element._values.owner, 'goose@bird.org');
+    });
+
+    it('updates cc', async () => {
+      element.cc = [
+        {displayName: 'initial-cc@bird.org', userId: '1234'},
+      ];
+      await element.updateComplete;
+
+      assert.deepEqual(element._values.cc, ['initial-cc@bird.org']);
+    });
+
+    it('updates components', async () => {
+      element.components = [{path: 'Hello>World'}];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element._values.components, ['Hello>World']);
+    });
+
+    it('updates labels', async () => {
+      element.labelNames = ['test-label'];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element._values.labels, ['test-label']);
+    });
+  });
+
+  describe('saves edit form', () => {
+    let saveStub;
+
+    beforeEach(() => {
+      saveStub = sinon.stub();
+      element.addEventListener('save', saveStub);
+    });
+
+    it('saves on form submit', async () => {
+      await element.updateComplete;
+
+      element.querySelector('#editForm').dispatchEvent(
+          new Event('submit', {bubbles: true, cancelable: true}));
+
+      sinon.assert.calledOnce(saveStub);
+    });
+
+    it('saves when clicking the save button', async () => {
+      await element.updateComplete;
+
+      element.querySelector('.save-changes').click();
+
+      sinon.assert.calledOnce(saveStub);
+    });
+
+    it('does not save on random keydowns', async () => {
+      await element.updateComplete;
+
+      element.querySelector('#editForm').dispatchEvent(
+          new KeyboardEvent('keydown', {key: 'a', ctrlKey: true}));
+      element.querySelector('#editForm').dispatchEvent(
+          new KeyboardEvent('keydown', {key: 'b', ctrlKey: false}));
+      element.querySelector('#editForm').dispatchEvent(
+          new KeyboardEvent('keydown', {key: 'c', metaKey: true}));
+
+      sinon.assert.notCalled(saveStub);
+    });
+
+    it('does not save on Enter without Ctrl', async () => {
+      await element.updateComplete;
+
+      element.querySelector('#editForm').dispatchEvent(
+          new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: false}));
+
+      sinon.assert.notCalled(saveStub);
+    });
+
+    it('saves on Ctrl+Enter', async () => {
+      await element.updateComplete;
+
+      element.querySelector('#editForm').dispatchEvent(
+          new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: true}));
+
+      sinon.assert.calledOnce(saveStub);
+    });
+
+    it('saves on Ctrl+Meta', async () => {
+      await element.updateComplete;
+
+      element.querySelector('#editForm').dispatchEvent(
+          new KeyboardEvent('keydown', {key: 'Enter', metaKey: true}));
+
+      sinon.assert.calledOnce(saveStub);
+    });
+  });
+
+  it('disconnecting element reports form is not dirty', () => {
+    element.formName = 'test';
+
+    assert.isFalse(store.dispatch.calledOnce);
+
+    document.body.removeChild(element);
+
+    assert.isTrue(store.dispatch.calledOnce);
+    sinon.assert.calledWith(
+        store.dispatch,
+        {
+          type: 'REPORT_DIRTY_FORM',
+          name: 'test',
+          isDirty: false,
+        },
+    );
+
+    document.body.appendChild(element);
+  });
+
+  it('_processChanges fires change event', async () => {
+    await element.updateComplete;
+
+    const changeStub = sinon.stub();
+    element.addEventListener('change', changeStub);
+
+    element._processChanges();
+
+    sinon.assert.calledOnce(changeStub);
+  });
+
+  it('save button disabled when disabled is true', async () => {
+    // Check that save button is initially disabled.
+    await element.updateComplete;
+
+    const button = element.querySelector('.save-changes');
+
+    assert.isTrue(element.disabled);
+    assert.isTrue(button.disabled);
+
+    element.isDirty = true;
+
+    await element.updateComplete;
+
+    assert.isFalse(element.disabled);
+    assert.isFalse(button.disabled);
+  });
+
+  it('editing form sets isDirty to true or false', async () => {
+    await element.updateComplete;
+
+    assert.isFalse(element.isDirty);
+
+    // User makes some changes.
+    const comment = element.querySelector('#commentText');
+    comment.value = 'Value';
+    comment.dispatchEvent(new Event('keyup'));
+
+    assert.isTrue(element.isDirty);
+
+    // User undoes the changes.
+    comment.value = '';
+    comment.dispatchEvent(new Event('keyup'));
+
+    assert.isFalse(element.isDirty);
+  });
+
+  it('reseting form disables save button', async () => {
+    // Check that save button is initially disabled.
+    assert.isTrue(element.disabled);
+
+    // User makes some changes.
+    element.isDirty = true;
+
+    // Check that save button is not disabled.
+    assert.isFalse(element.disabled);
+
+    // Reset form.
+    await element.updateComplete;
+    await element.reset();
+
+    // Check that save button is still disabled.
+    assert.isTrue(element.disabled);
+  });
+
+  it('save button is enabled if request fails', async () => {
+    // Check that save button is initially disabled.
+    assert.isTrue(element.disabled);
+
+    // User makes some changes.
+    element.isDirty = true;
+
+    // Check that save button is not disabled.
+    assert.isFalse(element.disabled);
+
+    // User submits the change.
+    element.saving = true;
+
+    // Check that save button is disabled.
+    assert.isTrue(element.disabled);
+
+    // Request fails.
+    element.saving = false;
+    element.error = 'error';
+
+    // Check that save button is re-enabled.
+    assert.isFalse(element.disabled);
+  });
+
+  it('delta empty when no changes', async () => {
+    await element.updateComplete;
+    assert.deepEqual(element.delta, {});
+  });
+
+  it('toggling checkbox toggles sendEmail', async () => {
+    element.sendEmail = false;
+
+    await element.updateComplete;
+    const checkbox = element.querySelector('#sendEmail');
+
+    await checkbox.updateComplete;
+
+    checkbox.click();
+    await element.updateComplete;
+
+    assert.equal(checkbox.checked, true);
+    assert.equal(element.sendEmail, true);
+
+    checkbox.click();
+    await element.updateComplete;
+
+    assert.equal(checkbox.checked, false);
+    assert.equal(element.sendEmail, false);
+
+    checkbox.click();
+    await element.updateComplete;
+
+    assert.equal(checkbox.checked, true);
+    assert.equal(element.sendEmail, true);
+  });
+
+  it('changing status produces delta change (lit-element)', async () => {
+    element.statuses = [
+      {'status': 'New'},
+      {'status': 'Old'},
+      {'status': 'Test'},
+    ];
+    element.status = 'New';
+
+    await element.updateComplete;
+
+    const statusComponent = element.querySelector('#statusInput');
+    statusComponent.status = 'Old';
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      status: 'Old',
+    });
+  });
+
+  it('changing owner produces delta change (React)', async () => {
+    element.ownerName = 'initial-owner@bird.org';
+    await element.updateComplete;
+
+    const input = element.querySelector('#ownerInput');
+    enterInput(input, 'new-owner@bird.org');
+    await element.updateComplete;
+
+    const expected = {ownerRef: {displayName: 'new-owner@bird.org'}};
+    assert.deepEqual(element.delta, expected);
+  });
+
+  it('adding CC produces delta change (React)', async () => {
+    element.cc = [
+      {displayName: 'initial-cc@bird.org', userId: '1234'},
+    ];
+
+    await element.updateComplete;
+
+    const input = element.querySelector('#ccInput');
+    enterInput(input, 'another@bird.org');
+    await element.updateComplete;
+
+    const expected = {
+      ccRefsAdd: [{displayName: 'another@bird.org'}],
+      ccRefsRemove: [{displayName: 'initial-cc@bird.org'}],
+    };
+    assert.deepEqual(element.delta, expected);
+  });
+
+  it('invalid status throws', async () => {
+    element.statuses = [
+      {'status': 'New'},
+      {'status': 'Old'},
+      {'status': 'Duplicate'},
+    ];
+    element.status = 'Duplicate';
+
+    await element.updateComplete;
+
+    const statusComponent = element.querySelector('#statusInput');
+    statusComponent.shadowRoot.querySelector('#mergedIntoInput').value = 'xx';
+    assert.deepEqual(element.delta, {});
+    assert.equal(
+        element.error,
+        'Invalid issue ref: xx. Expected [projectName:]issueId.');
+  });
+
+  it('cannot block an issue on itself', async () => {
+    element.projectName = 'proj';
+    element.issueRef = {projectName: 'proj', localId: 123};
+
+    await element.updateComplete;
+
+    for (const fieldName of ['blockedOn', 'blocking']) {
+      const input =
+        element.querySelector(`#${fieldName}Input`);
+      enterInput(input, '123');
+      await element.updateComplete;
+
+      assert.deepEqual(element.delta, {});
+      assert.equal(
+          element.error,
+          `Invalid issue ref: 123. Cannot merge or block an issue on itself.`);
+      fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+      await element.updateComplete;
+
+      enterInput(input, 'proj:123');
+      await element.updateComplete;
+
+      assert.deepEqual(element.delta, {});
+      assert.equal(
+          element.error,
+          `Invalid issue ref: proj:123. ` +
+        'Cannot merge or block an issue on itself.');
+      fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+      await element.updateComplete;
+
+      enterInput(input, 'proj2:123');
+      await element.updateComplete;
+
+      assert.notDeepEqual(element.delta, {});
+      assert.equal(element.error, '');
+
+      fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+      await element.updateComplete;
+    }
+  });
+
+  it('cannot merge an issue into itself', async () => {
+    element.statuses = [
+      {'status': 'New'},
+      {'status': 'Duplicate'},
+    ];
+    element.status = 'New';
+    element.projectName = 'proj';
+    element.issueRef = {projectName: 'proj', localId: 123};
+
+    await element.updateComplete;
+
+    const statusComponent = element.querySelector('#statusInput');
+    const root = statusComponent.shadowRoot;
+    const statusInput = root.querySelector('#statusInput');
+    statusInput.value = 'Duplicate';
+    statusInput.dispatchEvent(new Event('change'));
+
+    await element.updateComplete;
+
+    root.querySelector('#mergedIntoInput').value = 'proj:123';
+    assert.deepEqual(element.delta, {});
+    assert.equal(
+        element.error,
+        `Invalid issue ref: proj:123. Cannot merge or block an issue on itself.`);
+
+    root.querySelector('#mergedIntoInput').value = '123';
+    assert.deepEqual(element.delta, {});
+    assert.equal(
+        element.error,
+        `Invalid issue ref: 123. Cannot merge or block an issue on itself.`);
+
+    root.querySelector('#mergedIntoInput').value = 'proj2:123';
+    assert.notDeepEqual(element.delta, {});
+    assert.equal(element.error, '');
+  });
+
+  it('cannot set invalid emails', async () => {
+    await element.updateComplete;
+
+    const ccInput = element.querySelector('#ccInput');
+    enterInput(ccInput, 'invalid!email');
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {});
+    assert.equal(
+        element.error,
+        `Invalid email address: invalid!email`);
+
+    const input = element.querySelector('#ownerInput');
+    enterInput(input, 'invalid!email2');
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {});
+    assert.equal(
+        element.error,
+        `Invalid email address: invalid!email2`);
+  });
+
+  it('can remove invalid values', async () => {
+    element.projectName = 'proj';
+    element.issueRef = {projectName: 'proj', localId: 123};
+
+    element.statuses = [
+      {'status': 'Duplicate'},
+    ];
+    element.status = 'Duplicate';
+    element.mergedInto = element.issueRef;
+
+    element.blockedOn = [element.issueRef];
+    element.blocking = [element.issueRef];
+
+    await element.updateComplete;
+
+    const blockedOnInput = element.querySelector('#blockedOnInput');
+    const blockingInput = element.querySelector('#blockingInput');
+    const statusInput = element.querySelector('#statusInput');
+
+    await element.updateComplete;
+
+    const mergedIntoInput =
+      statusInput.shadowRoot.querySelector('#mergedIntoInput');
+
+    fireEvent.keyDown(blockedOnInput, {key: 'Backspace', code: 'Backspace'});
+    await element.updateComplete;
+    fireEvent.keyDown(blockingInput, {key: 'Backspace', code: 'Backspace'});
+    await element.updateComplete;
+    mergedIntoInput.value = 'proj:124';
+    await element.updateComplete;
+
+    assert.deepEqual(
+        element.delta,
+        {
+          blockedOnRefsRemove: [{projectName: 'proj', localId: 123}],
+          blockingRefsRemove: [{projectName: 'proj', localId: 123}],
+          mergedIntoRef: {projectName: 'proj', localId: 124},
+        });
+    assert.equal(element.error, '');
+  });
+
+  it('not changing status produces no delta', async () => {
+    element.statuses = [
+      {'status': 'Duplicate'},
+    ];
+    element.status = 'Duplicate';
+
+    element.mergedInto = {
+      projectName: 'chromium',
+      localId: 1234,
+    };
+
+    element.projectName = 'chromium';
+
+    await element.updateComplete;
+    await element.updateComplete; // Merged input updates its value.
+
+    assert.deepEqual(element.delta, {});
+  });
+
+  it('changing status to duplicate produces delta change', async () => {
+    element.statuses = [
+      {'status': 'New'},
+      {'status': 'Duplicate'},
+    ];
+    element.status = 'New';
+
+    await element.updateComplete;
+
+    const statusComponent = element.querySelector(
+        '#statusInput');
+    const root = statusComponent.shadowRoot;
+    const statusInput = root.querySelector('#statusInput');
+    statusInput.value = 'Duplicate';
+    statusInput.dispatchEvent(new Event('change'));
+
+    await element.updateComplete;
+
+    root.querySelector('#mergedIntoInput').value = 'chromium:1234';
+    assert.deepEqual(element.delta, {
+      status: 'Duplicate',
+      mergedIntoRef: {
+        projectName: 'chromium',
+        localId: 1234,
+      },
+    });
+  });
+
+  it('changing summary produces delta change', async () => {
+    element.summary = 'Old summary';
+
+    await element.updateComplete;
+
+    element.querySelector(
+        '#summaryInput').value = 'newfangled fancy summary';
+    assert.deepEqual(element.delta, {
+      summary: 'newfangled fancy summary',
+    });
+  });
+
+  it('custom fields the user cannot edit should be hidden', async () => {
+    element.projectName = 'proj';
+    const fieldName = 'projects/proj/fieldDefs/1';
+    const restrictedFieldName = 'projects/proj/fieldDefs/2';
+    element._permissions = {
+      [fieldName]: {permissions: [FIELD_DEF_VALUE_EDIT]},
+      [restrictedFieldName]: {permissions: []}};
+    element.fieldDefs = [
+      {
+        fieldRef: {
+          fieldName: 'normalFd',
+          fieldId: 1,
+          type: 'ENUM_TYPE',
+        },
+      },
+      {
+        fieldRef: {
+          fieldName: 'cantEditFd',
+          fieldId: 2,
+          type: 'ENUM_TYPE',
+        },
+      },
+    ];
+
+    await element.updateComplete;
+    assert.isFalse(element.querySelector('#normalFdInput').hidden);
+    assert.isTrue(element.querySelector('#cantEditFdInput').hidden);
+  });
+
+  it('changing enum custom fields produces delta', async () => {
+    element.fieldValueMap = new Map([['fakefield', ['prev value']]]);
+    element.fieldDefs = [
+      {
+        fieldRef: {
+          fieldName: 'testField',
+          fieldId: 1,
+          type: 'ENUM_TYPE',
+        },
+      },
+      {
+        fieldRef: {
+          fieldName: 'fakeField',
+          fieldId: 2,
+          type: 'ENUM_TYPE',
+        },
+      },
+    ];
+
+    await element.updateComplete;
+
+    const input1 = element.querySelector('#testFieldInput');
+    const input2 = element.querySelector('#fakeFieldInput');
+
+    input1.values = ['test value'];
+    input2.values = [];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      fieldValsAdd: [
+        {
+          fieldRef: {
+            fieldName: 'testField',
+            fieldId: 1,
+            type: 'ENUM_TYPE',
+          },
+          value: 'test value',
+        },
+      ],
+      fieldValsRemove: [
+        {
+          fieldRef: {
+            fieldName: 'fakeField',
+            fieldId: 2,
+            type: 'ENUM_TYPE',
+          },
+          value: 'prev value',
+        },
+      ],
+    });
+  });
+
+  it('changing approvers produces delta', async () => {
+    element.isApproval = true;
+    element.hasApproverPrivileges = true;
+    element.approvers = [
+      {displayName: 'foo@example.com', userId: '1'},
+      {displayName: 'bar@example.com', userId: '2'},
+      {displayName: 'baz@example.com', userId: '3'},
+    ];
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    element.querySelector('#approversInput').values =
+        ['chicken@example.com', 'foo@example.com', 'dog@example.com'];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      approverRefsAdd: [
+        {displayName: 'chicken@example.com'},
+        {displayName: 'dog@example.com'},
+      ],
+      approverRefsRemove: [
+        {displayName: 'bar@example.com'},
+        {displayName: 'baz@example.com'},
+      ],
+    });
+  });
+
+  it('changing blockedon produces delta change (React)', async () => {
+    element.blockedOn = [
+      {projectName: 'chromium', localId: '1234'},
+      {projectName: 'monorail', localId: '4567'},
+    ];
+    element.projectName = 'chromium';
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    const input = element.querySelector('#blockedOnInput');
+
+    fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+    await element.updateComplete;
+
+    enterInput(input, 'v8:5678');
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      blockedOnRefsAdd: [{
+        projectName: 'v8',
+        localId: 5678,
+      }],
+      blockedOnRefsRemove: [{
+        projectName: 'monorail',
+        localId: 4567,
+      }],
+    });
+  });
+
+  it('_optionsForField computes options', () => {
+    const optionsPerEnumField = new Map([
+      ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+    ]);
+    assert.deepEqual(
+        element._optionsForField(optionsPerEnumField, new Map(), 'enumField'), [
+          {
+            optionName: 'one',
+          },
+          {
+            optionName: 'two',
+          },
+        ]);
+  });
+
+  it('changing enum fields produces delta', async () => {
+    element.fieldDefs = [
+      {
+        fieldRef: {
+          fieldName: 'enumField',
+          fieldId: 1,
+          type: 'ENUM_TYPE',
+        },
+        isMultivalued: true,
+      },
+    ];
+
+    element.optionsPerEnumField = new Map([
+      ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+    ]);
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    element.querySelector(
+        '#enumFieldInput').values = ['one', 'two'];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      fieldValsAdd: [
+        {
+          fieldRef: {
+            fieldName: 'enumField',
+            fieldId: 1,
+            type: 'ENUM_TYPE',
+          },
+          value: 'one',
+        },
+        {
+          fieldRef: {
+            fieldName: 'enumField',
+            fieldId: 1,
+            type: 'ENUM_TYPE',
+          },
+          value: 'two',
+        },
+      ],
+    });
+  });
+
+  it('changing multiple single valued enum fields', async () => {
+    element.fieldDefs = [
+      {
+        fieldRef: {
+          fieldName: 'enumField',
+          fieldId: 1,
+          type: 'ENUM_TYPE',
+        },
+      },
+      {
+        fieldRef: {
+          fieldName: 'enumField2',
+          fieldId: 2,
+          type: 'ENUM_TYPE',
+        },
+      },
+    ];
+
+    element.optionsPerEnumField = new Map([
+      ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+      ['enumfield2', [{optionName: 'three'}, {optionName: 'four'}]],
+    ]);
+
+    await element.updateComplete;
+
+    element.querySelector('#enumFieldInput').values = ['two'];
+    element.querySelector('#enumField2Input').values = ['three'];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      fieldValsAdd: [
+        {
+          fieldRef: {
+            fieldName: 'enumField',
+            fieldId: 1,
+            type: 'ENUM_TYPE',
+          },
+          value: 'two',
+        },
+        {
+          fieldRef: {
+            fieldName: 'enumField2',
+            fieldId: 2,
+            type: 'ENUM_TYPE',
+          },
+          value: 'three',
+        },
+      ],
+    });
+  });
+
+  it('adding components produces delta', async () => {
+    await element.updateComplete;
+
+    element.isApproval = false;
+    element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+    element.components = [];
+
+    await element.updateComplete;
+
+    element._values.components = ['Hello>World'];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      compRefsAdd: [
+        {path: 'Hello>World'},
+      ],
+    });
+
+    element._values.components = ['Hello>World', 'Test', 'Multi'];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      compRefsAdd: [
+        {path: 'Hello>World'},
+        {path: 'Test'},
+        {path: 'Multi'},
+      ],
+    });
+
+    element._values.components = [];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {});
+  });
+
+  it('removing components produces delta', async () => {
+    await element.updateComplete;
+
+    element.isApproval = false;
+    element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+    element.components = [{path: 'Hello>World'}];
+
+    await element.updateComplete;
+
+    element._values.components = [];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.delta, {
+      compRefsRemove: [
+        {path: 'Hello>World'},
+      ],
+    });
+  });
+
+  it('approver input appears when user has privileges', async () => {
+    assert.isNull(element.querySelector('#approversInput'));
+    element.isApproval = true;
+    element.hasApproverPrivileges = true;
+
+    await element.updateComplete;
+
+    assert.isNotNull(element.querySelector('#approversInput'));
+  });
+
+  it('reset sets controlled values to default', async () => {
+    element.ownerName = 'burb@bird.com';
+    element.cc = [
+      {displayName: 'flamingo@bird.com', userId: '1234'},
+      {displayName: 'penguin@bird.com', userId: '5678'},
+    ];
+    element.components = [{path: 'Bird>Penguin'}];
+    element.labelNames = ['chickadee-chirp'];
+    element.blockedOn = [{localId: 1234, projectName: 'project'}];
+    element.blocking = [{localId: 5678, projectName: 'other-project'}];
+    element.projectName = 'project';
+
+    // Update cycle is needed because <mr-edit-metadata> initializes
+    // this.values in updated().
+    await element.updateComplete;
+
+    const initialValues = {
+      owner: 'burb@bird.com',
+      cc: ['flamingo@bird.com', 'penguin@bird.com'],
+      components: ['Bird>Penguin'],
+      labels: ['chickadee-chirp'],
+      blockedOn: ['1234'],
+      blocking: ['other-project:5678'],
+    };
+
+    assert.deepEqual(element._values, initialValues);
+
+    element._values = {
+      owner: 'newburb@hello.com',
+      cc: ['noburbs@wings.com'],
+    };
+    element.reset();
+
+    assert.deepEqual(element._values, initialValues);
+  })
+
+  it('reset empties form values', async () => {
+    element.fieldDefs = [
+      {
+        fieldRef: {
+          fieldName: 'testField',
+          fieldId: 1,
+          type: 'ENUM_TYPE',
+        },
+      },
+      {
+        fieldRef: {
+          fieldName: 'fakeField',
+          fieldId: 2,
+          type: 'ENUM_TYPE',
+        },
+      },
+    ];
+
+    await element.updateComplete;
+
+    const uploader = element.querySelector('mr-upload');
+    uploader.files = [
+      {name: 'test.png'},
+      {name: 'rutabaga.png'},
+    ];
+
+    element.querySelector('#testFieldInput').values = 'testy test';
+    element.querySelector('#fakeFieldInput').values = 'hello world';
+
+    await element.reset();
+
+    assert.lengthOf(element.querySelector('#testFieldInput').value, 0);
+    assert.lengthOf(element.querySelector('#fakeFieldInput').value, 0);
+    assert.lengthOf(uploader.files, 0);
+  });
+
+  it('reset results in empty delta', async () => {
+    element.ownerName = 'goose@bird.org';
+    await element.updateComplete;
+
+    element._values.owner = 'penguin@bird.org';
+    const expected = {ownerRef: {displayName: 'penguin@bird.org'}};
+    assert.deepEqual(element.delta, expected);
+
+    await element.reset();
+    assert.deepEqual(element.delta, {});
+  });
+
+  it('edit issue permissions', async () => {
+    const allFields = ['summary', 'status', 'owner', 'cc'];
+    const testCases = [
+      {permissions: [], nonNull: []},
+      {permissions: [ISSUE_EDIT_PERMISSION], nonNull: allFields},
+      {permissions: [ISSUE_EDIT_SUMMARY_PERMISSION], nonNull: ['summary']},
+      {permissions: [ISSUE_EDIT_STATUS_PERMISSION], nonNull: ['status']},
+      {permissions: [ISSUE_EDIT_OWNER_PERMISSION], nonNull: ['owner']},
+      {permissions: [ISSUE_EDIT_CC_PERMISSION], nonNull: ['cc']},
+    ];
+    element.statuses = [{'status': 'Foo'}];
+
+    for (const testCase of testCases) {
+      element.issuePermissions = testCase.permissions;
+      await element.updateComplete;
+
+      allFields.forEach((fieldName) => {
+        const field = element.querySelector(`#${fieldName}Input`);
+        if (testCase.nonNull.includes(fieldName)) {
+          assert.isNotNull(field);
+        } else {
+          assert.isNull(field);
+        }
+      });
+    }
+  });
+
+  it('duplicate issue is rendered correctly', async () => {
+    element.statuses = [
+      {'status': 'Duplicate'},
+    ];
+    element.status = 'Duplicate';
+    element.projectName = 'chromium';
+    element.mergedInto = {
+      projectName: 'chromium',
+      localId: 1234,
+    };
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    const statusComponent = element.querySelector('#statusInput');
+    const root = statusComponent.shadowRoot;
+    assert.equal(
+        root.querySelector('#mergedIntoInput').value, '1234');
+  });
+
+  it('duplicate issue on different project is rendered correctly', async () => {
+    element.statuses = [
+      {'status': 'Duplicate'},
+    ];
+    element.status = 'Duplicate';
+    element.projectName = 'chromium';
+    element.mergedInto = {
+      projectName: 'monorail',
+      localId: 1234,
+    };
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    const statusComponent = element.querySelector('#statusInput');
+    const root = statusComponent.shadowRoot;
+    assert.equal(
+        root.querySelector('#mergedIntoInput').value, 'monorail:1234');
+  });
+
+  it('filter out deleted users', async () => {
+    element.cc = [
+      {displayName: 'test@example.com', userId: '1234'},
+      {displayName: 'a_deleted_user'},
+      {displayName: 'someone@example.com', userId: '5678'},
+    ];
+
+    await element.updateComplete;
+
+    assert.deepEqual(element._values.cc, [
+      'test@example.com',
+      'someone@example.com',
+    ]);
+  });
+
+  it('renders valid markdown description with preview', async () => {
+    await element.updateComplete;
+
+    element.prefs = new Map([['render_markdown', true]]);
+    element.projectName = 'monkeyrail';
+    sinon.stub(element, 'getCommentContent').returns('# h1');
+
+    await element.updateComplete;
+
+    assert.isTrue(element._renderMarkdown);
+
+    const previewMarkdown = element.querySelector('.markdown-preview');
+    assert.isNotNull(previewMarkdown);
+
+    const headerText = previewMarkdown.querySelector('h1').textContent;
+    assert.equal(headerText, 'h1');
+  });
+
+  it('does not show preview when markdown is disabled', async () => {
+    element.prefs = new Map([['render_markdown', false]]);
+    element.projectName = 'monkeyrail';
+    sinon.stub(element, 'getCommentContent').returns('# h1');
+
+    await element.updateComplete;
+
+    const previewMarkdown = element.querySelector('.markdown-preview');
+    assert.isNull(previewMarkdown);
+  });
+
+  it('does not show preview when no input', async () => {
+    element.prefs = new Map([['render_markdown', true]]);
+    element.projectName = 'monkeyrail';
+    sinon.stub(element, 'getCommentContent').returns('');
+
+    await element.updateComplete;
+
+    const previewMarkdown = element.querySelector('.markdown-preview');
+    assert.isNull(previewMarkdown);
+  });
+});
+
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js
new file mode 100644
index 0000000..ba68c39
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js
@@ -0,0 +1,58 @@
+// 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} from 'lit-element';
+
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import {fieldTypes, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {displayNameToUserRef} from 'shared/convertersV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-field-values>`
+ *
+ * Takes in a list of field values and a single fieldDef and displays them
+ * according to their type.
+ *
+ */
+export class MrFieldValues extends LitElement {
+  /** @override */
+  static get styles() {
+    return SHARED_STYLES;
+  }
+
+  /** @override */
+  render() {
+    if (!this.values || !this.values.length) {
+      return html`${EMPTY_FIELD_VALUE}`;
+    }
+    switch (this.type) {
+      case fieldTypes.URL_TYPE:
+        return html`${this.values.map((value) => html`
+          <a href=${value} target="_blank" rel="nofollow">${value}</a>
+        `)}`;
+      case fieldTypes.USER_TYPE:
+        return html`${this.values.map((value) => html`
+          <mr-user-link .userRef=${displayNameToUserRef(value)}></mr-user-link>
+        `)}`;
+      default:
+        return html`${this.values.map((value, i) => html`
+          <a href="/p/${this.projectName}/issues/list?q=${this.name}=&quot;${value}&quot;">
+            ${value}</a>${this.values.length - 1 > i ? ', ' : ''}
+        `)}`;
+    }
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      name: {type: String},
+      type: {type: Object},
+      projectName: {type: String},
+      values: {type: Array},
+    };
+  }
+}
+
+customElements.define('mr-field-values', MrFieldValues);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js
new file mode 100644
index 0000000..e334841
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js
@@ -0,0 +1,86 @@
+// 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 {MrFieldValues} from './mr-field-values.js';
+
+import {fieldTypes} from 'shared/issue-fields.js';
+
+
+let element;
+
+describe('mr-field-values', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-field-values');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrFieldValues);
+  });
+
+  it('renders empty if no values', async () => {
+    element.values = [];
+
+    await element.updateComplete;
+
+    assert.equal('----', element.shadowRoot.textContent.trim());
+  });
+
+  it('renders user links when type is user', async () => {
+    element.type = fieldTypes.USER_TYPE;
+    element.values = ['test@example.com', 'hello@world.com'];
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('mr-user-link');
+
+    await links.updateComplete;
+
+    assert.equal(2, links.length);
+    assert.include(links[0].shadowRoot.textContent, 'test@example.com');
+    assert.include(links[1].shadowRoot.textContent, 'hello@world.com');
+  });
+
+  it('renders URLs when type is url', async () => {
+    element.type = fieldTypes.URL_TYPE;
+    element.values = ['http://hello.world', 'go/link'];
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('a');
+
+    assert.equal(2, links.length);
+    assert.include(links[0].textContent, 'http://hello.world');
+    assert.include(links[0].href, 'http://hello.world');
+    assert.include(links[1].textContent, 'go/link');
+    assert.include(links[1].href, 'go/link');
+  });
+
+  it('renders generic field when field is string', async () => {
+    element.type = fieldTypes.STR_TYPE;
+    element.values = ['blah', 'random value', 'nothing here'];
+    element.name = 'fieldName';
+    element.projectName = 'project';
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('a');
+
+    assert.equal(3, links.length);
+    assert.include(links[0].textContent, 'blah');
+    assert.include(links[0].href,
+        '/p/project/issues/list?q=fieldName=%22blah%22');
+    assert.include(links[1].textContent, 'random value');
+    assert.include(links[1].href,
+        '/p/project/issues/list?q=fieldName=%22random%20value%22');
+    assert.include(links[2].textContent, 'nothing here');
+    assert.include(links[2].href,
+        '/p/project/issues/list?q=fieldName=%22nothing%20here%22');
+  });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js
new file mode 100644
index 0000000..60d570c
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js
@@ -0,0 +1,352 @@
+// 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 issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/links/mr-hotlist-link/mr-hotlist-link.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {pluralize} from 'shared/helpers.js';
+import './mr-metadata.js';
+
+
+/**
+ * `<mr-issue-metadata>`
+ *
+ * The metadata view for a single issue. Contains information such as the owner.
+ *
+ */
+export class MrIssueMetadata extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          box-sizing: border-box;
+          padding: 0.25em 8px;
+          max-width: 100%;
+          display: block;
+        }
+        h3 {
+          display: block;
+          font-size: var(--chops-main-font-size);
+          margin: 0;
+          line-height: 160%;
+          width: 40%;
+          height: 100%;
+          overflow: ellipsis;
+          flex-grow: 0;
+          flex-shrink: 0;
+        }
+        a.label {
+          color: hsl(120, 100%, 25%);
+          text-decoration: none;
+        }
+        a.label[data-derived] {
+          font-style: italic;
+        }
+        button.linkify {
+          display: flex;
+          align-items: center;
+          text-decoration: none;
+          padding: 0.25em 0;
+        }
+        button.linkify i.material-icons {
+          margin-right: 4px;
+          font-size: var(--chops-icon-font-size);
+        }
+        mr-hotlist-link {
+          text-overflow: ellipsis;
+          overflow: hidden;
+          display: block;
+          width: 100%;
+        }
+        .bottom-section-cell, .labels-container {
+          padding: 0.5em 4px;
+          width: 100%;
+          box-sizing: border-box;
+        }
+        .bottom-section-cell {
+          display: flex;
+          flex-direction: row;
+          flex-wrap: nowrap;
+          align-items: flex-start;
+        }
+        .bottom-section-content {
+          max-width: 60%;
+        }
+        .star-line {
+          width: 100%;
+          text-align: center;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+        }
+        mr-issue-star {
+          margin-right: 4px;
+          padding-bottom: 2px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const hotlistsByRole = this._hotlistsByRole;
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <div class="star-line">
+        <mr-issue-star
+          .issueRef=${this.issueRef}
+        ></mr-issue-star>
+        Starred by ${this.issue.starCount || 0} ${pluralize(this.issue.starCount, 'user')}
+      </div>
+      <mr-metadata
+        aria-label="Issue Metadata"
+        .owner=${this.issue.ownerRef}
+        .cc=${this.issue.ccRefs}
+        .issueStatus=${this.issue.statusRef}
+        .components=${this._components}
+        .fieldDefs=${this._fieldDefs}
+        .mergedInto=${this.mergedInto}
+        .modifiedTimestamp=${this.issue.modifiedTimestamp}
+      ></mr-metadata>
+
+      <div class="labels-container">
+        ${this.issue.labelRefs && this.issue.labelRefs.map((label) => html`
+          <a
+            title="${_labelTitle(this.labelDefMap, label)}"
+            href="/p/${this.issueRef.projectName}/issues/list?q=label:${label.label}"
+            class="label"
+            ?data-derived=${label.isDerived}
+          >${label.label}</a>
+          <br>
+        `)}
+      </div>
+
+      ${this.sortedBlockedOn.length ? html`
+        <div class="bottom-section-cell">
+          <h3>BlockedOn:</h3>
+            <div class="bottom-section-content">
+            ${this.sortedBlockedOn.map((issue) => html`
+              <mr-issue-link
+                .projectName=${this.issueRef.projectName}
+                .issue=${issue}
+              >
+              </mr-issue-link>
+              <br />
+            `)}
+            <button
+              class="linkify"
+              @click=${this.openViewBlockedOn}
+            >
+              <i class="material-icons" role="presentation">list</i>
+              View details
+            </button>
+          </div>
+        </div>
+      `: ''}
+
+      ${this.blocking.length ? html`
+        <div class="bottom-section-cell">
+          <h3>Blocking:</h3>
+          <div class="bottom-section-content">
+            ${this.blocking.map((issue) => html`
+              <mr-issue-link
+                .projectName=${this.issueRef.projectName}
+                .issue=${issue}
+              >
+              </mr-issue-link>
+              <br />
+            `)}
+          </div>
+        </div>
+      `: ''}
+
+      ${this._userId ? html`
+        <div class="bottom-section-cell">
+          <h3>Your Hotlists:</h3>
+          <div class="bottom-section-content" id="user-hotlists">
+            ${this._renderHotlists(hotlistsByRole.user)}
+            <button
+              class="linkify"
+              @click=${this.openUpdateHotlists}
+            >
+              <i class="material-icons" role="presentation">create</i> Update your hotlists
+            </button>
+          </div>
+        </div>
+      `: ''}
+
+      ${hotlistsByRole.participants.length ? html`
+        <div class="bottom-section-cell">
+          <h3>Participant's Hotlists:</h3>
+          <div class="bottom-section-content">
+            ${this._renderHotlists(hotlistsByRole.participants)}
+          </div>
+        </div>
+      ` : ''}
+
+      ${hotlistsByRole.others.length ? html`
+        <div class="bottom-section-cell">
+          <h3>Other Hotlists:</h3>
+          <div class="bottom-section-content">
+            ${this._renderHotlists(hotlistsByRole.others)}
+          </div>
+        </div>
+      ` : ''}
+    `;
+  }
+
+  /**
+   * Helper to render hotlists.
+   * @param {Array<Hotlist>} hotlists
+   * @return {Array<TemplateResult>}
+   * @private
+   */
+  _renderHotlists(hotlists) {
+    return hotlists.map((hotlist) => html`
+      <mr-hotlist-link .hotlist=${hotlist}></mr-hotlist-link>
+    `);
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+      issueRef: {type: Object},
+      projectConfig: String,
+      user: {type: Object},
+      issueHotlists: {type: Array},
+      blocking: {type: Array},
+      sortedBlockedOn: {type: Array},
+      relatedIssues: {type: Object},
+      labelDefMap: {type: Object},
+      _components: {type: Array},
+      _fieldDefs: {type: Array},
+      _type: {type: String},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issue = issueV0.viewedIssue(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.user = userV0.currentUser(state);
+    this.projectConfig = projectV0.viewedConfig(state);
+    this.blocking = issueV0.blockingIssues(state);
+    this.sortedBlockedOn = issueV0.sortedBlockedOn(state);
+    this.mergedInto = issueV0.mergedInto(state);
+    this.relatedIssues = issueV0.relatedIssues(state);
+    this.issueHotlists = issueV0.hotlists(state);
+    this.labelDefMap = projectV0.labelDefMap(state);
+    this._components = issueV0.components(state);
+    this._fieldDefs = issueV0.fieldDefs(state);
+    this._type = issueV0.type(state);
+  }
+
+  /**
+   * @return {string|number} The current user's userId.
+   * @private
+   */
+  get _userId() {
+    return this.user && this.user.userId;
+  }
+
+  /**
+   * @return {Object<string, Array<Hotlist>>}
+   * @private
+   */
+  get _hotlistsByRole() {
+    const issueHotlists = this.issueHotlists;
+    const owner = this.issue && this.issue.ownerRef;
+    const cc = this.issue && this.issue.ccRefs;
+
+    const hotlists = {
+      user: [],
+      participants: [],
+      others: [],
+    };
+    (issueHotlists || []).forEach((hotlist) => {
+      if (hotlist.ownerRef.userId === this._userId) {
+        hotlists.user.push(hotlist);
+      } else if (_userIsParticipant(hotlist.ownerRef, owner, cc)) {
+        hotlists.participants.push(hotlist);
+      } else {
+        hotlists.others.push(hotlist);
+      }
+    });
+    return hotlists;
+  }
+
+  /**
+   * Opens dialog for updating ths issue's hotlists.
+   * @fires CustomEvent#open-dialog
+   */
+  openUpdateHotlists() {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'update-issue-hotlists',
+      },
+    }));
+  }
+
+  /**
+   * Opens dialog with detailed view of blocked on issues.
+   * @fires CustomEvent#open-dialog
+   */
+  openViewBlockedOn() {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'reorder-related-issues',
+      },
+    }));
+  }
+}
+
+/**
+ * @param {UserRef} user
+ * @param {UserRef} owner
+ * @param {Array<UserRef>} cc
+ * @return {boolean} Whether a given user is a participant of
+ *   a given hotlist attached to an issue. Used to sort hotlists into
+ *   "My hotlists" and "Other hotlists".
+ * @private
+ */
+function _userIsParticipant(user, owner, cc) {
+  if (owner && owner.userId === user.userId) {
+    return true;
+  }
+  return cc && cc.some((ccUser) => ccUser && ccUser.userId === user.userId);
+}
+
+/**
+ * @param {Map.<string, LabelDef>} labelDefMap
+ * @param {LabelDef} label
+ * @return {string} Tooltip shown to the user when hovering over a
+ *   given label.
+ * @private
+ */
+function _labelTitle(labelDefMap, label) {
+  if (!label) return '';
+  let docstring = '';
+  const key = label.label.toLowerCase();
+  if (labelDefMap && labelDefMap.has(key)) {
+    docstring = labelDefMap.get(key).docstring;
+  }
+  return (label.isDerived ? 'Derived: ' : '') + label.label +
+    (docstring ? ` = ${docstring}` : '');
+}
+
+customElements.define('mr-issue-metadata', MrIssueMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js
new file mode 100644
index 0000000..c328057
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js
@@ -0,0 +1,60 @@
+// 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 {MrIssueMetadata} from './mr-issue-metadata.js';
+
+let element;
+
+describe('mr-issue-metadata', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-metadata');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueMetadata);
+  });
+
+  it('labels render', async () => {
+    element.issue = {
+      labelRefs: [
+        {label: 'test'},
+        {label: 'hello-world', isDerived: true},
+      ],
+    };
+
+    element.labelDefMap = new Map([
+      ['test', {label: 'test', docstring: 'this is a docstring'}],
+    ]);
+
+    await element.updateComplete;
+
+    const labels = element.shadowRoot.querySelectorAll('.label');
+
+    assert.equal(labels.length, 2);
+    assert.equal(labels[0].textContent.trim(), 'test');
+    assert.equal(labels[0].getAttribute('title'), 'test = this is a docstring');
+    assert.isUndefined(labels[0].dataset.derived);
+
+    assert.equal(labels[1].textContent.trim(), 'hello-world');
+    assert.equal(labels[1].getAttribute('title'), 'Derived: hello-world');
+    assert.isDefined(labels[1].dataset.derived);
+  });
+
+  it('update hotlist button is shown to users', async () => {
+    element.user = {userId: 1234};
+    await element.updateComplete;
+    assert.isNotNull(element.shadowRoot.querySelector('#user-hotlists'));
+  });
+
+  it('update hotlist button is not shown to anon', async () => {
+    await element.updateComplete;
+    assert.isNull(element.shadowRoot.querySelector('#user-hotlists'));
+  });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js
new file mode 100644
index 0000000..0ce172d
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js
@@ -0,0 +1,357 @@
+// 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 'elements/chops/chops-timestamp/chops-timestamp.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/mr-issue-slo/mr-issue-slo.js';
+
+import * as issueV0 from 'reducers/issueV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as userV0 from 'reducers/userV0.js';
+import './mr-field-values.js';
+import {isExperimentEnabled, SLO_EXPERIMENT} from 'shared/experiments.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {HARDCODED_FIELD_GROUPS, valuesForField, fieldDefsWithGroup,
+  fieldDefsWithoutGroup} from 'shared/metadata-helpers.js';
+import 'shared/typedef.js';
+import {AVAILABLE_CUES, cueNames, specToCueName,
+  cueNameToSpec} from 'elements/help/mr-cue/cue-helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+
+/**
+ * `<mr-metadata>`
+ *
+ * Generalized metadata components, used for either approvals or issues.
+ *
+ */
+export class MrMetadata extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: table;
+          table-layout: fixed;
+          width: 100%;
+        }
+        td, th {
+          padding: 0.5em 4px;
+          vertical-align: top;
+          text-overflow: ellipsis;
+          overflow: hidden;
+        }
+        td {
+          width: 60%;
+        }
+        td.allow-overflow {
+          overflow: visible;
+        }
+        th {
+          text-align: left;
+          width: 40%;
+        }
+        .group-separator {
+          border-top: var(--chops-normal-border);
+        }
+        .group-title {
+          font-weight: normal;
+          font-style: oblique;
+          border-bottom: var(--chops-normal-border);
+          text-align: center;
+        }
+    `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      ${this._renderBuiltInFields()}
+      ${this._renderCustomFieldGroups()}
+    `;
+  }
+
+  /**
+   * Helper for handling the rendering of built in fields.
+   * @return {Array<TemplateResult>}
+   */
+  _renderBuiltInFields() {
+    return this.builtInFieldSpec.map((fieldName) => {
+      const fieldKey = fieldName.toLowerCase();
+
+      // Adding classes to table rows based on field names makes selecting
+      // rows with specific values easier, for example in tests.
+      let className = `row-${fieldKey}`;
+
+      const cueName = specToCueName(fieldKey);
+      if (cueName) {
+        className = `cue-${cueName}`;
+
+        if (!AVAILABLE_CUES.has(cueName)) return '';
+
+        return html`
+          <tr class=${className}>
+            <td colspan="2">
+              <mr-cue cuePrefName=${cueName}></mr-cue>
+            </td>
+          </tr>
+        `;
+      }
+
+      const isApprovalStatus = fieldKey === 'approvalstatus';
+      const isMergedInto = fieldKey === 'mergedinto';
+
+      const fieldValueTemplate = this._renderBuiltInFieldValue(fieldName);
+
+      if (!fieldValueTemplate) return '';
+
+      // Allow overflow to enable the FedRef popup to expand.
+      // TODO(jeffcarp): Look into a more elegant solution.
+      return html`
+        <tr class=${className}>
+          <th>${isApprovalStatus ? 'Status' : fieldName}:</th>
+          <td class=${isMergedInto ? 'allow-overflow' : ''}>
+            ${fieldValueTemplate}
+          </td>
+        </tr>
+      `;
+    });
+  }
+
+  /**
+   * A helper to display a single built-in field.
+   *
+   * @param {string} fieldName The name of the built in field to render.
+   * @return {TemplateResult|undefined} lit-html template for displaying the
+   *   value of the built in field. If undefined, the rendering code assumes
+   *   that the field should be hidden if empty.
+   */
+  _renderBuiltInFieldValue(fieldName) {
+    // TODO(zhangtiff): Merge with code in shared/issue-fields.js for further
+    // de-duplication.
+    switch (fieldName.toLowerCase()) {
+      case 'approvalstatus':
+        return this.approvalStatus || EMPTY_FIELD_VALUE;
+      case 'approvers':
+        return this.approvers && this.approvers.length ?
+          this.approvers.map((approver) => html`
+            <mr-user-link
+              .userRef=${approver}
+              showAvailabilityIcon
+            ></mr-user-link>
+            <br />
+          `) : EMPTY_FIELD_VALUE;
+      case 'setter':
+        return this.setter ? html`
+          <mr-user-link
+            .userRef=${this.setter}
+            showAvailabilityIcon
+          ></mr-user-link>
+          ` : undefined; // Hide the field when empty.
+      case 'owner':
+        return this.owner ? html`
+          <mr-user-link
+            .userRef=${this.owner}
+            showAvailabilityIcon
+            showAvailabilityText
+          ></mr-user-link>
+          ` : EMPTY_FIELD_VALUE;
+      case 'cc':
+        return this.cc && this.cc.length ?
+          this.cc.map((cc) => html`
+            <mr-user-link
+              .userRef=${cc}
+              showAvailabilityIcon
+            ></mr-user-link>
+            <br />
+          `) : EMPTY_FIELD_VALUE;
+      case 'status':
+        return this.issueStatus ? html`
+          ${this.issueStatus.status} <em>${
+            this.issueStatus.meansOpen ? '(Open)' : '(Closed)'}
+          </em>` : EMPTY_FIELD_VALUE;
+      case 'mergedinto':
+        // TODO(zhangtiff): This should use the project config to determine if a
+        // field allows merging rather than used a hard-coded value.
+        return this.issueStatus && this.issueStatus.status === 'Duplicate' ?
+          html`
+            <mr-issue-link
+              .projectName=${this.issueRef.projectName}
+              .issue=${this.mergedInto}
+            ></mr-issue-link>
+          `: undefined; // Hide the field when empty.
+      case 'components':
+        return (this.components && this.components.length) ?
+          this.components.map((comp) => html`
+            <a
+              href="/p/${this.issueRef.projectName
+                }/issues/list?q=component:${comp.path}"
+              title="${comp.path}${comp.docstring ?
+                ' = ' + comp.docstring : ''}"
+            >
+              ${comp.path}</a><br />
+          `) : EMPTY_FIELD_VALUE;
+      case 'modified':
+        return this.modifiedTimestamp ? html`
+            <chops-timestamp
+              .timestamp=${this.modifiedTimestamp}
+              short
+            ></chops-timestamp>
+          ` : EMPTY_FIELD_VALUE;
+      case 'slo':
+        if (isExperimentEnabled(
+            SLO_EXPERIMENT, this.currentUser, this.queryParams)) {
+          return html`<mr-issue-slo .issue=${this.issue}></mr-issue-slo>`;
+        } else {
+          return;
+        }
+    }
+
+    // Non-existent field.
+    return;
+  }
+
+  /**
+   * Helper for handling the rendering of custom fields defined in a project
+   * config.
+   * @return {TemplateResult} lit-html template.
+   */
+  _renderCustomFieldGroups() {
+    const grouped = fieldDefsWithGroup(this.fieldDefs,
+        this.fieldGroups, this.issueType);
+    const ungrouped = fieldDefsWithoutGroup(this.fieldDefs,
+        this.fieldGroups, this.issueType);
+    return html`
+      ${grouped.map((group) => html`
+        <tr>
+          <th class="group-title" colspan="2">
+            ${group.groupName}
+          </th>
+        </tr>
+        ${this._renderCustomFields(group.fieldDefs)}
+        <tr>
+          <th class="group-separator" colspan="2"></th>
+        </tr>
+      `)}
+
+      ${this._renderCustomFields(ungrouped)}
+    `;
+  }
+
+  /**
+   * Helper for handling the rendering of built in fields.
+   *
+   * @param {Array<FieldDef>} fieldDefs Arrays of configurations Objects
+   *   for fields to render.
+   * @return {Array<TemplateResult>} Array of lit-html templates to render, each
+   *   representing a single table row for a custom field.
+   */
+  _renderCustomFields(fieldDefs) {
+    if (!fieldDefs || !fieldDefs.length) return [];
+    return fieldDefs.map((field) => {
+      const fieldValues = valuesForField(
+          this.fieldValueMap, field.fieldRef.fieldName) || [];
+      return html`
+        <tr ?hidden=${field.isNiche && !fieldValues.length}>
+          <th title=${field.docstring}>${field.fieldRef.fieldName}:</th>
+          <td>
+            <mr-field-values
+              .name=${field.fieldRef.fieldName}
+              .type=${field.fieldRef.type}
+              .values=${fieldValues}
+              .projectName=${this.issueRef.projectName}
+            ></mr-field-values>
+          </td>
+        </tr>
+      `;
+    });
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * An Array of Strings to specify which built in fields to display.
+       */
+      builtInFieldSpec: {type: Array},
+      approvalStatus: {type: Array},
+      approvers: {type: Array},
+      setter: {type: Object},
+      cc: {type: Array},
+      components: {type: Array},
+      fieldDefs: {type: Array},
+      fieldGroups: {type: Array},
+      issue: {type: Object},
+      issueStatus: {type: String},
+      issueType: {type: String},
+      mergedInto: {type: Object},
+      modifiedTimestamp: {type: Number},
+      owner: {type: Object},
+      isApproval: {type: Boolean},
+      issueRef: {type: Object},
+      fieldValueMap: {type: Object},
+      currentUser: {type: Object},
+      queryParams: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.isApproval = false;
+    this.fieldGroups = HARDCODED_FIELD_GROUPS;
+    this.issueRef = {};
+
+    // Default built in fields used by issue metadata.
+    this.builtInFieldSpec = [
+      'Owner', 'CC', cueNameToSpec(cueNames.AVAILABILITY_MSGS),
+      'Status', 'MergedInto', 'Components', 'Modified', 'SLO',
+    ];
+    this.fieldValueMap = new Map();
+
+    this.approvalStatus = undefined;
+    this.approvers = undefined;
+    this.setter = undefined;
+    this.cc = undefined;
+    this.components = undefined;
+    this.fieldDefs = undefined;
+    this.issue = undefined;
+    this.issueStatus = undefined;
+    this.issueType = undefined;
+    this.mergedInto = undefined;
+    this.owner = undefined;
+    this.modifiedTimestamp = undefined;
+    this.currentUser = undefined;
+    this.queryParams = {};
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    // This is set for accessibility. Do not override.
+    this.setAttribute('role', 'table');
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.fieldValueMap = issueV0.fieldValueMap(state);
+    this.issue = issueV0.viewedIssue(state);
+    this.issueType = issueV0.type(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.relatedIssues = issueV0.relatedIssues(state);
+    this.currentUser = userV0.currentUser(state);
+    this.queryParams = sitewide.queryParams(state);
+  }
+}
+
+customElements.define('mr-metadata', MrMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js
new file mode 100644
index 0000000..d9dcd25
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js
@@ -0,0 +1,345 @@
+// 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 {MrMetadata} from './mr-metadata.js';
+
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+
+let element;
+
+describe('mr-metadata', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-metadata');
+    document.body.appendChild(element);
+
+    element.issueRef = {projectName: 'proj'};
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrMetadata);
+  });
+
+  it('has table role set', () => {
+    assert.equal(element.getAttribute('role'), 'table');
+  });
+
+  describe('default issue fields', () => {
+    it('renders empty Owner', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-owner');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'Owner:');
+      assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+    });
+
+    it('renders populated Owner', async () => {
+      element.owner = {displayName: 'test@example.com'};
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-owner');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('mr-user-link');
+
+      assert.equal(labelElement.textContent, 'Owner:');
+      assert.include(dataElement.shadowRoot.textContent.trim(),
+          'test@example.com');
+    });
+
+    it('renders empty CC', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-cc');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'CC:');
+      assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+    });
+
+    it('renders multiple CCed users', async () => {
+      element.cc = [
+        {displayName: 'test@example.com'},
+        {displayName: 'hello@example.com'},
+      ];
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-cc');
+      const labelElement = tr.querySelector('th');
+      const dataElements = tr.querySelectorAll('mr-user-link');
+
+      assert.equal(labelElement.textContent, 'CC:');
+      assert.include(dataElements[0].shadowRoot.textContent.trim(),
+          'test@example.com');
+      assert.include(dataElements[1].shadowRoot.textContent.trim(),
+          'hello@example.com');
+    });
+
+    it('renders empty Status', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-status');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'Status:');
+      assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+    });
+
+    it('renders populated Status', async () => {
+      element.issueStatus = {status: 'Fixed', meansOpen: false};
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-status');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'Status:');
+      assert.equal(dataElement.textContent.trim(), 'Fixed (Closed)');
+    });
+
+    it('hides empty MergedInto', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+      assert.isNull(tr);
+    });
+
+    it('hides MergedInto when Status is not Duplicate', async () => {
+      element.issueStatus = {status: 'test'};
+      element.mergedInto = {projectName: 'chromium', localId: 22};
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+      assert.isNull(tr);
+    });
+
+    it('shows MergedInto when Status is Duplicate', async () => {
+      element.issueStatus = {status: 'Duplicate'};
+      element.mergedInto = {projectName: 'chromium', localId: 22};
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('mr-issue-link');
+
+      assert.equal(labelElement.textContent, 'MergedInto:');
+      assert.equal(dataElement.shadowRoot.textContent.trim(),
+          'Issue chromium:22');
+    });
+
+    it('renders empty Components', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-components');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'Components:');
+      assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+    });
+
+    it('renders multiple Components', async () => {
+      element.components = [
+        {path: 'Test', docstring: 'i got docs'},
+        {path: 'Test>Nothing'},
+      ];
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-components');
+      const labelElement = tr.querySelector('th');
+      const dataElements = tr.querySelectorAll('td > a');
+
+      assert.equal(labelElement.textContent, 'Components:');
+
+      assert.equal(dataElements[0].textContent.trim(), 'Test');
+      assert.equal(dataElements[0].title, 'Test = i got docs');
+
+      assert.equal(dataElements[1].textContent.trim(), 'Test>Nothing');
+      assert.equal(dataElements[1].title, 'Test>Nothing');
+    });
+
+    it('renders empty Modified', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-modified');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'Modified:');
+      assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+    });
+
+    it('renders populated Modified', async () => {
+      element.modifiedTimestamp = 1234;
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-modified');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('chops-timestamp');
+
+      assert.equal(labelElement.textContent, 'Modified:');
+      assert.equal(dataElement.timestamp, 1234);
+    });
+
+    it('does not render SLO if user not in experiment', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-slo');
+      assert.isNull(tr);
+    });
+
+    it('renders SLO if user in experiment', async () => {
+      element.currentUser = {displayName: 'jessan@google.com'};
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-slo');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('mr-issue-slo');
+
+      assert.equal(labelElement.textContent, 'SLO:');
+      assert.equal(dataElement.shadowRoot.textContent.trim(), 'N/A');
+    });
+  });
+
+  describe('approval fields', () => {
+    beforeEach(() => {
+      element.builtInFieldSpec = ['ApprovalStatus', 'Approvers', 'Setter',
+        'cue.availability_msgs'];
+    });
+
+    it('renders empty ApprovalStatus', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-approvalstatus');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'Status:');
+      assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+    });
+
+    it('renders populated ApprovalStatus', async () => {
+      element.approvalStatus = 'Approved';
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-approvalstatus');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'Status:');
+      assert.equal(dataElement.textContent.trim(), 'Approved');
+    });
+
+    it('renders empty Approvers', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-approvers');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'Approvers:');
+      assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+    });
+
+    it('renders multiple Approvers', async () => {
+      element.approvers = [
+        {displayName: 'test@example.com'},
+        {displayName: 'hello@example.com'},
+      ];
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-approvers');
+      const labelElement = tr.querySelector('th');
+      const dataElements = tr.querySelectorAll('mr-user-link');
+
+      assert.equal(labelElement.textContent, 'Approvers:');
+      assert.include(dataElements[0].shadowRoot.textContent.trim(),
+          'test@example.com');
+      assert.include(dataElements[1].shadowRoot.textContent.trim(),
+          'hello@example.com');
+    });
+
+    it('hides empty Setter', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-setter');
+
+      assert.isNull(tr);
+    });
+
+    it('renders populated Setter', async () => {
+      element.setter = {displayName: 'test@example.com'};
+
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-setter');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('mr-user-link');
+
+      assert.equal(labelElement.textContent, 'Setter:');
+      assert.include(dataElement.shadowRoot.textContent.trim(),
+          'test@example.com');
+    });
+
+    it('renders cue.availability_msgs', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector(
+          'tr.cue-availability_msgs');
+      const cueElement = tr.querySelector('mr-cue');
+
+      assert.isDefined(cueElement);
+    });
+  });
+
+  describe('custom config', () => {
+    beforeEach(() => {
+      element.builtInFieldSpec = ['owner', 'fakefield'];
+    });
+
+    it('owner still renders when lowercase', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-owner');
+      const labelElement = tr.querySelector('th');
+      const dataElement = tr.querySelector('td');
+
+      assert.equal(labelElement.textContent, 'owner:');
+      assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+    });
+
+    it('fakefield does not render', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.row-fakefield');
+
+      assert.isNull(tr);
+    });
+
+    it('cue.availability_msgs does not render when not configured', async () => {
+      await element.updateComplete;
+
+      const tr = element.shadowRoot.querySelector('tr.cue-availability_msgs');
+
+      assert.isNull(tr);
+    });
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js
new file mode 100644
index 0000000..2d74c10
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js
@@ -0,0 +1,452 @@
+// 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} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/chops/chops-collapse/chops-collapse.js';
+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 ui from 'reducers/ui.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import 'elements/framework/mr-comment-content/mr-description.js';
+import '../mr-comment-list/mr-comment-list.js';
+import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
+import 'elements/issue-detail/metadata/mr-metadata/mr-metadata.js';
+import {APPROVER_RESTRICTED_STATUSES, STATUS_ENUM_TO_TEXT, TEXT_TO_STATUS_ENUM,
+  STATUS_CLASS_MAP, CLASS_ICON_MAP, APPROVAL_STATUSES,
+} from 'shared/consts/approval.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+import {cueNames, cueNameToSpec} from 'elements/help/mr-cue/cue-helpers.js';
+
+
+/**
+ * @type {Array<string>} The list of built in metadata fields to show on
+ *   issue approvals.
+ */
+const APPROVAL_METADATA_FIELDS = ['ApprovalStatus', 'Approvers', 'Setter',
+  cueNameToSpec(cueNames.AVAILABILITY_MSGS)];
+
+/**
+ * `<mr-approval-card>`
+ *
+ * This element shows a card for a single approval.
+ *
+ */
+export class MrApprovalCard extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <style>
+        mr-approval-card {
+          width: 100%;
+          background-color: var(--chops-white);
+          font-size: var(--chops-main-font-size);
+          border-bottom: var(--chops-normal-border);
+          box-sizing: border-box;
+          display: block;
+          border-left: 4px solid var(--approval-bg-color);
+
+          /* Default styles are for the NotSet/NeedsReview case. */
+          --approval-bg-color: var(--chops-purple-50);
+          --approval-accent-color: var(--chops-purple-700);
+        }
+        mr-approval-card.status-na {
+          --approval-bg-color: hsl(227, 20%, 92%);
+          --approval-accent-color: hsl(227, 80%, 40%);
+        }
+        mr-approval-card.status-approved {
+          --approval-bg-color: hsl(78, 55%, 90%);
+          --approval-accent-color: hsl(78, 100%, 30%);
+        }
+        mr-approval-card.status-pending {
+          --approval-bg-color: hsl(40, 75%, 90%);
+          --approval-accent-color: hsl(33, 100%, 39%);
+        }
+        mr-approval-card.status-rejected {
+          --approval-bg-color: hsl(5, 60%, 92%);
+          --approval-accent-color: hsl(357, 100%, 39%);
+        }
+        mr-approval-card chops-button.edit-survey {
+          border: var(--chops-normal-border);
+          margin: 0;
+        }
+        mr-approval-card h3 {
+          margin: 0;
+          padding: 0;
+          display: inline;
+          font-weight: inherit;
+          font-size: inherit;
+          line-height: inherit;
+        }
+        mr-approval-card mr-description {
+          display: block;
+          margin-bottom: 0.5em;
+        }
+        .approver-notice {
+          padding: 0.25em 0;
+          width: 100%;
+          display: flex;
+          flex-direction: row;
+          align-items: baseline;
+          justify-content: space-between;
+          border-bottom: 1px dotted hsl(0, 0%, 83%);
+        }
+        .card-content {
+          box-sizing: border-box;
+          padding: 0.5em 16px;
+          padding-bottom: 1em;
+        }
+        .expand-icon {
+          display: block;
+          margin-right: 8px;
+          color: hsl(0, 0%, 45%);
+        }
+        mr-approval-card .header {
+          margin: 0;
+          width: 100%;
+          border: 0;
+          font-size: var(--chops-large-font-size);
+          font-weight: normal;
+          box-sizing: border-box;
+          display: flex;
+          align-items: center;
+          flex-direction: row;
+          padding: 0.5em 8px;
+          background-color: var(--approval-bg-color);
+          cursor: pointer;
+        }
+        mr-approval-card .status {
+          font-size: var(--chops-main-font-size);
+          color: var(--approval-accent-color);
+          display: inline-flex;
+          align-items: center;
+          margin-left: 32px;
+        }
+        mr-approval-card .survey {
+          padding: 0.5em 0;
+          max-height: 500px;
+          overflow-y: auto;
+          max-width: 100%;
+          box-sizing: border-box;
+        }
+        mr-approval-card [role="heading"] {
+          display: flex;
+          flex-direction: row;
+          justify-content: space-between;
+          align-items: flex-end;
+        }
+        mr-approval-card .edit-header {
+          margin-top: 40px;
+        }
+      </style>
+      <button
+        class="header"
+        @click=${this.toggleCard}
+        aria-expanded=${(this.opened || false).toString()}
+      >
+        <i class="material-icons expand-icon">
+          ${this.opened ? 'expand_less' : 'expand_more'}
+        </i>
+        <h3>${this.fieldName}</h3>
+        <span class="status">
+          <i class="material-icons status-icon" role="presentation">
+            ${CLASS_ICON_MAP[this._statusClass]}
+          </i>
+          ${this._status}
+        </span>
+      </button>
+      <chops-collapse class="card-content" ?opened=${this.opened}>
+        <div class="approver-notice">
+          ${this._isApprover ? html`
+            You are an approver for this bit.
+          `: ''}
+          ${this.user && this.user.isSiteAdmin ? html`
+            Your site admin privileges give you full access to edit this approval.
+          `: ''}
+        </div>
+        <mr-metadata
+          aria-label="${this.fieldName} Approval Metadata"
+          .approvalStatus=${this._status}
+          .approvers=${this.approvers}
+          .setter=${this.setter}
+          .fieldDefs=${this.fieldDefs}
+          .builtInFieldSpec=${APPROVAL_METADATA_FIELDS}
+          isApproval
+        ></mr-metadata>
+        <h4
+          class="medium-heading"
+          role="heading"
+        >
+          ${this.fieldName} Survey
+          <chops-button class="edit-survey" @click=${this._openSurveyEditor}>
+            Edit responses
+          </chops-button>
+        </h4>
+        <mr-description
+          class="survey"
+          .descriptionList=${this._allSurveys}
+        ></mr-description>
+        <mr-comment-list
+          headingLevel=4
+          .comments=${this.comments}
+        ></mr-comment-list>
+        ${this.issuePermissions.includes('addissuecomment') ? html`
+          <h4 id="edit${this.fieldName}" class="medium-heading edit-header">
+            Editing approval: ${this.phaseName} &gt; ${this.fieldName}
+          </h4>
+          <mr-edit-metadata
+            .formName="${this.phaseName} > ${this.fieldName}"
+            .approvers=${this.approvers}
+            .fieldDefs=${this.fieldDefs}
+            .statuses=${this._availableStatuses}
+            .status=${this._status}
+            .error=${this.updateError && (this.updateError.description || this.updateError.message)}
+            ?saving=${this.updatingApproval}
+            ?hasApproverPrivileges=${this._hasApproverPrivileges}
+            isApproval
+            @save=${this.save}
+            @discard=${this.reset}
+          ></mr-edit-metadata>
+        ` : ''}
+      </chops-collapse>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      fieldName: {type: String},
+      approvers: {type: Array},
+      phaseName: {type: String},
+      setter: {type: Object},
+      fieldDefs: {type: Array},
+      focusId: {type: String},
+      user: {type: Object},
+      issue: {type: Object},
+      issueRef: {type: Object},
+      issuePermissions: {type: Array},
+      projectConfig: {type: Object},
+      comments: {type: String},
+      opened: {
+        type: Boolean,
+        reflect: true,
+      },
+      statusEnum: {type: String},
+      updatingApproval: {type: Boolean},
+      updateError: {type: Object},
+      _allSurveys: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.opened = false;
+    this.comments = [];
+    this.fieldDefs = [];
+    this.issuePermissions = [];
+    this._allSurveys = [];
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    const fieldDefsByApproval = projectV0.fieldDefsByApprovalName(state);
+    if (fieldDefsByApproval && this.fieldName &&
+        fieldDefsByApproval.has(this.fieldName)) {
+      this.fieldDefs = fieldDefsByApproval.get(this.fieldName);
+    }
+    const commentsByApproval = issueV0.commentsByApprovalName(state);
+    if (commentsByApproval && this.fieldName &&
+        commentsByApproval.has(this.fieldName)) {
+      const comments = commentsByApproval.get(this.fieldName);
+      this.comments = comments.slice(1);
+      this._allSurveys = commentListToDescriptionList(comments);
+    }
+    this.focusId = ui.focusId(state);
+    this.user = userV0.currentUser(state);
+    this.issue = issueV0.viewedIssue(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.projectConfig = projectV0.viewedConfig(state);
+    this.updatingApproval = issueV0.requests(state).updateApproval.requesting;
+    this.updateError = issueV0.requests(state).updateApproval.error;
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if ((changedProperties.has('comments') ||
+        changedProperties.has('focusId')) && this.comments) {
+      const focused = this.comments.find(
+          (comment) => `c${comment.sequenceNum}` === this.focusId);
+      if (focused) {
+        // Make sure to open the card when a comment is focused.
+        this.opened = true;
+      }
+    }
+    if (changedProperties.has('statusEnum')) {
+      this.setAttribute('class', this._statusClass);
+    }
+    if (changedProperties.has('user') || changedProperties.has('approvers')) {
+      if (this._isApprover) {
+        // Open the card by default if the user is an approver.
+        this.opened = true;
+      }
+    }
+    super.update(changedProperties);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('issue')) {
+      this.reset();
+    }
+  }
+
+  /**
+   * Resets the approval edit form.
+   */
+  reset() {
+    const form = this.querySelector('mr-edit-metadata');
+    if (!form) return;
+    form.reset();
+  }
+
+  /**
+   * Saves the user's changes in the approval update form.
+   */
+  async save() {
+    const form = this.querySelector('mr-edit-metadata');
+    const delta = form.delta;
+
+    if (delta.status) {
+      delta.status = TEXT_TO_STATUS_ENUM[delta.status];
+    }
+
+    // TODO(ehmaldonado): Show snackbar on change, and prevent starring issues
+    // to resetting the form.
+
+    const message = {
+      issueRef: this.issueRef,
+      fieldRef: {
+        type: fieldTypes.APPROVAL_TYPE,
+        fieldName: this.fieldName,
+      },
+      approvalDelta: delta,
+      commentContent: form.getCommentContent(),
+      sendEmail: form.sendEmail,
+    };
+
+    // Add files to message.
+    const uploads = await form.getAttachments();
+
+    if (uploads && uploads.length) {
+      message.uploads = uploads;
+    }
+
+    if (message.commentContent || message.approvalDelta || message.uploads) {
+      store.dispatch(issueV0.updateApproval(message));
+    }
+  }
+
+  /**
+   * Opens and closes the approval card.
+   */
+  toggleCard() {
+    this.opened = !this.opened;
+  }
+
+  /**
+   * @return {string} The CSS class used to style the approval card,
+   *   given its status.
+   * @private
+   */
+  get _statusClass() {
+    return STATUS_CLASS_MAP[this._status];
+  }
+
+  /**
+   * @return {string} The human readable value of an approval status.
+   * @private
+   */
+  get _status() {
+    return STATUS_ENUM_TO_TEXT[this.statusEnum || ''];
+  }
+
+  /**
+   * @return {boolean} Whether the user is an approver or not.
+   * @private
+   */
+  get _isApprover() {
+    // Assumption: Since a user who is an approver should always be a project
+    // member, displayNames should be visible to them if they are an approver.
+    if (!this.approvers || !this.user || !this.user.displayName) return false;
+    const userGroups = this.user.groups || [];
+    return !!this.approvers.find((a) => {
+      return a.displayName === this.user.displayName || userGroups.find(
+          (group) => group.displayName === a.displayName,
+      );
+    });
+  }
+
+  /**
+   * @return {boolean} Whether the user can approver the approval or not.
+   *   Not the same as _isApprover because site admins can approve approvals
+   *   even if they are not approvers.
+   * @private
+   */
+  get _hasApproverPrivileges() {
+    return (this.user && this.user.isSiteAdmin) || this._isApprover;
+  }
+
+  /**
+   * @return {Array<StatusDef>}
+   * @private
+   */
+  get _availableStatuses() {
+    return APPROVAL_STATUSES.filter((s) => {
+      if (s.status === this._status) {
+        // The current status should always appear as an option.
+        return true;
+      }
+
+      if (!this._hasApproverPrivileges &&
+          APPROVER_RESTRICTED_STATUSES.has(s.status)) {
+        // If you are not an approver and and this status is restricted,
+        // you can't change to this status.
+        return false;
+      }
+
+      // No one can set statuses to NotSet, not even approvers.
+      return s.status !== 'NotSet';
+    });
+  }
+
+  /**
+   * Launches the description editing dialog for the survey.
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _openSurveyEditor() {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'edit-description',
+        fieldName: this.fieldName,
+      },
+    }));
+  }
+}
+
+customElements.define('mr-approval-card', MrApprovalCard);
diff --git a/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js
new file mode 100644
index 0000000..0424c21
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js
@@ -0,0 +1,245 @@
+// 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 {MrApprovalCard} from './mr-approval-card.js';
+
+let element;
+
+describe('mr-approval-card', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-approval-card');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrApprovalCard);
+  });
+
+  it('_isApprover true when user is an approver', () => {
+    // User not in approver list.
+    element.approvers = [
+      {displayName: 'tester@user.com'},
+      {displayName: 'test@notuser.com'},
+      {displayName: 'hello@world.com'},
+    ];
+    element.user = {displayName: 'test@user.com', groups: []};
+    assert.isFalse(element._isApprover);
+
+    // Use is in approver list.
+    element.approvers = [
+      {displayName: 'tester@user.com'},
+      {displayName: 'test@notuser.com'},
+      {displayName: 'hello@world.com'},
+      {displayName: 'test@user.com'},
+    ];
+    assert.isTrue(element._isApprover);
+
+    // User's group is not in the list.
+    element.approvers = [
+      {displayName: 'tester@user.com'},
+      {displayName: 'nongroup@group.com'},
+      {displayName: 'group@nongroup.com'},
+      {displayName: 'ignore@test.com'},
+    ];
+    element.user = {
+      displayName: 'test@user.com',
+      groups: [
+        {displayName: 'group@group.com'},
+        {displayName: 'test@group.com'},
+        {displayName: 'group@user.com'},
+      ],
+    };
+    assert.isFalse(element._isApprover);
+
+    // User's group is in the list.
+    element.approvers = [
+      {displayName: 'tester@user.com'},
+      {displayName: 'group@group.com'},
+      {displayName: 'test@notuser.com'},
+    ];
+    element.user = {
+      displayName: 'test@user.com',
+      groups: [
+        {displayName: 'group@group.com'},
+      ],
+    };
+    assert.isTrue(element._isApprover);
+  });
+
+  it('approvals change color based on status', async () => {
+    // Initialize dependent CSS property from a stylesheet not included in
+    // our testing environment.
+    element.style.setProperty('--chops-purple-50', '#f3e5f5');
+
+    element.statusEnum = 'NEEDS_REVIEW';
+    await element.updateComplete;
+
+    const header = element.querySelector('button.header');
+
+    // Purple. Note that Chrome uses RGB for computed styles regardless of
+    // underlying CSS.
+    assert.equal(
+      window.getComputedStyle(header).getPropertyValue('background-color'),
+      'rgb(243, 229, 245)');
+
+    element.statusEnum = 'APPROVED';
+    await element.updateComplete;
+
+    // Green.
+    assert.equal(
+      window.getComputedStyle(header).getPropertyValue('background-color'),
+      'rgb(235, 244, 215)');
+  });
+
+  it('site admins have approver privileges', async () => {
+    await element.updateComplete;
+
+    const notice = element.querySelector('.approver-notice');
+    assert.equal(notice.textContent.trim(), '');
+
+    element.user = {isSiteAdmin: true};
+    await element.updateComplete;
+
+    assert.isTrue(element._hasApproverPrivileges);
+
+    assert.equal(notice.textContent.trim(),
+        'Your site admin privileges give you full access to edit this approval.',
+    );
+  });
+
+  it('site admins see all approval statuses except NotSet', () => {
+    element.user = {isSiteAdmin: true};
+
+    assert.isFalse(element._isApprover);
+
+    element.statusEnum = 'NEEDS_REVIEW';
+
+    assert.equal(element._availableStatuses.length, 7);
+    assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+    assert.equal(element._availableStatuses[1].status, 'NA');
+    assert.equal(element._availableStatuses[2].status, 'ReviewRequested');
+    assert.equal(element._availableStatuses[3].status, 'ReviewStarted');
+    assert.equal(element._availableStatuses[4].status, 'NeedInfo');
+    assert.equal(element._availableStatuses[5].status, 'Approved');
+    assert.equal(element._availableStatuses[6].status, 'NotApproved');
+  });
+
+  it('approvers see all approval statuses except NotSet', () => {
+    element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+    element.approvers = [{displayName: 'test@email.com'}];
+
+    assert.isTrue(element._isApprover);
+
+    element.statusEnum = 'NEEDS_REVIEW';
+
+    assert.equal(element._availableStatuses.length, 7);
+    assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+    assert.equal(element._availableStatuses[1].status, 'NA');
+    assert.equal(element._availableStatuses[2].status, 'ReviewRequested');
+    assert.equal(element._availableStatuses[3].status, 'ReviewStarted');
+    assert.equal(element._availableStatuses[4].status, 'NeedInfo');
+    assert.equal(element._availableStatuses[5].status, 'Approved');
+    assert.equal(element._availableStatuses[6].status, 'NotApproved');
+  });
+
+  it('non-approvers see non-restricted approval statuses', () => {
+    element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+    element.approvers = [{displayName: 'test@otheremail.com'}];
+
+    assert.isFalse(element._isApprover);
+
+    element.statusEnum = 'NEEDS_REVIEW';
+
+    assert.equal(element._availableStatuses.length, 4);
+    assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+    assert.equal(element._availableStatuses[1].status, 'ReviewRequested');
+    assert.equal(element._availableStatuses[2].status, 'ReviewStarted');
+    assert.equal(element._availableStatuses[3].status, 'NeedInfo');
+  });
+
+  it('non-approvers see restricted approval status when set', () => {
+    element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+    element.approvers = [{displayName: 'test@otheremail.com'}];
+
+    assert.isFalse(element._isApprover);
+
+    element.statusEnum = 'APPROVED';
+
+    assert.equal(element._availableStatuses.length, 5);
+    assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+    assert.equal(element._availableStatuses[1].status, 'ReviewRequested');
+    assert.equal(element._availableStatuses[2].status, 'ReviewStarted');
+    assert.equal(element._availableStatuses[3].status, 'NeedInfo');
+    assert.equal(element._availableStatuses[4].status, 'Approved');
+  });
+
+  it('expands to show focused comment', async () => {
+    element.focusId = 'c4';
+    element.fieldName = 'field';
+    element.comments = [
+      {
+        sequenceNum: 1,
+        approvalRef: {fieldName: 'other-field'},
+      },
+      {
+        sequenceNum: 2,
+        approvalRef: {fieldName: 'field'},
+      },
+      {
+        sequenceNum: 3,
+      },
+      {
+        sequenceNum: 4,
+        approvalRef: {fieldName: 'field'},
+      },
+    ];
+
+    await element.updateComplete;
+
+    assert.isTrue(element.opened);
+  });
+
+  it('does not expand to show focused comment on other elements', async () => {
+    element.focusId = 'c3';
+    element.comments = [
+      {
+        sequenceNum: 1,
+        approvalRef: {fieldName: 'other-field'},
+      },
+      {
+        sequenceNum: 2,
+        approvalRef: {fieldName: 'field'},
+      },
+      {
+        sequenceNum: 4,
+        approvalRef: {fieldName: 'field'},
+      },
+    ];
+
+    await element.updateComplete;
+
+    assert.isFalse(element.opened);
+  });
+
+  it('mr-edit-metadata is displayed if user has addissuecomment', async () => {
+    element.issuePermissions = ['addissuecomment'];
+
+    await element.updateComplete;
+
+    assert.isNotNull(element.querySelector('mr-edit-metadata'));
+  });
+
+  it('mr-edit-metadata is hidden if user has no addissuecomment', async () => {
+    element.issuePermissions = [];
+
+    await element.updateComplete;
+
+    assert.isNull(element.querySelector('mr-edit-metadata'));
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js
new file mode 100644
index 0000000..aad9a8a
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js
@@ -0,0 +1,163 @@
+// 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 {cache} from 'lit-html/directives/cache.js';
+import {LitElement, html, css} from 'lit-element';
+
+import '../../chops/chops-button/chops-button.js';
+import './mr-comment.js';
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import {userIsMember} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-comment-list>`
+ *
+ * Display a list of Monorail comments.
+ *
+ */
+export class MrCommentList extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+
+    this.commentsShownCount = 2;
+    this.comments = [];
+    this.headingLevel = 4;
+
+    this.focusId = null;
+
+    this.usersProjects = new Map();
+
+    this._hideComments = true;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      commentsShownCount: {type: Number},
+      comments: {type: Array},
+      headingLevel: {type: Number},
+
+      focusId: {type: String},
+
+      usersProjects: {type: Object},
+
+      _hideComments: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.focusId = ui.focusId(state);
+    this.usersProjects = userV0.projectsPerUser(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (!this._hideComments) return;
+
+    // If any hidden comment is focused, show all hidden comments.
+    const hiddenCount =
+      _hiddenCount(this.comments.length, this.commentsShownCount);
+    const hiddenComments = this.comments.slice(0, hiddenCount);
+    for (const comment of hiddenComments) {
+      if ('c' + comment.sequenceNum === this.focusId) {
+        this._hideComments = false;
+        break;
+      }
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [SHARED_STYLES, css`
+      button.toggle {
+        background: none;
+        color: var(--chops-link-color);
+        border: 0;
+        border-bottom: var(--chops-normal-border);
+        border-top: var(--chops-normal-border);
+        width: 100%;
+        padding: 0.5em 8px;
+        text-align: left;
+        font-size: var(--chops-main-font-size);
+      }
+      button.toggle:hover {
+        cursor: pointer;
+        text-decoration: underline;
+      }
+      button.toggle[hidden] {
+        display: none;
+      }
+      .edit-slot {
+        margin-top: 3em;
+      }
+    `];
+  }
+
+  /** @override */
+  render() {
+    const hiddenCount =
+      _hiddenCount(this.comments.length, this.commentsShownCount);
+    return html`
+      <button @click=${this._toggleHide}
+          class="toggle"
+          ?hidden=${hiddenCount <= 0}>
+        ${this._hideComments ? 'Show' : 'Hide'}
+        ${hiddenCount}
+        older
+        ${hiddenCount == 1 ? 'comment' : 'comments'}
+      </button>
+      ${cache(this._hideComments ? '' :
+    html`${this.comments.slice(0, hiddenCount).map(
+        this.renderComment.bind(this))}`)}
+      ${this.comments.slice(hiddenCount).map(this.renderComment.bind(this))}
+    `;
+  }
+
+  /**
+   * Helper to render a single comment.
+   * @param {Comment} comment
+   * @return {TemplateResult}
+   */
+  renderComment(comment) {
+    const commenterIsMember = userIsMember(
+        comment.commenter, comment.projectName, this.usersProjects);
+    return html`
+      <mr-comment
+          .comment=${comment}
+          headingLevel=${this.headingLevel}
+          ?highlighted=${'c' + comment.sequenceNum === this.focusId}
+          ?commenterIsMember=${commenterIsMember}
+      ></mr-comment>`;
+  }
+
+  /**
+   * Hides or unhides comments that are hidden by default. For example,
+   * if an issue has 200 comments, the first 100 comments are shown initially,
+   * then the last 100 can be toggled to be shown.
+   * @private
+   */
+  _toggleHide() {
+    this._hideComments = !this._hideComments;
+  }
+}
+
+/**
+ * Computes how many comments the user is able to expand.
+ * @param {number} commentCount Total comments.
+ * @param {number} commentsShownCount The number of comments shown.
+ * @return {number} The number of hidden comments.
+ * @private
+ */
+function _hiddenCount(commentCount, commentsShownCount) {
+  return Math.max(commentCount - commentsShownCount, 0);
+}
+
+customElements.define('mr-comment-list', MrCommentList);
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js
new file mode 100644
index 0000000..548b7a7
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js
@@ -0,0 +1,108 @@
+// 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 {MrCommentList} from './mr-comment-list.js';
+
+
+let element;
+
+describe('mr-comment-list', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-comment-list');
+    document.body.appendChild(element);
+    element.comments = [
+      {
+        canFlag: true,
+        localId: 898395,
+        canDelete: true,
+        projectName: 'chromium',
+        commenter: {
+          displayName: 'user@example.com',
+          userId: '12345',
+        },
+        content: 'foo',
+        sequenceNum: 1,
+        timestamp: 1549319989,
+      },
+      {
+        canFlag: true,
+        localId: 898395,
+        canDelete: true,
+        projectName: 'chromium',
+        commenter: {
+          displayName: 'user@example.com',
+          userId: '12345',
+        },
+        content: 'foo',
+        sequenceNum: 2,
+        timestamp: 1549320089,
+      },
+      {
+        canFlag: true,
+        localId: 898395,
+        canDelete: true,
+        projectName: 'chromium',
+        commenter: {
+          displayName: 'user@example.com',
+          userId: '12345',
+        },
+        content: 'foo',
+        sequenceNum: 3,
+        timestamp: 1549320189,
+      },
+    ];
+
+    // Stub RAF to execute immediately.
+    sinon.stub(window, 'requestAnimationFrame').callsFake((func) => func());
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    window.requestAnimationFrame.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCommentList);
+  });
+
+  it('scrolls to comment', async () => {
+    await element.updateComplete;
+
+    const commentElements = element.shadowRoot.querySelectorAll('mr-comment');
+    const commentElement = commentElements[commentElements.length - 1];
+    sinon.stub(commentElement, 'scrollIntoView');
+
+    element.focusId = 'c3';
+
+    await element.updateComplete;
+
+    assert.isTrue(element._hideComments);
+    assert.isTrue(commentElement.scrollIntoView.calledOnce);
+
+    commentElement.scrollIntoView.restore();
+  });
+
+  it('scrolls to hidden comment', async () => {
+    await element.updateComplete;
+
+    element.focusId = 'c1';
+
+    await element.updateComplete;
+
+    assert.isFalse(element._hideComments);
+    // TODO: Check that the comment has been scrolled into view.
+  });
+
+  it('doesnt scroll to unknown comment', async () => {
+    await element.updateComplete;
+
+    element.focusId = 'c100';
+
+    await element.updateComplete;
+
+    assert.isTrue(element._hideComments);
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment.js
new file mode 100644
index 0000000..e56bef3
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment.js
@@ -0,0 +1,416 @@
+// 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 {store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import 'elements/framework/mr-comment-content/mr-comment-content.js';
+import 'elements/framework/mr-comment-content/mr-attachment.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {issueStringToRef} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+const ISSUE_REF_FIELD_NAMES = [
+  'Blocking',
+  'Blockedon',
+  'Mergedinto',
+];
+
+/**
+ * `<mr-comment>`
+ *
+ * A component for an individual comment.
+ *
+ */
+export class MrComment extends LitElement {
+  /** @override */
+  constructor() {
+    super();
+
+    this._isExpandedIfDeleted = false;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      comment: {type: Object},
+      headingLevel: {type: String},
+      highlighted: {
+        type: Boolean,
+        reflect: true,
+      },
+      commenterIsMember: {type: Boolean},
+      _isExpandedIfDeleted: {type: Boolean},
+      _showOriginalContent: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('highlighted') && this.highlighted) {
+      window.requestAnimationFrame(() => {
+        this.scrollIntoView();
+        // TODO(ehmaldonado): Figure out a way to get the height from the issue
+        // header, and scroll by that amount.
+        window.scrollBy(0, -150);
+      });
+    }
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+          margin: 1.5em 0 0 0;
+        }
+        :host([highlighted]) {
+          border: 1px solid var(--chops-primary-accent-color);
+          box-shadow: 0 0 4px 4px var(--chops-active-choice-bg);
+        }
+        :host([hidden]) {
+          display: none;
+        }
+        .comment-header {
+          background: var(--chops-card-heading-bg);
+          padding: 3px 1px 1px 8px;
+          width: 100%;
+          display: flex;
+          flex-direction: row;
+          justify-content: space-between;
+          align-items: center;
+          box-sizing: border-box;
+        }
+        .comment-header a {
+          display: inline-flex;
+        }
+        .role-label {
+          background-color: var(--chops-gray-600);
+          border-radius: 3px;
+          color: var(--chops-white);
+          display: inline-block;
+          padding: 2px 4px;
+          font-size: 75%;
+          font-weight: bold;
+          line-height: 14px;
+          vertical-align: text-bottom;
+          margin-left: 16px;
+        }
+        .comment-options {
+          float: right;
+          text-align: right;
+          text-decoration: none;
+        }
+        .comment-body {
+          margin: 4px;
+          box-sizing: border-box;
+        }
+        .deleted-comment-notice {
+          margin-left: 4px;
+        }
+        .issue-diff {
+          background: var(--chops-card-details-bg);
+          display: inline-block;
+          padding: 4px 8px;
+          width: 100%;
+          box-sizing: border-box;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      ${this._renderHeading()}
+      ${_shouldShowComment(this._isExpandedIfDeleted, this.comment) ? html`
+        ${this._renderDiff()}
+        ${this._renderBody()}
+      ` : ''}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderHeading() {
+    return html`
+      <div
+        role="heading"
+        aria-level=${this.headingLevel}
+        class="comment-header">
+        <div>
+          <a
+            href="?id=${this.comment.localId}#c${this.comment.sequenceNum}"
+            class="comment-link"
+          >Comment ${this.comment.sequenceNum}</a>
+
+          ${this._renderByline()}
+        </div>
+        ${_shouldOfferCommentOptions(this.comment) ? html`
+          <div class="comment-options">
+            <mr-dropdown
+              .items=${this._commentOptions}
+              label="Comment options"
+              icon="more_vert"
+            ></mr-dropdown>
+          </div>
+        ` : ''}
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderByline() {
+    if (_shouldShowComment(this._isExpandedIfDeleted, this.comment)) {
+      return html`
+        by
+        <mr-user-link .userRef=${this.comment.commenter}></mr-user-link>
+        on
+        <chops-timestamp
+          .timestamp=${this.comment.timestamp}
+        ></chops-timestamp>
+        ${this.commenterIsMember && !this.comment.isDeleted ? html`
+          <span class="role-label">Project Member</span>` : ''}
+      `;
+    } else {
+      return html`<span class="deleted-comment-notice">Deleted</span>`;
+    }
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderDiff() {
+    if (!(this.comment.descriptionNum || this.comment.amendments)) return '';
+
+    return html`
+      <div class="issue-diff">
+        ${(this.comment.amendments || []).map((delta) => html`
+          <strong>${delta.fieldName}:</strong>
+          ${_issuesForAmendment(delta, this.comment.projectName).map((issueForAmendment) => html`
+            <mr-issue-link
+              projectName=${this.comment.projectName}
+              .issue=${issueForAmendment.issue}
+              text=${issueForAmendment.text}
+            ></mr-issue-link>
+          `)}
+          ${!_amendmentHasIssueRefs(delta.fieldName) ? delta.newOrDeltaValue : ''}
+          ${delta.oldValue ? `(was: ${delta.oldValue})` : ''}
+          <br>
+        `)}
+        ${this.comment.descriptionNum ? 'Description was changed.' : ''}
+      </div><br>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderBody() {
+    const commentContent = this._showOriginalContent ?
+      this.comment.inboundMessage :
+      this.comment.content;
+    return html`
+      <div class="comment-body">
+        <mr-comment-content
+          ?hidden=${this.comment.descriptionNum}
+          .content=${commentContent}
+          .author=${this.comment.commenter.displayName}
+          ?isDeleted=${this.comment.isDeleted}
+        ></mr-comment-content>
+        <div ?hidden=${this.comment.descriptionNum}>
+          ${(this.comment.attachments || []).map((attachment) => html`
+            <mr-attachment
+              .attachment=${attachment}
+              projectName=${this.comment.projectName}
+              localId=${this.comment.localId}
+              sequenceNum=${this.comment.sequenceNum}
+              ?canDelete=${this.comment.canDelete}
+            ></mr-attachment>
+          `)}
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Displays three dot menu options available to the current user for a given
+   * comment.
+   * @return {Array<MenuItem>}
+   */
+  get _commentOptions() {
+    const options = [];
+    if (_canExpandDeletedComment(this.comment)) {
+      const text =
+        (this._isExpandedIfDeleted ? 'Hide' : 'Show') + ' comment content';
+      options.push({
+        text: text,
+        handler: this._toggleHideDeletedComment.bind(this),
+      });
+      options.push({separator: true});
+    }
+    if (this.comment.canDelete) {
+      const text =
+        (this.comment.isDeleted ? 'Undelete' : 'Delete') + ' comment';
+      options.push({
+        text: text,
+        handler: _deleteComment.bind(null, this.comment),
+      });
+    }
+    if (this.comment.canFlag) {
+      const text = (this.comment.isSpam ? 'Unflag' : 'Flag') + ' comment';
+      options.push({
+        text: text,
+        handler: _flagComment.bind(null, this.comment),
+      });
+    }
+    if (this.comment.inboundMessage) {
+      const text =
+        (this._showOriginalContent ? 'Hide' : 'Show') + ' original email';
+      options.push({
+        text: text,
+        handler: this._toggleShowOriginalContent.bind(this),
+      });
+    }
+    return options;
+  }
+
+  /**
+   * Toggles whether the email of the user who deleted the comment should be
+   * shown.
+   */
+  _toggleShowOriginalContent() {
+    this._showOriginalContent = !this._showOriginalContent;
+  }
+
+  /**
+   * Change if deleted content for a comment is shown or not.
+   */
+  _toggleHideDeletedComment() {
+    this._isExpandedIfDeleted = !this._isExpandedIfDeleted;
+  }
+}
+
+/**
+ * Says whether a comment should be shown or not.
+ * @param {boolean} isExpandedIfDeleted If the user has chosen to see the
+ *   deleted comment.
+ * @param {IssueComment} comment
+ * @return {boolean} If the comment should be shown.
+ */
+function _shouldShowComment(isExpandedIfDeleted, comment) {
+  return !comment.isDeleted || isExpandedIfDeleted;
+}
+
+/**
+ * Whether the user can view additional comment options like flagging or
+ * deleting.
+ * @param {IssueComment} comment
+ * @return {boolean}
+ */
+function _shouldOfferCommentOptions(comment) {
+  return comment.canDelete || comment.canFlag;
+}
+
+/**
+ * Whether a user has permission to view a given deleted comment.
+ * @param {IssueComment} comment
+ * @return {boolean}
+ */
+function _canExpandDeletedComment(comment) {
+  return ((comment.isSpam && comment.canFlag) ||
+          (comment.isDeleted && comment.canDelete));
+}
+
+/**
+ * Deletes a given comment or undeletes it if it's already deleted.
+ * @param {IssueComment} comment The comment to delete.
+ */
+async function _deleteComment(comment) {
+  const issueRef = {
+    projectName: comment.projectName,
+    localId: comment.localId,
+  };
+  await prpcClient.call('monorail.Issues', 'DeleteIssueComment', {
+    issueRef,
+    sequenceNum: comment.sequenceNum,
+    delete: comment.isDeleted === undefined,
+  });
+  store.dispatch(issueV0.fetchComments(issueRef));
+}
+
+/**
+ * Sends a request to flag a comment as spam. Flags or unflags based on
+ * the comments existing isSpam state.
+ * @param {IssueComment} comment The comment to flag.
+ */
+async function _flagComment(comment) {
+  const issueRef = {
+    projectName: comment.projectName,
+    localId: comment.localId,
+  };
+  await prpcClient.call('monorail.Issues', 'FlagComment', {
+    issueRef,
+    sequenceNum: comment.sequenceNum,
+    flag: comment.isSpam === undefined,
+  });
+  store.dispatch(issueV0.fetchComments(issueRef));
+}
+
+/**
+ * Finds if a given change in a comment contains issues (ie: for Blocking or
+ * BlockedOn edits), then formats those issues into a list to be rendered by the
+ * frontend.
+ * @param {Amendment} delta
+ * @param {string} projectName The project name the user is currently viewing.
+ * @return {Array<{issue: Issue, text: string}>}
+ */
+function _issuesForAmendment(delta, projectName) {
+  if (!_amendmentHasIssueRefs(delta.fieldName) ||
+      !delta.newOrDeltaValue) {
+    return [];
+  }
+  // TODO(ehmaldonado): Request the issue to check for permissions and display
+  // the issue summary.
+  return delta.newOrDeltaValue.split(' ').map((deltaValue) => {
+    let refString = deltaValue;
+
+    // When an issue is removed, its ID is prepended with a minus sign.
+    if (refString.startsWith('-')) {
+      refString = refString.substr(1);
+    }
+    const issueRef = issueStringToRef(refString, projectName);
+    return {
+      issue: {
+        ...issueRef,
+      },
+      text: deltaValue,
+    };
+  });
+}
+
+/**
+ * Check if a field is one of the field types that accepts issues as input.
+ * @param {string} fieldName
+ * @return {boolean} If the field contains issues.
+ */
+function _amendmentHasIssueRefs(fieldName) {
+  return ISSUE_REF_FIELD_NAMES.includes(fieldName);
+}
+
+customElements.define('mr-comment', MrComment);
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js
new file mode 100644
index 0000000..6933825
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js
@@ -0,0 +1,257 @@
+// 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 {MrComment} from './mr-comment.js';
+
+
+let element;
+
+/**
+ * Testing helper to find if an Array of options has an option with some
+ * text.
+ * @param {Array<MenuItem>} options Dropdown options to look through.
+ * @param {string} needle The text to search for.
+ * @return {boolean} Whether the option exists or not.
+ */
+const hasOptionWithText = (options, needle) => {
+  return options.some(({text}) => text === needle);
+};
+
+describe('mr-comment', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-comment');
+    element.comment = {
+      canFlag: true,
+      localId: 898395,
+      canDelete: true,
+      projectName: 'chromium',
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+      content: 'foo',
+      sequenceNum: 3,
+      timestamp: 1549319989,
+    };
+    document.body.appendChild(element);
+
+    // Stub RAF to execute immediately.
+    sinon.stub(window, 'requestAnimationFrame').callsFake((func) => func());
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    window.requestAnimationFrame.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrComment);
+  });
+
+  it('scrolls to comment', async () => {
+    sinon.stub(element, 'scrollIntoView');
+
+    element.highlighted = true;
+    await element.updateComplete;
+
+    assert.isTrue(element.scrollIntoView.calledOnce);
+
+    element.scrollIntoView.restore();
+  });
+
+  it('comment header renders self link to comment', async () => {
+    element.comment = {
+      localId: 1,
+      projectName: 'test',
+      sequenceNum: 2,
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('.comment-link');
+
+    assert.equal(link.textContent, 'Comment 2');
+    assert.include(link.href, '?id=1#c2');
+  });
+
+  it('renders issue links for Blockedon issue amendments', async () => {
+    element.comment = {
+      projectName: 'test',
+      amendments: [
+        {
+          fieldName: 'Blockedon',
+          newOrDeltaValue: '-2 3',
+        },
+      ],
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+    };
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+    assert.equal(links.length, 2);
+
+    assert.equal(links[0].text, '-2');
+    assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+    assert.equal(links[1].text, '3');
+    assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+  });
+
+  it('renders issue links for Blocking issue amendments', async () => {
+    element.comment = {
+      projectName: 'test',
+      amendments: [
+        {
+          fieldName: 'Blocking',
+          newOrDeltaValue: '-2 3',
+        },
+      ],
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+    };
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+    assert.equal(links.length, 2);
+
+    assert.equal(links[0].text, '-2');
+    assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+    assert.equal(links[1].text, '3');
+    assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+  });
+
+  it('renders issue links for Mergedinto issue amendments', async () => {
+    element.comment = {
+      projectName: 'test',
+      amendments: [
+        {
+          fieldName: 'Mergedinto',
+          newOrDeltaValue: '-2 3',
+        },
+      ],
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+    };
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+    assert.equal(links.length, 2);
+
+    assert.equal(links[0].text, '-2');
+    assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+    assert.equal(links[1].text, '3');
+    assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+  });
+
+  describe('3-dot menu options', () => {
+    it('allows showing deleted comment content', () => {
+      element._isExpandedIfDeleted = false;
+
+      // The comment is deleted.
+      element.comment = {content: 'test', isDeleted: true, canDelete: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Show comment content'));
+
+      // The comment is spam.
+      element.comment = {content: 'test', isSpam: true, canFlag: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Show comment content'));
+    });
+
+    it('allows hiding deleted comment content', () => {
+      element._isExpandedIfDeleted = true;
+
+      // The comment is deleted.
+      element.comment = {content: 'test', isDeleted: true, canDelete: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Hide comment content'));
+
+      // The comment is spam.
+      element.comment = {content: 'test', isSpam: true, canFlag: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Hide comment content'));
+    });
+
+    it('disallows showing deleted comment content', () => {
+      // The comment is deleted.
+      element.comment = {content: 'test', isDeleted: true, canDelete: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Hide comment content'));
+
+      // The comment is spam.
+      element.comment = {content: 'test', isSpam: true, canFlag: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Hide comment content'));
+    });
+
+    it('allows deleting comment', () => {
+      element.comment = {content: 'test', isDeleted: false, canDelete: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Delete comment'));
+    });
+
+    it('disallows deleting comment', () => {
+      element.comment = {content: 'test', isDeleted: false, canDelete: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Delete comment'));
+    });
+
+    it('allows undeleting comment', () => {
+      element.comment = {content: 'test', isDeleted: true, canDelete: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Undelete comment'));
+    });
+
+    it('disallows undeleting comment', () => {
+      element.comment = {content: 'test', isDeleted: true, canDelete: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Undelete comment'));
+    });
+
+    it('allows flagging comment as spam', () => {
+      element.comment = {content: 'test', isSpam: false, canFlag: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Flag comment'));
+    });
+
+    it('disallows flagging comment as spam', () => {
+      element.comment = {content: 'test', isSpam: false, canFlag: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Flag comment'));
+    });
+
+    it('allows unflagging comment as spam', () => {
+      element.comment = {content: 'test', isSpam: true, canFlag: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Unflag comment'));
+    });
+
+    it('disallows unflagging comment as spam', () => {
+      element.comment = {content: 'test', isSpam: true, canFlag: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Unflag comment'));
+    });
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-flipper/mr-flipper.js b/static_src/elements/issue-detail/mr-flipper/mr-flipper.js
new file mode 100644
index 0000000..8159e01
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-flipper/mr-flipper.js
@@ -0,0 +1,151 @@
+// 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 {connectStore} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * Class for displaying a single flipper.
+ * @extends {LitElement}
+ */
+export default class MrFlipper extends connectStore(LitElement) {
+  /** @override */
+  static get properties() {
+    return {
+      currentIndex: {type: Number},
+      totalCount: {type: Number},
+      prevUrl: {type: String},
+      nextUrl: {type: String},
+      listUrl: {type: String},
+      queryParams: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.currentIndex = null;
+    this.totalCount = null;
+    this.prevUrl = null;
+    this.nextUrl = null;
+    this.listUrl = null;
+
+    this.queryParams = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.queryParams = sitewide.queryParams(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('queryParams')) {
+      this.fetchFlipperData(qs.stringify(this.queryParams));
+    }
+  }
+
+  // Eventually this should be replaced with pRPC.
+  fetchFlipperData(query) {
+    const options = {
+      credentials: 'include',
+      method: 'GET',
+    };
+    fetch(`detail/flipper?${query}`, options).then(
+        (response) => response.text(),
+    ).then(
+        (responseBody) => {
+          let responseData;
+          try {
+          // Strip XSSI prefix from response.
+            responseData = JSON.parse(responseBody.substr(5));
+          } catch (e) {
+            console.error(`Error parsing JSON response for flipper: ${e}`);
+            return;
+          }
+          this._populateResponseData(responseData);
+        },
+    );
+  }
+
+  _populateResponseData(data) {
+    this.totalCount = data.total_count;
+    this.currentIndex = data.cur_index;
+    this.prevUrl = data.prev_url;
+    this.nextUrl = data.next_url;
+    this.listUrl = data.list_url;
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: flex;
+          justify-content: center;
+          flex-direction: column;
+        }
+        /* Use visibility instead of display:hidden for hiding in order to
+        * avoid popping when elements are made visible. */
+        .row a[hidden], .counts[hidden] {
+          visibility: hidden;
+        }
+        .counts[hidden] {
+          display: block;
+        }
+        .row a {
+          display: block;
+          padding: 0.25em 0;
+        }
+        .row a, .row div {
+          flex: 1;
+          white-space: nowrap;
+          padding: 0 2px;
+        }
+        .row .counts {
+          padding: 0 16px;
+        }
+        .row {
+          display: flex;
+          align-items: baseline;
+          text-align: center;
+          flex-direction: row;
+        }
+        @media (max-width: 960px) {
+          :host {
+            display: inline-block;
+          }
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <div class="row">
+        <a href="${this.prevUrl}" ?hidden="${!this.prevUrl}" title="Prev" class="prev-url">
+          &lsaquo; Prev
+        </a>
+        <div class="counts" ?hidden=${!this.totalCount}>
+          ${this.currentIndex + 1} of ${this.totalCount}
+        </div>
+        <a href="${this.nextUrl}" ?hidden="${!this.nextUrl}" title="Next" class="next-url">
+          Next &rsaquo;
+        </a>
+      </div>
+      <div class="row">
+        <a href="${this.listUrl}" ?hidden="${!this.listUrl}" title="Back to list" class="list-url">
+          Back to list
+        </a>
+      </div>
+    `;
+  }
+}
+
+window.customElements.define('mr-flipper', MrFlipper);
diff --git a/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js b/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js
new file mode 100644
index 0000000..183a8d5
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js
@@ -0,0 +1,75 @@
+// 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 MrFlipper from './mr-flipper.js';
+import sinon from 'sinon';
+
+const xssiPrefix = ')]}\'';
+
+let element;
+
+describe('mr-flipper', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-flipper');
+    document.body.appendChild(element);
+
+    sinon.stub(window, 'fetch');
+
+    const response = new window.Response(`${xssiPrefix}{"message": "Ok"}`, {
+      status: 201,
+      headers: {
+        'Content-type': 'application/json',
+      },
+    });
+    window.fetch.returns(Promise.resolve(response));
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    window.fetch.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrFlipper);
+  });
+
+  it('renders links', async () => {
+    // Test DOM after properties are updated.
+    element._populateResponseData({
+      cur_index: 4,
+      total_count: 13,
+      prev_url: 'http://prevurl/',
+      next_url: 'http://nexturl/',
+      list_url: 'http://listurl/',
+    });
+
+    await element.updateComplete;
+
+    const prevUrlEl = element.shadowRoot.querySelector('a.prev-url');
+    const nextUrlEl = element.shadowRoot.querySelector('a.next-url');
+    const listUrlEl = element.shadowRoot.querySelector('a.list-url');
+    const countsEl = element.shadowRoot.querySelector('div.counts');
+
+    assert.equal(prevUrlEl.href, 'http://prevurl/');
+    assert.equal(nextUrlEl.href, 'http://nexturl/');
+    assert.equal(listUrlEl.href, 'http://listurl/');
+    assert.include(countsEl.innerText, '5 of 13');
+  });
+
+  it('fetches flipper data when queryParams change', async () => {
+    await element.updateComplete;
+
+    sinon.stub(element, 'fetchFlipperData');
+
+    element.queryParams = {id: 21, q: 'owner:me'};
+
+    sinon.assert.notCalled(element.fetchFlipperData);
+
+    await element.updateComplete;
+
+    sinon.assert.calledWith(element.fetchFlipperData, 'id=21&q=owner%3Ame');
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js
new file mode 100644
index 0000000..bd88b3f
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js
@@ -0,0 +1,162 @@
+// 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} from 'lit-element';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as ui from 'reducers/ui.js';
+import 'elements/framework/mr-comment-content/mr-description.js';
+import '../mr-comment-list/mr-comment-list.js';
+import '../metadata/mr-edit-metadata/mr-edit-issue.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+
+/**
+ * `<mr-issue-details>`
+ *
+ * This is the main details section for a given issue.
+ *
+ */
+export class MrIssueDetails extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    let comments = [];
+    let descriptions = [];
+
+    if (this.commentsByApproval && this.commentsByApproval.has('')) {
+      // Comments without an approval go into the main view.
+      const mainComments = this.commentsByApproval.get('');
+      comments = mainComments.slice(1);
+      descriptions = commentListToDescriptionList(mainComments);
+    }
+
+    return html`
+      <style>
+        mr-issue-details {
+          font-size: var(--chops-main-font-size);
+          background-color: var(--chops-white);
+          padding-bottom: 1em;
+          display: flex;
+          align-items: stretch;
+          justify-content: flex-start;
+          flex-direction: column;
+          margin: 0;
+          box-sizing: border-box;
+        }
+        h3 {
+          margin-top: 1em;
+        }
+        mr-description {
+          margin-bottom: 1em;
+        }
+        mr-edit-issue {
+          margin-top: 40px;
+        }
+      </style>
+      <mr-description .descriptionList=${descriptions}></mr-description>
+      <mr-comment-list
+        headingLevel="2"
+        .comments=${comments}
+        .commentsShownCount=${this.commentsShownCount}
+      ></mr-comment-list>
+      ${this.issuePermissions.includes('addissuecomment') ?
+        html`<mr-edit-issue></mr-edit-issue>` : ''}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      commentsByApproval: {type: Object},
+      commentsShownCount: {type: Number},
+      issuePermissions: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.commentsByApproval = new Map();
+    this.issuePermissions = [];
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.commentsByApproval = issueV0.commentsByApprovalName(state);
+    this.issuePermissions = issueV0.permissions(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+    this._measureCommentLoadTime(changedProperties);
+  }
+
+  async _measureCommentLoadTime(changedProperties) {
+    if (!changedProperties.has('commentsByApproval')) {
+      return;
+    }
+    if (!this.commentsByApproval || this.commentsByApproval.size === 0) {
+      // For cold loads, if the GetIssue call returns before ListComments,
+      // commentsByApproval is initially set to an empty Map. Filter that out.
+      return;
+    }
+    const fullAppLoad = ui.navigationCount(store.getState()) === 1;
+    if (!(fullAppLoad || changedProperties.get('commentsByApproval'))) {
+      // For hot loads, the previous issue data is still in the Redux store, so
+      // the first update sets the comments to the previous issue's comments.
+      // We need to wait for the following update.
+      return;
+    }
+    const startMark = fullAppLoad ? undefined : 'start load issue detail page';
+    if (startMark && !performance.getEntriesByName(startMark).length) {
+      // Modifying the issue template, description, comments, or attachments
+      // triggers a comment update. We only want to include full issue loads.
+      return;
+    }
+
+    await Promise.all(_subtreeUpdateComplete(this));
+
+    const endMark = 'finish load issue detail comments';
+    performance.mark(endMark);
+
+    const measurementType = fullAppLoad ? 'from outside app' : 'within app';
+    const measurementName = `load issue detail page (${measurementType})`;
+    performance.measure(measurementName, startMark, endMark);
+
+    const measurement =
+      performance.getEntriesByName(measurementName)[0].duration;
+    window.getTSMonClient().recordIssueCommentsLoadTiming(
+        measurement, fullAppLoad);
+
+    // Be sure to clear this mark even on full page navigations.
+    performance.clearMarks('start load issue detail page');
+    performance.clearMarks(endMark);
+    performance.clearMeasures(measurementName);
+  }
+}
+
+/**
+ * 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.updateComplete) {
+    return [];
+  }
+
+  const context = element.shadowRoot ? element.shadowRoot : element;
+  const children = context.querySelectorAll('*');
+  const childPromises = Array.from(children, (e) => _subtreeUpdateComplete(e));
+  return [element.updateComplete].concat(...childPromises);
+}
+
+customElements.define('mr-issue-details', MrIssueDetails);
diff --git a/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js
new file mode 100644
index 0000000..3919e15
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js
@@ -0,0 +1,39 @@
+// 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 {MrIssueDetails} from './mr-issue-details.js';
+
+let element;
+
+describe('mr-issue-details', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-details');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueDetails);
+  });
+
+  it('mr-edit-issue is displayed if user has addissuecomment', async () => {
+    element.issuePermissions = ['addissuecomment'];
+
+    await element.updateComplete;
+
+    assert.isNotNull(element.querySelector('mr-edit-issue'));
+  });
+
+  it('mr-edit-issue is hidden if user has no addissuecomment', async () => {
+    element.issuePermissions = [];
+
+    await element.updateComplete;
+
+    assert.isNull(element.querySelector('mr-edit-issue'));
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js
new file mode 100644
index 0000000..0d04d32
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js
@@ -0,0 +1,379 @@
+// 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 'elements/issue-detail/mr-flipper/mr-flipper.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {userIsMember} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/links/mr-crbug-link/mr-crbug-link.js';
+import 'elements/framework/mr-pref-toggle/mr-pref-toggle.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_DELETE_PERMISSION,
+  ISSUE_FLAGSPAM_PERMISSION} from 'shared/consts/permissions.js';
+import {issueToIssueRef} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {AVAILABLE_MD_PROJECTS, DEFAULT_MD_PROJECTS} from 'shared/md-helper.js';
+
+const DELETE_ISSUE_CONFIRMATION_NOTICE = `\
+Normally, you would just close issues by setting their status to a closed value.
+Are you sure you want to delete this issue?`;
+
+
+/**
+ * `<mr-issue-header>`
+ *
+ * The header for a given launch issue.
+ *
+ */
+export class MrIssueHeader extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          width: 100%;
+          margin-top: 0;
+          font-size: var(--chops-large-font-size);
+          background-color: var(--monorail-metadata-toggled-bg);
+          border-bottom: var(--chops-normal-border);
+          padding: 0.25em 8px;
+          box-sizing: border-box;
+          display: flex;
+          flex-direction: row;
+          justify-content: space-between;
+          align-items: center;
+        }
+        h1 {
+          font-size: 100%;
+          line-height: 140%;
+          font-weight: bolder;
+          padding: 0;
+          margin: 0;
+        }
+        mr-flipper {
+          border-left: var(--chops-normal-border);
+          padding-left: 8px;
+          margin-left: 4px;
+          font-size: var(--chops-main-font-size);
+        }
+        mr-pref-toggle {
+          margin-right: 2px;
+        }
+        .issue-actions {
+          min-width: fit-content;
+          display: flex;
+          flex-direction: row;
+          align-items: center;
+          font-size: var(--chops-main-font-size);
+        }
+        .issue-actions div {
+          min-width: 70px;
+          display: flex;
+          justify-content: space-between;
+        }
+        .spam-notice {
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          padding: 1px 6px;
+          border-radius: 3px;
+          background: #F44336;
+          color: var(--chops-white);
+          font-weight: bold;
+          font-size: var(--chops-main-font-size);
+          margin-right: 4px;
+        }
+        .byline {
+          display: block;
+          font-size: var(--chops-main-font-size);
+          width: 100%;
+          line-height: 140%;
+          color: var(--chops-primary-font-color);
+        }
+        .role-label {
+          background-color: var(--chops-gray-600);
+          border-radius: 3px;
+          color: var(--chops-white);
+          display: inline-block;
+          padding: 2px 4px;
+          font-size: 75%;
+          font-weight: bold;
+          line-height: 14px;
+          vertical-align: text-bottom;
+          margin-left: 16px;
+        }
+        .main-text-outer {
+          flex-basis: 100%;
+          display: flex;
+          justify-content: flex-start;
+          flex-direction: row;
+          align-items: center;
+        }
+        .main-text {
+          flex-basis: 100%;
+        }
+        @media (max-width: 840px) {
+          :host {
+            flex-wrap: wrap;
+            justify-content: center;
+          }
+          .main-text {
+            width: 100%;
+            margin-bottom: 0.5em;
+          }
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const reporterIsMember = userIsMember(
+        this.issue.reporterRef, this.issue.projectName, this.usersProjects);
+    const markdownEnabled = AVAILABLE_MD_PROJECTS.has(this.projectName);
+    const markdownDefaultOn = DEFAULT_MD_PROJECTS.has(this.projectName);
+    return html`
+      <div class="main-text-outer">
+        <div class="main-text">
+          <h1>
+            ${this.issue.isSpam ? html`
+              <span class="spam-notice">Spam</span>
+            `: ''}
+            Issue ${this.issue.localId}: ${this.issue.summary}
+          </h1>
+          <small class="byline">
+            Reported by
+            <mr-user-link
+              .userRef=${this.issue.reporterRef}
+              aria-label="issue reporter"
+            ></mr-user-link>
+            on <chops-timestamp .timestamp=${this.issue.openedTimestamp}></chops-timestamp>
+            ${reporterIsMember ? html`
+              <span class="role-label">Project Member</span>` : ''}
+          </small>
+        </div>
+      </div>
+      <div class="issue-actions">
+        <div>
+          <mr-crbug-link .issue=${this.issue}></mr-crbug-link>
+          <mr-pref-toggle
+            .userDisplayName=${this.userDisplayName}
+            label="Code"
+            title="Code font"
+            prefName="code_font"
+          ></mr-pref-toggle>
+          ${markdownEnabled ? html`
+            <mr-pref-toggle
+              .userDisplayName=${this.userDisplayName}
+              initialValue=${markdownDefaultOn}
+              label="Markdown"
+              title="Render in markdown"
+              prefName="render_markdown"
+            ></mr-pref-toggle> ` : ''}
+        </div>
+        ${this._issueOptions.length ? html`
+          <mr-dropdown
+            .items=${this._issueOptions}
+            icon="more_vert"
+            label="Issue options"
+          ></mr-dropdown>
+        ` : ''}
+        <mr-flipper></mr-flipper>
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: {type: String},
+      issue: {type: Object},
+      issuePermissions: {type: Object},
+      isRestricted: {type: Boolean},
+      projectTemplates: {type: Array},
+      projectName: {type: String},
+      usersProjects: {type: Object},
+      _action: {type: String},
+      _targetProjectError: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.issuePermissions = [];
+    this.projectTemplates = [];
+    this.projectName = '';
+    this.issue = {};
+    this.usersProjects = new Map();
+    this.isRestricted = false;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issue = issueV0.viewedIssue(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.projectTemplates = projectV0.viewedTemplates(state);
+    this.projectName = projectV0.viewedProjectName(state);
+    this.usersProjects = userV0.projectsPerUser(state);
+
+    const restrictions = issueV0.restrictions(state);
+    this.isRestricted = restrictions && Object.keys(restrictions).length;
+  }
+
+  /**
+   * @return {Array<MenuItem>} Actions the user can take on the issue.
+   * @private
+   */
+  get _issueOptions() {
+    // We create two edit Arrays for the top and bottom half of the menu,
+    // to be separated by a separator in the UI.
+    const editOptions = [];
+    const riskyOptions = [];
+    const isSpam = this.issue.isSpam;
+    const isRestricted = this.isRestricted;
+
+    const permissions = this.issuePermissions;
+    const templates = this.projectTemplates;
+
+
+    if (permissions.includes(ISSUE_EDIT_PERMISSION)) {
+      editOptions.push({
+        text: 'Edit issue description',
+        handler: this._openEditDescription.bind(this),
+      });
+      if (templates.length) {
+        riskyOptions.push({
+          text: 'Convert issue template',
+          handler: this._openConvertIssue.bind(this),
+        });
+      }
+    }
+
+    if (permissions.includes(ISSUE_DELETE_PERMISSION)) {
+      riskyOptions.push({
+        text: 'Delete issue',
+        handler: this._deleteIssue.bind(this),
+      });
+      if (!isRestricted) {
+        editOptions.push({
+          text: 'Move issue',
+          handler: this._openMoveCopyIssue.bind(this, 'Move'),
+        });
+        editOptions.push({
+          text: 'Copy issue',
+          handler: this._openMoveCopyIssue.bind(this, 'Copy'),
+        });
+      }
+    }
+
+    if (permissions.includes(ISSUE_FLAGSPAM_PERMISSION)) {
+      const text = (isSpam ? 'Un-flag' : 'Flag') + ' issue as spam';
+      riskyOptions.push({
+        text,
+        handler: this._markIssue.bind(this),
+      });
+    }
+
+    if (editOptions.length && riskyOptions.length) {
+      editOptions.push({separator: true});
+    }
+    return editOptions.concat(riskyOptions);
+  }
+
+  /**
+   * Marks an issue as either spam or not spam based on whether the issue
+   * was spam.
+   */
+  _markIssue() {
+    prpcClient.call('monorail.Issues', 'FlagIssues', {
+      issueRefs: [{
+        projectName: this.issue.projectName,
+        localId: this.issue.localId,
+      }],
+      flag: !this.issue.isSpam,
+    }).then(() => {
+      store.dispatch(issueV0.fetch({
+        projectName: this.issue.projectName,
+        localId: this.issue.localId,
+      }));
+    });
+  }
+
+  /**
+   * Deletes an issue.
+   */
+  _deleteIssue() {
+    const ok = confirm(DELETE_ISSUE_CONFIRMATION_NOTICE);
+    if (ok) {
+      const issueRef = issueToIssueRef(this.issue);
+      // TODO(crbug.com/monorail/7374): Delete for the v0 -> v3 migration.
+      prpcClient.call('monorail.Issues', 'DeleteIssue', {
+        issueRef,
+        delete: true,
+      }).then(() => {
+        store.dispatch(issueV0.fetch(issueRef));
+      });
+    }
+  }
+
+  /**
+   * Launches the dialog to edit an issue's description.
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _openEditDescription() {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'edit-description',
+        fieldName: '',
+      },
+    }));
+  }
+
+  /**
+   * Opens dialog to either move or copy an issue.
+   * @param {"move"|"copy"} action
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _openMoveCopyIssue(action) {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'move-copy-issue',
+        action,
+      },
+    }));
+  }
+
+  /**
+   * Opens dialog for converting an issue.
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _openConvertIssue() {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'convert-issue',
+      },
+    }));
+  }
+}
+
+customElements.define('mr-issue-header', MrIssueHeader);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js
new file mode 100644
index 0000000..25ab0e7
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js
@@ -0,0 +1,167 @@
+// 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 {MrIssueHeader} from './mr-issue-header.js';
+import {store, resetState} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_DELETE_PERMISSION,
+  ISSUE_FLAGSPAM_PERMISSION} from 'shared/consts/permissions.js';
+
+let element;
+
+describe('mr-issue-header', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+    element = document.createElement('mr-issue-header');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueHeader);
+  });
+
+  it('updating issue id changes header', () => {
+    store.dispatch({type: issueV0.VIEW_ISSUE,
+      issueRef: {localId: 1, projectName: 'test'}});
+    store.dispatch({type: issueV0.FETCH_SUCCESS,
+      issue: {localId: 1, projectName: 'test', summary: 'test'}});
+
+    assert.deepEqual(element.issue, {localId: 1, projectName: 'test',
+      summary: 'test'});
+  });
+
+  it('_issueOptions toggles spam', () => {
+    element.issuePermissions = [ISSUE_FLAGSPAM_PERMISSION];
+    element.issue = {isSpam: false};
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Flag issue as spam'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Un-flag issue as spam'));
+
+    element.issue = {isSpam: true};
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Flag issue as spam'));
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Un-flag issue as spam'));
+
+    element.issuePermissions = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Flag issue as spam'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Un-flag issue as spam'));
+
+    element.issue = {isSpam: false};
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Flag issue as spam'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Un-flag issue as spam'));
+  });
+
+  it('_issueOptions toggles convert issue', () => {
+    element.issuePermissions = [];
+    element.projectTemplates = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Convert issue template'));
+
+    element.projectTemplates = [{templateName: 'test'}];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Convert issue template'));
+
+    element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+    element.projectTemplates = [];
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Convert issue template'));
+
+    element.projectTemplates = [{templateName: 'test'}];
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Convert issue template'));
+  });
+
+  it('_issueOptions toggles delete', () => {
+    element.issuePermissions = [ISSUE_DELETE_PERMISSION];
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Delete issue'));
+
+    element.issuePermissions = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Delete issue'));
+  });
+
+  it('_issueOptions toggles move and copy', () => {
+    element.issuePermissions = [ISSUE_DELETE_PERMISSION];
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Move issue'));
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Copy issue'));
+
+    element.isRestricted = true;
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Move issue'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Copy issue'));
+
+    element.issuePermissions = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Move issue'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Copy issue'));
+  });
+
+  it('_issueOptions toggles edit description', () => {
+    element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Edit issue description'));
+
+    element.issuePermissions = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Edit issue description'));
+  });
+
+  it('markdown toggle renders on enabled projects', async () => {
+    element.projectName = 'monkeyrail';
+
+    await element.updateComplete;
+
+    // This looks for how many mr-pref-toggle buttons there are,
+    // if there are two then this project also renders on markdown.
+    const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+    assert.equal(chopsToggles.length, 2);
+
+  });
+
+  it('markdown toggle does not render on disabled projects', async () => {
+    element.projectName = 'moneyrail';
+
+    await element.updateComplete;
+
+    const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+    assert.equal(chopsToggles.length, 1);
+  });
+
+  it('markdown toggle is on by default on enabled projects', async () => {
+    element.projectName = 'monkeyrail';
+
+    await element.updateComplete;
+    
+    const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+    const markdownButton = chopsToggles[1];
+    assert.equal("true", markdownButton.getAttribute('initialvalue'));
+  });
+});
+
+function findOptionWithText(issueOptions, text) {
+  return issueOptions.find((option) => option.text === text);
+}
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js
new file mode 100644
index 0000000..a93822b
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js
@@ -0,0 +1,393 @@
+// 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 {LitElement, html} from 'lit-element';
+
+import 'elements/chops/chops-button/chops-button.js';
+import './mr-issue-header.js';
+import './mr-restriction-indicator';
+import '../mr-issue-details/mr-issue-details.js';
+import '../metadata/mr-metadata/mr-issue-metadata.js';
+import '../mr-launch-overview/mr-launch-overview.js';
+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 {ISSUE_DELETE_PERMISSION} from 'shared/consts/permissions.js';
+
+// eslint-disable-next-line max-len
+import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js';
+import '../dialogs/mr-edit-description/mr-edit-description.js';
+import '../dialogs/mr-move-copy-issue/mr-move-copy-issue.js';
+import '../dialogs/mr-convert-issue/mr-convert-issue.js';
+import '../dialogs/mr-related-issues/mr-related-issues.js';
+import '../../help/mr-click-throughs/mr-click-throughs.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+const APPROVAL_COMMENT_COUNT = 5;
+const DETAIL_COMMENT_COUNT = 100;
+
+/**
+ * `<mr-issue-page>`
+ *
+ * The main entry point for a Monorail issue detail page.
+ *
+ */
+export class MrIssuePage extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <style>
+        mr-issue-page {
+          --mr-issue-page-horizontal-padding: 12px;
+          --mr-toggled-font-family: inherit;
+          --monorail-metadata-toggled-bg: var(--monorail-metadata-open-bg);
+        }
+        mr-issue-page[issueClosed] {
+          --monorail-metadata-toggled-bg: var(--monorail-metadata-closed-bg);
+        }
+        mr-issue-page[codeFont] {
+          --mr-toggled-font-family: Monospace;
+        }
+        .container-issue {
+          width: 100%;
+          flex-direction: column;
+          align-items: stretch;
+          justify-content: flex-start;
+          z-index: 200;
+        }
+        .container-issue-content {
+          padding: 0;
+          flex-grow: 1;
+          display: flex;
+          align-items: stretch;
+          justify-content: space-between;
+          flex-direction: row;
+          flex-wrap: nowrap;
+          box-sizing: border-box;
+          padding-top: 0.5em;
+        }
+        .container-outside {
+          box-sizing: border-box;
+          width: 100%;
+          max-width: 100%;
+          margin: auto;
+          padding: 0;
+          display: flex;
+          align-items: stretch;
+          justify-content: space-between;
+          flex-direction: row;
+          flex-wrap: no-wrap;
+        }
+        .container-no-issue {
+          padding: 0.5em 16px;
+          font-size: var(--chops-large-font-size);
+        }
+        .metadata-container {
+          font-size: var(--chops-main-font-size);
+          background: var(--monorail-metadata-toggled-bg);
+          border-right: var(--chops-normal-border);
+          border-bottom: var(--chops-normal-border);
+          width: 24em;
+          min-width: 256px;
+          flex-grow: 0;
+          flex-shrink: 0;
+          box-sizing: border-box;
+          z-index: 100;
+        }
+        .issue-header-container {
+          z-index: 10;
+          position: sticky;
+          top: var(--monorail-header-height);
+          margin-bottom: 0.25em;
+          width: 100%;
+        }
+        mr-issue-details {
+          min-width: 50%;
+          max-width: 1000px;
+          flex-grow: 1;
+          box-sizing: border-box;
+          min-height: 100%;
+          padding-left: var(--mr-issue-page-horizontal-padding);
+          padding-right: var(--mr-issue-page-horizontal-padding);
+        }
+        mr-issue-metadata {
+          position: sticky;
+          overflow-y: auto;
+          top: var(--monorail-header-height);
+          height: calc(100vh - var(--monorail-header-height));
+        }
+        mr-launch-overview {
+          border-left: var(--chops-normal-border);
+          padding-left: var(--mr-issue-page-horizontal-padding);
+          padding-right: var(--mr-issue-page-horizontal-padding);
+          flex-grow: 0;
+          flex-shrink: 0;
+          width: 50%;
+          box-sizing: border-box;
+          min-height: 100%;
+        }
+        @media (max-width: 1126px) {
+          .container-issue-content {
+            flex-direction: column;
+            padding: 0 var(--mr-issue-page-horizontal-padding);
+          }
+          mr-issue-details, mr-launch-overview {
+            width: 100%;
+            padding: 0;
+            border: 0;
+          }
+        }
+        @media (max-width: 840px) {
+          .container-outside {
+            flex-direction: column;
+          }
+          .metadata-container {
+            width: 100%;
+            height: auto;
+            border: 0;
+            border-bottom: var(--chops-normal-border);
+          }
+          mr-issue-metadata {
+            min-width: auto;
+            max-width: auto;
+            width: 100%;
+            padding: 0;
+            min-height: 0;
+            border: 0;
+          }
+          mr-issue-metadata, .issue-header-container {
+            position: static;
+          }
+        }
+      </style>
+      <mr-click-throughs
+         .userDisplayName=${this.userDisplayName}></mr-click-throughs>
+      ${this._renderIssue()}
+    `;
+  }
+
+  /**
+   * Render the issue.
+   * @return {TemplateResult}
+   */
+  _renderIssue() {
+    const issueIsEmpty = !this.issue || !this.issue.localId;
+    const movedToRef = this.issue.movedToRef;
+    const commentShown = this.issue.approvalValues ? APPROVAL_COMMENT_COUNT :
+      DETAIL_COMMENT_COUNT;
+
+    if (this.fetchIssueError) {
+      return html`
+        <div class="container-no-issue" id="fetch-error">
+          ${this.fetchIssueError.description}
+        </div>
+      `;
+    }
+
+    if (this.fetchingIssue && issueIsEmpty) {
+      return html`
+        <div class="container-no-issue" id="loading">
+          Loading...
+        </div>
+      `;
+    }
+
+    if (this.issue.isDeleted) {
+      return html`
+        <div class="container-no-issue" id="deleted">
+          <p>Issue ${this.issueRef.localId} has been deleted.</p>
+          ${this.issuePermissions.includes(ISSUE_DELETE_PERMISSION) ? html`
+            <chops-button
+              @click=${this._undeleteIssue}
+              class="undelete emphasized"
+            >
+              Undelete Issue
+            </chops-button>
+          `: ''}
+        </div>
+      `;
+    }
+
+    if (movedToRef && movedToRef.localId) {
+      return html`
+        <div class="container-no-issue" id="moved">
+          <h2>Issue has moved.</h2>
+          <p>
+            This issue was moved to ${movedToRef.projectName}.
+            <a
+              class="new-location"
+              href="/p/${movedToRef.projectName}/issues/detail?id=${movedToRef.localId}"
+            >
+              Go to issue</a>.
+          </p>
+        </div>
+      `;
+    }
+
+    if (!issueIsEmpty) {
+      return html`
+        <div
+          class="container-outside"
+          @open-dialog=${this._openDialog}
+          id="issue"
+        >
+          <aside class="metadata-container">
+            <mr-issue-metadata></mr-issue-metadata>
+          </aside>
+          <div class="container-issue">
+            <div class="issue-header-container">
+              <mr-issue-header
+                .userDisplayName=${this.userDisplayName}
+              ></mr-issue-header>
+              <mr-restriction-indicator></mr-restriction-indicator>
+            </div>
+            <div class="container-issue-content">
+              <mr-issue-details
+                class="main-item"
+                .commentsShownCount=${commentShown}
+              ></mr-issue-details>
+              <mr-launch-overview class="main-item"></mr-launch-overview>
+            </div>
+          </div>
+        </div>
+        <mr-edit-description id="edit-description"></mr-edit-description>
+        <mr-move-copy-issue id="move-copy-issue"></mr-move-copy-issue>
+        <mr-convert-issue id="convert-issue"></mr-convert-issue>
+        <mr-related-issues id="reorder-related-issues"></mr-related-issues>
+        <mr-update-issue-hotlists-dialog
+          id="update-issue-hotlists"
+          .issueRefs=${[this.issueRef]}
+          .issueHotlists=${this.issueHotlists}
+        ></mr-update-issue-hotlists-dialog>
+      `;
+    }
+
+    return '';
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: {type: String},
+      // Redux state.
+      fetchIssueError: {type: String},
+      fetchingIssue: {type: Boolean},
+      fetchingProjectConfig: {type: Boolean},
+      issue: {type: Object},
+      issueHotlists: {type: Array},
+      issueClosed: {
+        type: Boolean,
+        reflect: true,
+      },
+      codeFont: {
+        type: Boolean,
+        reflect: true,
+      },
+      issuePermissions: {type: Object},
+      issueRef: {type: Object},
+      prefs: {type: Object},
+      loginUrl: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.issue = {};
+    this.issueRef = {};
+    this.issuePermissions = [];
+    this.prefs = {};
+    this.codeFont = false;
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.projectName = projectV0.viewedProjectName(state);
+    this.issue = issueV0.viewedIssue(state);
+    this.issueHotlists = issueV0.hotlists(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.fetchIssueError = issueV0.requests(state).fetch.error;
+    this.fetchingIssue = issueV0.requests(state).fetch.requesting;
+    this.fetchingProjectConfig = projectV0.fetchingConfig(state);
+    this.issueClosed = !issueV0.isOpen(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.prefs = userV0.prefs(state);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('prefs')) {
+      this.codeFont = !!this.prefs.get('code_font');
+    }
+    if (changedProperties.has('fetchIssueError') &&
+      !this.userDisplayName && this.fetchIssueError &&
+      this.fetchIssueError.codeName === 'PERMISSION_DENIED') {
+      page(this.loginUrl);
+    }
+    super.update(changedProperties);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('issueRef') || changedProperties.has('issue')) {
+      const title = this._pageTitle(this.issueRef, this.issue);
+      store.dispatch(sitewide.setPageTitle(title));
+    }
+  }
+
+  /**
+   * Generates a title for the currently viewed page based on issue data.
+   * @param {IssueRef} issueRef
+   * @param {Issue} issue
+   * @return {string}
+   */
+  _pageTitle(issueRef, issue) {
+    const titlePieces = [];
+    if (issueRef.localId) {
+      titlePieces.push(issueRef.localId);
+    }
+    if (!issue || !issue.localId) {
+      // Issue is not loaded.
+      titlePieces.push('Loading issue...');
+    } else {
+      if (issue.isDeleted) {
+        titlePieces.push('Deleted issue');
+      } else if (issue.summary) {
+        titlePieces.push(issue.summary);
+      }
+    }
+    return titlePieces.join(' - ');
+  }
+
+  /**
+   * Opens a dialog with a specific ID based on an Event.
+   * @param {CustomEvent} e
+   */
+  _openDialog(e) {
+    this.querySelector('#' + e.detail.dialogId).open(e);
+  }
+
+  /**
+   * Undeletes the current issue.
+   */
+  _undeleteIssue() {
+    prpcClient.call('monorail.Issues', 'DeleteIssue', {
+      issueRef: this.issueRef,
+      delete: false,
+    }).then(() => {
+      store.dispatch(issueV0.fetchIssuePageData(this.issueRef));
+    });
+  }
+}
+
+customElements.define('mr-issue-page', MrIssuePage);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js
new file mode 100644
index 0000000..31edd4c
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js
@@ -0,0 +1,272 @@
+// 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 {MrIssuePage} from './mr-issue-page.js';
+import {store, resetState} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let loadingElement;
+let fetchErrorElement;
+let deletedElement;
+let movedElement;
+let issueElement;
+
+function populateElementReferences() {
+  loadingElement = element.querySelector('#loading');
+  fetchErrorElement = element.querySelector('#fetch-error');
+  deletedElement = element.querySelector('#deleted');
+  movedElement = element.querySelector('#moved');
+  issueElement = element.querySelector('#issue');
+}
+
+describe('mr-issue-page', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+    element = document.createElement('mr-issue-page');
+    document.body.appendChild(element);
+    sinon.stub(prpcClient, 'call');
+    // TODO(ehmaldonado): Remove once the old autocomplete code is deprecated.
+    window.TKR_populateAutocomplete = () => {};
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+    // TODO(ehmaldonado): Remove once the old autocomplete code is deprecated.
+    window.TKR_populateAutocomplete = undefined;
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssuePage);
+  });
+
+  describe('_pageTitle', () => {
+    it('displays loading when no issue', () => {
+      assert.equal(element._pageTitle({}, {}), 'Loading issue...');
+    });
+
+    it('display issue ID when available', () => {
+      assert.equal(element._pageTitle({projectName: 'test', localId: 1}, {}),
+          '1 - Loading issue...');
+    });
+
+    it('display deleted issues', () => {
+      assert.equal(element._pageTitle({projectName: 'test', localId: 1},
+          {projectName: 'test', localId: 1, isDeleted: true},
+      ), '1 - Deleted issue');
+    });
+
+    it('displays loaded issue', () => {
+      assert.equal(element._pageTitle({projectName: 'test', localId: 2},
+          {projectName: 'test', localId: 2, summary: 'test'}), '2 - test');
+    });
+  });
+
+  it('issue not loaded yet', async () => {
+    // Prevent unrelated Redux changes from affecting this test.
+    // TODO(zhangtiff): Find a more canonical way to test components
+    // in and out of Redux.
+    sinon.stub(store, 'dispatch');
+
+    element.fetchingIssue = true;
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNotNull(loadingElement);
+    assert.isNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNull(issueElement);
+
+    store.dispatch.restore();
+  });
+
+  it('no loading on future issue fetches', async () => {
+    element.issue = {localId: 222};
+    element.fetchingIssue = true;
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNotNull(issueElement);
+  });
+
+  it('fetch error', async () => {
+    element.fetchingIssue = false;
+    element.fetchIssueError = 'error';
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNotNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNull(issueElement);
+  });
+
+  it('deleted issue', async () => {
+    element.fetchingIssue = false;
+    element.issue = {isDeleted: true};
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNull(fetchErrorElement);
+    assert.isNotNull(deletedElement);
+    assert.isNull(issueElement);
+  });
+
+  it('normal issue', async () => {
+    element.fetchingIssue = false;
+    element.issue = {localId: 111};
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNotNull(issueElement);
+  });
+
+  it('code font pref toggles attribute', async () => {
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('codeFont'));
+
+    element.prefs = new Map([['code_font', true]]);
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('codeFont'));
+
+    element.prefs = new Map([['code_font', false]]);
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('codeFont'));
+  });
+
+  it('undeleting issue only shown if you have permissions', async () => {
+    sinon.stub(store, 'dispatch');
+
+    element.issue = {isDeleted: true};
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNotNull(deletedElement);
+
+    let button = element.querySelector('.undelete');
+    assert.isNull(button);
+
+    element.issuePermissions = ['deleteissue'];
+    await element.updateComplete;
+
+    button = element.querySelector('.undelete');
+    assert.isNotNull(button);
+
+    store.dispatch.restore();
+  });
+
+  it('undeleting issue updates page with issue', async () => {
+    const issueRef = {localId: 111, projectName: 'test'};
+    const deletedIssuePromise = Promise.resolve({
+      issue: {isDeleted: true},
+    });
+    const issuePromise = Promise.resolve({
+      issue: {localId: 111, projectName: 'test'},
+    });
+    const deletePromise = Promise.resolve({});
+
+    sinon.spy(element, '_undeleteIssue');
+
+    prpcClient.call.withArgs('monorail.Issues', 'GetIssue', {issueRef})
+        .onFirstCall().returns(deletedIssuePromise)
+        .onSecondCall().returns(issuePromise);
+    prpcClient.call.withArgs('monorail.Issues', 'DeleteIssue',
+        {delete: false, issueRef}).returns(deletePromise);
+
+    store.dispatch(issueV0.viewIssue(issueRef));
+    store.dispatch(issueV0.fetchIssuePageData(issueRef));
+
+    await deletedIssuePromise;
+    await element.updateComplete;
+
+    populateElementReferences();
+
+    assert.deepEqual(element.issue,
+        {isDeleted: true, localId: 111, projectName: 'test'});
+    assert.isNull(issueElement);
+    assert.isNotNull(deletedElement);
+
+    // Make undelete button visible. This must be after deletedIssuePromise
+    // resolves since issuePermissions are cleared by Redux after that promise.
+    element.issuePermissions = ['deleteissue'];
+    await element.updateComplete;
+
+    const button = element.querySelector('.undelete');
+    button.click();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Issues', 'GetIssue',
+        {issueRef});
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Issues', 'DeleteIssue',
+        {delete: false, issueRef});
+
+    await deletePromise;
+    await issuePromise;
+    await element.updateComplete;
+
+    assert.isTrue(element._undeleteIssue.calledOnce);
+
+    assert.deepEqual(element.issue, {localId: 111, projectName: 'test'});
+
+    await element.updateComplete;
+
+    populateElementReferences();
+    assert.isNotNull(issueElement);
+
+    element._undeleteIssue.restore();
+  });
+
+  it('issue has moved', async () => {
+    element.fetchingIssue = false;
+    element.issue = {movedToRef: {projectName: 'hello', localId: 10}};
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(issueElement);
+    assert.isNull(deletedElement);
+    assert.isNotNull(movedElement);
+
+    const link = movedElement.querySelector('.new-location');
+    assert.equal(link.getAttribute('href'), '/p/hello/issues/detail?id=10');
+  });
+
+  it('moving to a restricted issue', async () => {
+    element.fetchingIssue = false;
+    element.issue = {localId: 111};
+
+    await element.updateComplete;
+
+    element.issue = {localId: 222};
+    element.fetchIssueError = 'error';
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNotNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNull(movedElement);
+    assert.isNull(issueElement);
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js
new file mode 100644
index 0000000..af558a4
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js
@@ -0,0 +1,178 @@
+// 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 issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+
+
+/**
+ * `<mr-restriction-indicator>`
+ *
+ * Display for showing whether an issue is restricted.
+ *
+ */
+export class MrRestrictionIndicator extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        width: 100%;
+        margin-top: 0;
+        background-color: var(--monorail-metadata-toggled-bg);
+        border-bottom: var(--chops-normal-border);
+        font-size: var(--chops-main-font-size);
+        padding: 0.25em 8px;
+        box-sizing: border-box;
+        display: flex;
+        flex-direction: row;
+        justify-content: flex-start;
+        align-items: center;
+      }
+      :host([showWarning]) {
+        background-color: var(--chops-red-700);
+        color: var(--chops-white);
+        font-weight: bold;
+      }
+      :host([showWarning]) i {
+        color: var(--chops-white);
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      i.material-icons {
+        color: var(--chops-primary-icon-color);
+        font-size: var(--chops-icon-font-size);
+      }
+      .lock-icon {
+        margin-right: 4px;
+      }
+      i.warning-icon {
+        margin-right: 4px;
+      }
+      i[hidden] {
+        display: none;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <i
+        class="lock-icon material-icons"
+        icon="lock"
+        ?hidden=${!this._restrictionText}
+        title=${this._restrictionText}
+      >
+        lock
+      </i>
+      <i
+        class="warning-icon material-icons"
+        icon="warning"
+        ?hidden=${!this.showWarning}
+        title=${this._warningText}
+      >
+        warning
+      </i>
+      ${this._combinedText}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      restrictions: Object,
+      prefs: Object,
+      hidden: {
+        type: Boolean,
+        reflect: true,
+      },
+      showWarning: {
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.hidden = true;
+    this.showWarning = false;
+    this.prefs = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.restrictions = issueV0.restrictions(state);
+    this.prefs = userV0.prefs(state);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('prefs') ||
+        changedProperties.has('restrictions')) {
+      this.hidden = !this._combinedText;
+
+      this.showWarning = !!this._warningText;
+    }
+
+    super.update(changedProperties);
+  }
+
+  /**
+   * Checks if the user should see a corp mode warning about an issue being
+   * public.
+   * @return {string}
+   */
+  get _warningText() {
+    const {restrictions, prefs} = this;
+    if (!prefs) return '';
+    if (!restrictions) return '';
+    if ('view' in restrictions && restrictions['view'].length) return '';
+    if (prefs.get('public_issue_notice')) {
+      return 'Public issue: Please do not post confidential information.';
+    }
+    return '';
+  }
+
+  /**
+   * Gets either corp mode or restricted issue text depending on which
+   * is relevant to the issue.
+   * @return {string}
+   */
+  get _combinedText() {
+    if (this._warningText) return this._warningText;
+    return this._restrictionText;
+  }
+
+  /**
+   * Computes the text to show users on a restricted issue.
+   * @return {string}
+   */
+  get _restrictionText() {
+    const {restrictions} = this;
+    if (!restrictions) return;
+    if ('view' in restrictions && restrictions['view'].length) {
+      return `Only users with ${arrayToEnglish(restrictions['view'])
+      } permission or issue reporter may view.`;
+    } else if ('edit' in restrictions && restrictions['edit'].length) {
+      return `Only users with ${arrayToEnglish(restrictions['edit'])
+      } permission may edit.`;
+    } else if ('comment' in restrictions && restrictions['comment'].length) {
+      return `Only users with ${arrayToEnglish(restrictions['comment'])
+      } permission or issue reporter may comment.`;
+    }
+    return '';
+  }
+}
+
+customElements.define('mr-restriction-indicator', MrRestrictionIndicator);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js
new file mode 100644
index 0000000..3afbbcb
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js
@@ -0,0 +1,130 @@
+// 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 {MrRestrictionIndicator} from './mr-restriction-indicator.js';
+
+let element;
+
+describe('mr-restriction-indicator', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-restriction-indicator');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrRestrictionIndicator);
+  });
+
+  it('shows element only when restricted or showWarning', async () => {
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('hidden'));
+
+    element.restrictions = {view: ['Google']};
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('hidden'));
+
+    element.restrictions = {};
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('hidden'));
+
+    element.prefs = new Map([['public_issue_notice', true]]);
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('hidden'));
+
+    element.prefs = new Map([['public_issue_notice', false]]);
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('hidden'));
+
+    element.prefs = new Map([]);
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('hidden'));
+
+    // It is possible to have an edit or comment restriction on
+    // a public issue when the user is opted in to public issue notices.
+    // In that case, the lock icon is shown, plus a warning icon and the
+    // public issue notice.
+    element.restrictions = new Map([['edit', ['Google']]]);
+    element.prefs = new Map([['public_issue_notice', true]]);
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('hidden'));
+  });
+
+  it('displays view restrictions', async () => {
+    element.restrictions = {
+      view: ['Google', 'hello'],
+      edit: ['Editor', 'world'],
+      comment: ['commentor'],
+    };
+
+    await element.updateComplete;
+
+    const restrictString =
+      'Only users with Google and hello permission or issue reporter may view.';
+    assert.equal(element._restrictionText, restrictString);
+
+    assert.include(element.shadowRoot.textContent, restrictString);
+  });
+
+  it('displays edit restrictions', async () => {
+    element.restrictions = {
+      view: [],
+      edit: ['Editor', 'world'],
+      comment: ['commentor'],
+    };
+
+    await element.updateComplete;
+
+    const restrictString =
+      'Only users with Editor and world permission may edit.';
+    assert.equal(element._restrictionText, restrictString);
+
+    assert.include(element.shadowRoot.textContent, restrictString);
+  });
+
+  it('displays comment restrictions', async () => {
+    element.restrictions = {
+      view: [],
+      edit: [],
+      comment: ['commentor'],
+    };
+
+    await element.updateComplete;
+
+    const restrictString =
+      'Only users with commentor permission or issue reporter may comment.';
+    assert.equal(element._restrictionText, restrictString);
+
+    assert.include(element.shadowRoot.textContent, restrictString);
+  });
+
+  it('displays public issue notice, if the user has that pref', async () => {
+    element.restrictions = {};
+
+    element.prefs = new Map();
+    assert.equal(element._restrictionText, '');
+    assert.include(element.shadowRoot.textContent, '');
+
+    element.prefs = new Map([['public_issue_notice', true]]);
+
+    await element.updateComplete;
+
+    const noticeString =
+      'Public issue: Please do not post confidential information.';
+    assert.equal(element._warningText, noticeString);
+
+    assert.include(element.shadowRoot.textContent, noticeString);
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js
new file mode 100644
index 0000000..741baaa
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js
@@ -0,0 +1,102 @@
+// 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} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import './mr-phase.js';
+
+/**
+ * `<mr-launch-overview>`
+ *
+ * This is a shorthand view of the phases for a user to see a quick overview.
+ *
+ */
+export class MrLaunchOverview extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <style>
+        mr-launch-overview {
+          max-width: 100%;
+          display: flex;
+          flex-flow: column;
+          justify-content: flex-start;
+          align-items: stretch;
+        }
+        mr-launch-overview[hidden] {
+          display: none;
+        }
+        mr-phase {
+          margin-bottom: 0.75em;
+        }
+      </style>
+      ${this.phases.map((phase) => html`
+        <mr-phase
+          .phaseName=${phase.phaseRef.phaseName}
+          .approvals=${this._approvalsForPhase(this.approvals, phase.phaseRef.phaseName)}
+        ></mr-phase>
+      `)}
+      ${this._phaselessApprovals.length ? html`
+        <mr-phase .approvals=${this._phaselessApprovals}></mr-phase>
+      `: ''}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      approvals: {type: Array},
+      phases: {type: Array},
+      hidden: {
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.approvals = [];
+    this.phases = [];
+    this.hidden = true;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    if (!issueV0.viewedIssue(state)) return;
+
+    this.approvals = issueV0.viewedIssue(state).approvalValues || [];
+    this.phases = issueV0.viewedIssue(state).phases || [];
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('phases') || changedProperties.has('approvals')) {
+      this.hidden = !this.phases.length && !this.approvals.length;
+    }
+    super.update(changedProperties);
+  }
+
+  get _phaselessApprovals() {
+    return this._approvalsForPhase(this.approvals);
+  }
+
+  _approvalsForPhase(approvals, phaseName) {
+    return (approvals || []).filter((a) => {
+      // We can assume phase names will be unique.
+      return a.phaseRef.phaseName == phaseName;
+    });
+  }
+}
+customElements.define('mr-launch-overview', MrLaunchOverview);
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js
new file mode 100644
index 0000000..3e2ff46
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.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 {MrLaunchOverview} from './mr-launch-overview.js';
+
+
+let element;
+
+describe('mr-launch-overview', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-launch-overview');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrLaunchOverview);
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js
new file mode 100644
index 0000000..a81be65
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js
@@ -0,0 +1,460 @@
+// 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} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import '../mr-approval-card/mr-approval-card.js';
+import {valueForField, valuesForField} from 'shared/metadata-helpers.js';
+import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
+import 'elements/issue-detail/metadata/mr-metadata/mr-field-values.js';
+
+const TARGET_PHASE_MILESTONE_MAP = {
+  'Beta': 'feature_freeze',
+  'Stable-Exp': 'final_beta_cut',
+  'Stable': 'stable_cut',
+  'Stable-Full': 'stable_cut',
+};
+
+const APPROVED_PHASE_MILESTONE_MAP = {
+  'Beta': 'earliest_beta',
+  'Stable-Exp': 'final_beta',
+  'Stable': 'stable_date',
+  'Stable-Full': 'stable_date',
+};
+
+// The following milestones are unique to ios.
+const IOS_APPROVED_PHASE_MILESTONE_MAP = {
+  'Beta': 'earliest_beta_ios',
+};
+
+// See monorail:4692 and the use of PHASES_WITH_MILESTONES
+// in tracker/issueentry.py
+const PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full'];
+
+/**
+ * `<mr-phase>`
+ *
+ * This is the component for a single phase.
+ *
+ */
+export class MrPhase extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    const isPhaseWithMilestone = PHASES_WITH_MILESTONES.includes(
+        this.phaseName);
+    const noApprovals = !this.approvals || !this.approvals.length;
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <style>
+        mr-phase {
+          display: block;
+        }
+        mr-phase chops-dialog {
+          --chops-dialog-theme: {
+            width: 500px;
+            max-width: 100%;
+          };
+        }
+        mr-phase h2 {
+          margin: 0;
+          font-size: var(--chops-large-font-size);
+          font-weight: normal;
+          padding: 0.5em 8px;
+          box-sizing: border-box;
+          display: flex;
+          align-items: center;
+          flex-direction: row;
+          justify-content: space-between;
+        }
+        mr-phase h2 em {
+          margin-left: 16px;
+          font-size: var(--chops-main-font-size);
+        }
+        mr-phase .chip {
+          display: inline-block;
+          font-size: var(--chops-main-font-size);
+          padding: 0.25em 8px;
+          margin: 0 2px;
+          border-radius: 16px;
+          background: var(--chops-blue-gray-50);
+        }
+        .phase-edit {
+          padding: 0.1em 8px;
+        }
+      </style>
+      <h2>
+        <div>
+          Approvals<span ?hidden=${!this.phaseName || !this.phaseName.length}>:
+            ${this.phaseName}
+          </span>
+          ${isPhaseWithMilestone ? html`${this.fieldDefs &&
+              this.fieldDefs.map((field) => this._renderPhaseField(field))}
+            <em ?hidden=${!this._nextDate}>
+              ${this._dateDescriptor}
+              <chops-timestamp .timestamp=${this._nextDate}></chops-timestamp>
+            </em>
+            <em ?hidden=${!this._nextUniqueiOSDate}>
+              <b>iOS</b> ${this._dateDescriptor}
+              <chops-timestamp .timestamp=${this._nextUniqueiOSDate}
+              ></chops-timestamp>
+            </em>
+          `: ''}
+        </div>
+        ${isPhaseWithMilestone ? html`
+          <chops-button @click=${this.edit} class="de-emphasized phase-edit">
+            <i class="material-icons" role="presentation">create</i>
+            Edit
+          </chops-button>
+        `: ''}
+      </h2>
+      ${this.approvals && this.approvals.map((approval) => html`
+        <mr-approval-card
+          .approvers=${approval.approverRefs}
+          .setter=${approval.setterRef}
+          .fieldName=${approval.fieldRef.fieldName}
+          .phaseName=${this.phaseName}
+          .statusEnum=${approval.status}
+          .survey=${approval.survey}
+          .surveyTemplate=${approval.surveyTemplate}
+          .urls=${approval.urls}
+          .labels=${approval.labels}
+          .users=${approval.users}
+        ></mr-approval-card>
+      `)}
+      ${noApprovals ? html`No tasks for this phase.` : ''}
+      <!-- TODO(ehmaldonado): Move to /issue-detail/dialogs -->
+      <chops-dialog id="editPhase" aria-labelledby="phaseDialogTitle">
+        <h3 id="phaseDialogTitle" class="medium-heading">
+          Editing phase: ${this.phaseName}
+        </h3>
+        <mr-edit-metadata
+          id="metadataForm"
+          class="edit-actions-right"
+          .formName=${this.phaseName}
+          .fieldDefs=${this.fieldDefs}
+          .phaseName=${this.phaseName}
+          ?disabled=${this._updatingIssue}
+          .error=${this._updateIssueError && this._updateIssueError.description}
+          @save=${this.save}
+          @discard=${this.cancel}
+          isApproval
+          disableAttachments
+        ></mr-edit-metadata>
+      </chops-dialog>
+    `;
+  }
+
+  /**
+   *
+   * @param {FieldDef} field The field to be rendered.
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderPhaseField(field) {
+    const values = valuesForField(this._fieldValueMap, field.fieldRef.fieldName,
+        this.phaseName);
+    return html`
+      <div class="chip">
+        ${field.fieldRef.fieldName}:
+        <mr-field-values
+          .name=${field.fieldRef.fieldName}
+          .type=${field.fieldRef.type}
+          .values=${values}
+          .projectName=${this.issueRef.projectName}
+        ></mr-field-values>
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+      issueRef: {type: Object},
+      phaseName: {type: String},
+      approvals: {type: Array},
+      fieldDefs: {type: Array},
+
+      _updatingIssue: {type: Boolean},
+      _updateIssueError: {type: Object},
+      _fieldValueMap: {type: Object},
+      _milestoneData: {type: Object},
+      _isFetchingMilestone: {type: Boolean},
+      _fetchedMilestone: {type: String},
+    };
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.issue = {};
+    this.issueRef = {};
+    this.phaseName = '';
+    this.approvals = [];
+    this.fieldDefs = [];
+
+    this._updatingIssue = false;
+    this._updateIssueError = undefined;
+
+    // A response Object from
+    // https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+    this._milestoneData = {};
+    this._isFetchingMilestone = false;
+    this._fetchedMilestone = undefined;
+    /**
+     * @type {Promise} Used for testing to allow waiting for milestone
+     *   fetch operations to finish.
+     */
+    this._fetchMilestoneComplete = undefined;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issue = issueV0.viewedIssue(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.fieldDefs = projectV0.fieldDefsForPhases(state);
+    this._updatingIssue = issueV0.requests(state).update.requesting;
+    this._updateIssueError = issueV0.requests(state).update.error;
+    this._fieldValueMap = issueV0.fieldValueMap(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('issue')) {
+      this.reset();
+    }
+    if (changedProperties.has('_updatingIssue')) {
+      if (!this._updatingIssue && !this._updateIssueError) {
+        // Close phase edit modal only after a request finishes without errors.
+        this.cancel();
+      }
+    }
+
+    if (!this._isFetchingMilestone) {
+      const milestoneToFetch = this._milestoneToFetch;
+      if (milestoneToFetch && this._fetchedMilestone !== milestoneToFetch) {
+        this._fetchMilestoneComplete = this.fetchMilestoneData(
+            milestoneToFetch);
+      }
+    }
+  }
+
+  /**
+   * Makes an XHR request to Chromium Dash to find Chrome-specific launch data.
+   * eg. when certain Chrome milestones are planned for release.
+   * @param {string} milestone A string containing a Chrome milestone number.
+   * @return {Promise<void>}
+   */
+  async fetchMilestoneData(milestone) {
+    this._isFetchingMilestone = true;
+
+    try {
+      const resp = await window.fetch(
+          `https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=${
+            milestone}`);
+      this._milestoneData = await resp.json();
+    } catch (error) {
+      console.error(`Error when fetching milestone data: ${error}`);
+    }
+    this._fetchedMilestone = milestone;
+    this._isFetchingMilestone = false;
+  }
+
+  /**
+   * Opens the phase editing dialog when the user clicks the edit button.
+   */
+  edit() {
+    this.reset();
+    this.querySelector('#editPhase').open();
+  }
+
+  /**
+   * Stops editing the phase.
+   */
+  cancel() {
+    this.querySelector('#editPhase').close();
+    this.reset();
+  }
+
+  /**
+   * Resets the edit form to its default values.
+   */
+  reset() {
+    const form = this.querySelector('#metadataForm');
+    form.reset();
+  }
+
+  /**
+   * Saves the changes the user has made.
+   */
+  save() {
+    const form = this.querySelector('#metadataForm');
+    const delta = form.delta;
+
+    if (delta.fieldValsAdd) {
+      delta.fieldValsAdd = delta.fieldValsAdd.map(
+          (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
+    }
+    if (delta.fieldValsRemove) {
+      delta.fieldValsRemove = delta.fieldValsRemove.map(
+          (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
+    }
+
+    const message = {
+      issueRef: this.issueRef,
+      delta: delta,
+      sendEmail: form.sendEmail,
+      commentContent: form.getCommentContent(),
+    };
+
+    if (message.commentContent || message.delta) {
+      store.dispatch(issueV0.update(message));
+    }
+  }
+
+  /**
+   * Shows the next relevant Chrome Milestone date for this phase. Depending
+   * on the M-Target, M-Approved, or M-Launched values, this date means
+   * different things.
+   * @return {number} Unix timestamp in seconds.
+   * @private
+   */
+  get _nextDate() {
+    const phaseName = this.phaseName;
+    const status = this._status;
+    let data = this._milestoneData && this._milestoneData.mstones;
+    // Data pulled from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+    if (!phaseName || !status || !data || !data.length) return 0;
+    data = data[0];
+
+    let key = TARGET_PHASE_MILESTONE_MAP[phaseName];
+    if (['Approved', 'Launched'].includes(status)) {
+      const osValues = this._fieldValueMap.get('OS');
+      // If iOS is the only OS and the phase is one where iOS has unique
+      // milestones, the only date we show should be this._nextUniqueiOSDate.
+      if (osValues && osValues.every((os) => {
+        return os === 'iOS';
+      }) && phaseName in IOS_APPROVED_PHASE_MILESTONE_MAP) {
+        return 0;
+      }
+      key = APPROVED_PHASE_MILESTONE_MAP[phaseName];
+    }
+    if (!key || !(key in data)) return 0;
+    return Math.floor((new Date(data[key])).getTime() / 1000);
+  }
+
+  /**
+   * For issues where iOS is the OS, this function finds the relevant iOS
+   * launch date.
+   * @return {number} Unix timestamp in seconds.
+   * @private
+   */
+  get _nextUniqueiOSDate() {
+    const phaseName = this.phaseName;
+    const status = this._status;
+    let data = this._milestoneData && this._milestoneData.mstones;
+    // Data pull from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+    if (!phaseName || !status || !data || !data.length) return 0;
+    data = data[0];
+
+    const osValues = this._fieldValueMap.get('OS');
+    if (['Approved', 'Launched'].includes(status) &&
+        osValues && osValues.includes('iOS')) {
+      const key = IOS_APPROVED_PHASE_MILESTONE_MAP[phaseName];
+      if (key) {
+        return Math.floor((new Date(data[key])).getTime() / 1000);
+      }
+    }
+    return 0;
+  }
+
+  /**
+   * Depending on what kind of date we're showing, we want to include
+   * different text to describe the date.
+   * @return {string}
+   * @private
+   */
+  get _dateDescriptor() {
+    const status = this._status;
+    if (status === 'Approved') {
+      return 'Launching on ';
+    } else if (status === 'Launched') {
+      return 'Launched on ';
+    }
+    return 'Due by ';
+  }
+
+  /**
+   * The Chrome-specific status of a gate, computed from M-Approved,
+   * M-Launched, and M-Target fields.
+   * @return {string}
+   * @private
+   */
+  get _status() {
+    const target = this._targetMilestone;
+    const approved = this._approvedMilestone;
+    const launched = this._launchedMilestone;
+    if (approved >= target) {
+      if (launched >= approved) {
+        return 'Launched';
+      }
+      return 'Approved';
+    }
+    return 'Target';
+  }
+
+  /**
+   * The Chrome Milestone that this phase was approved for.
+   * @return {string}
+   * @private
+   */
+  get _approvedMilestone() {
+    return valueForField(this._fieldValueMap, 'M-Approved', this.phaseName);
+  }
+
+  /**
+   * The Chrome Milestone that this phase was launched on.
+   * @return {string}
+   * @private
+   */
+  get _launchedMilestone() {
+    return valueForField(this._fieldValueMap, 'M-Launched', this.phaseName);
+  }
+
+  /**
+   * The Chrome Milestone that this phase is targeting.
+   * @return {string}
+   * @private
+   */
+  get _targetMilestone() {
+    return valueForField(this._fieldValueMap, 'M-Target', this.phaseName);
+  }
+
+  /**
+   * The Chrome Milestone that's used to decide what date to show the user.
+   * @return {string}
+   * @private
+   */
+  get _milestoneToFetch() {
+    const target = Number.parseInt(this._targetMilestone) || 0;
+    const approved = Number.parseInt(this._approvedMilestone) || 0;
+    const launched = Number.parseInt(this._launchedMilestone) || 0;
+
+    const latestMilestone = Math.max(target, approved, launched);
+    return latestMilestone > 0 ? `${latestMilestone}` : '';
+  }
+}
+
+
+customElements.define('mr-phase', MrPhase);
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js
new file mode 100644
index 0000000..d55897e
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js
@@ -0,0 +1,209 @@
+// 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 {MrPhase} from './mr-phase.js';
+
+
+let element;
+
+describe('mr-phase', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-phase');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrPhase);
+  });
+
+  it('clicking edit button opens edit dialog', async () => {
+    element.phaseName = 'Beta';
+
+    await element.updateComplete;
+
+    const editDialog = element.querySelector('#editPhase');
+    assert.isFalse(editDialog.opened);
+
+    element.querySelector('.phase-edit').click();
+
+    await element.updateComplete;
+
+    assert.isTrue(editDialog.opened);
+  });
+
+  it('discarding form changes closes dialog', async () => {
+    await element.updateComplete;
+
+    // Open the edit dialog.
+    element.edit();
+    const editDialog = element.querySelector('#editPhase');
+    const editForm = element.querySelector('#metadataForm');
+
+    await element.updateComplete;
+
+    assert.isTrue(editDialog.opened);
+    editForm.discard();
+
+    await element.updateComplete;
+
+    assert.isFalse(editDialog.opened);
+  });
+
+  describe('milestone fetching', () => {
+    beforeEach(() => {
+      sinon.stub(element, 'fetchMilestoneData');
+    });
+
+    it('_launchedMilestone extracts M-Launched for phase', () => {
+      element._fieldValueMap = new Map([['m-launched beta', ['87']]]);
+      element.phaseName = 'Beta';
+
+      assert.equal(element._launchedMilestone, '87');
+      assert.equal(element._approvedMilestone, undefined);
+      assert.equal(element._targetMilestone, undefined);
+    });
+
+    it('_approvedMilestone extracts M-Approved for phase', () => {
+      element._fieldValueMap = new Map([['m-approved beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      assert.equal(element._launchedMilestone, undefined);
+      assert.equal(element._approvedMilestone, '86');
+      assert.equal(element._targetMilestone, undefined);
+    });
+
+    it('_targetMilestone extracts M-Target for phase', () => {
+      element._fieldValueMap = new Map([['m-target beta', ['85']]]);
+      element.phaseName = 'Beta';
+
+      assert.equal(element._launchedMilestone, undefined);
+      assert.equal(element._approvedMilestone, undefined);
+      assert.equal(element._targetMilestone, '85');
+    });
+
+    it('_milestoneToFetch returns empty when no relevant milestone', () => {
+      element._fieldValueMap = new Map([['m-target beta', ['85']]]);
+      element.phaseName = 'Stable';
+
+      assert.equal(element._milestoneToFetch, '');
+    });
+
+    it('_milestoneToFetch selects highest milestone', () => {
+      element._fieldValueMap = new Map([
+        ['m-target beta', ['84']],
+        ['m-approved beta', ['85']],
+        ['m-launched beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      assert.equal(element._milestoneToFetch, '86');
+    });
+
+    it('does not fetch when no milestones specified', async () => {
+      element.issue = {projectName: 'chromium', localId: 12};
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.fetchMilestoneData);
+    });
+
+    it('does not fetch when milestone to fetch is unchanged', async () => {
+      element._fetchedMilestone = '86';
+      element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.fetchMilestoneData);
+    });
+
+    it('fetches when milestone found', async () => {
+      element._fetchedMilestone = undefined;
+      element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      await element.updateComplete;
+
+      sinon.assert.calledWith(element.fetchMilestoneData, '86');
+    });
+
+    it('re-fetches when new milestone found', async () => {
+      element._fetchedMilestone = '86';
+      element._fieldValueMap = new Map([
+        ['m-target beta', ['86']],
+        ['m-launched beta', ['87']]]);
+      element.phaseName = 'Beta';
+
+      await element.updateComplete;
+
+      sinon.assert.calledWith(element.fetchMilestoneData, '87');
+    });
+
+    it('re-fetches only after last stale fetch finishes', async () => {
+      element._fetchedMilestone = '84';
+      element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+      element.phaseName = 'Beta';
+      element._isFetchingMilestone = true;
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.fetchMilestoneData);
+
+      // Previous in flight fetch finishes.
+      element._fetchedMilestone = '85';
+      element._isFetchingMilestone = false;
+
+      await element.updateComplete;
+
+      sinon.assert.calledWith(element.fetchMilestoneData, '86');
+    });
+  });
+
+  describe('milestone fetching with fake server responses', () => {
+    beforeEach(() => {
+      sinon.stub(window, 'fetch');
+      sinon.spy(element, 'fetchMilestoneData');
+    });
+
+    afterEach(() => {
+      window.fetch.restore();
+    });
+
+    it('does not refetch when server response finishes', async () => {
+      const response = new window.Response('{"mstones": [{"mstone": 86}]}', {
+        status: 200,
+        headers: {
+          'Content-type': 'application/json',
+        },
+      });
+
+      window.fetch.returns(Promise.resolve(response));
+
+      element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      await element.updateComplete;
+
+      sinon.assert.calledWith(element.fetchMilestoneData, '86');
+
+      assert.isTrue(element._isFetchingMilestone);
+
+      await element._fetchMilestoneComplete;
+
+      assert.deepEqual(element._milestoneData, {'mstones': [{'mstone': 86}]});
+      assert.equal(element._fetchedMilestone, '86');
+      assert.isFalse(element._isFetchingMilestone);
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element.fetchMilestoneData);
+    });
+  });
+});
diff --git a/static_src/elements/issue-entry/mr-issue-entry-page.js b/static_src/elements/issue-entry/mr-issue-entry-page.js
new file mode 100644
index 0000000..b1cc2ef
--- /dev/null
+++ b/static_src/elements/issue-entry/mr-issue-entry-page.js
@@ -0,0 +1,56 @@
+// Copyright 2020 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 {LitElement, html, css} from 'lit-element';
+
+/**
+ * `<mr-issue-entry-page>`
+ *
+ * This is the main details section for a given issue.
+ *
+ */
+export class MrIssueEntryPage extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        margin: 0;
+      }
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: {type: String},
+      loginUrl: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /* dependency injection for testing purpose */
+    this._page = page;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    if (!this.userDisplayName) {
+      this._page(this.loginUrl);
+    }
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <div>SPA issue entry page place holder</div>
+    `;
+  }
+}
+
+customElements.define('mr-issue-entry-page', MrIssueEntryPage);
diff --git a/static_src/elements/issue-entry/mr-issue-entry-page.test.js b/static_src/elements/issue-entry/mr-issue-entry-page.test.js
new file mode 100644
index 0000000..013a3a4
--- /dev/null
+++ b/static_src/elements/issue-entry/mr-issue-entry-page.test.js
@@ -0,0 +1,58 @@
+// Copyright 2020 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 {MrIssueEntryPage} from './mr-issue-entry-page.js';
+
+let element;
+
+describe('mr-issue-entry-page', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-entry-page');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueEntryPage);
+  });
+
+  describe('requires user to be logged in', () => {
+    it('redirects to loginUrl if not logged in', async () => {
+      document.body.removeChild(element);
+      element = document.createElement('mr-issue-entry-page');
+      assert.isUndefined(element.userDisplayName);
+
+      const EXPECTED = 'abc';
+      element.loginUrl = EXPECTED;
+
+      const pageStub = sinon.stub(element, '_page');
+      document.body.appendChild(element);
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(pageStub);
+      sinon.assert.calledWith(pageStub, EXPECTED);
+    });
+
+    it('renders when user is logged in', async () => {
+      document.body.removeChild(element);
+      element = document.createElement('mr-issue-entry-page');
+
+      element.loginUrl = 'abc';
+      element.userDisplayName = 'not_undefined';
+
+      const pageStub = sinon.stub(element, '_page');
+      const renderSpy = sinon.spy(element, 'render');
+      document.body.appendChild(element);
+      await element.updateComplete;
+
+      sinon.assert.notCalled(pageStub);
+      sinon.assert.calledOnce(renderSpy);
+    });
+  });
+});
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');
+  });
+});
diff --git a/static_src/elements/mr-app/mr-app.js b/static_src/elements/mr-app/mr-app.js
new file mode 100644
index 0000000..a48d40f
--- /dev/null
+++ b/static_src/elements/mr-app/mr-app.js
@@ -0,0 +1,587 @@
+// 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} from 'lit-element';
+import {repeat} from 'lit-html/directives/repeat';
+import page from 'page';
+import qs from 'qs';
+
+import {getServerStatusCron} from 'shared/cron.js';
+import 'elements/framework/mr-site-banner/mr-site-banner.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as permissions from 'reducers/permissions.js';
+import * as users from 'reducers/users.js';
+import * as userv0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import * as sitewide from 'reducers/sitewide.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+import {trackPageChange} from 'shared/ga-helpers.js';
+import 'elements/chops/chops-announcement/chops-announcement.js';
+import 'elements/issue-list/mr-list-page/mr-list-page.js';
+import 'elements/issue-entry/mr-issue-entry-page.js';
+import 'elements/framework/mr-header/mr-header.js';
+import 'elements/help/mr-cue/mr-cue.js';
+import {cueNames} from 'elements/help/mr-cue/cue-helpers.js';
+import 'elements/chops/chops-snackbar/chops-snackbar.js';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+const QUERY_PARAMS_THAT_RESET_SCROLL = ['q', 'mode', 'id'];
+
+/**
+ * `<mr-app>`
+ *
+ * The container component for all pages under the Monorail SPA.
+ *
+ */
+export class MrApp extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    if (this.page === 'wizard') {
+      return html`<div id="reactMount"></div>`;
+    }
+
+    return html`
+      <style>
+        ${SHARED_STYLES}
+        mr-app {
+          display: block;
+          padding-top: var(--monorail-header-height);
+          margin-top: -1px; /* Prevent a double border from showing up. */
+
+          /* From shared-styles.js. */
+          --mr-edit-field-padding: 0.125em 4px;
+          --mr-edit-field-width: 90%;
+          --mr-input-grid-gap: 6px;
+          font-family: var(--chops-font-family);
+          color: var(--chops-primary-font-color);
+          font-size: var(--chops-main-font-size);
+        }
+        main {
+          border-top: var(--chops-normal-border);
+        }
+        .snackbar-container {
+          position: fixed;
+          bottom: 1em;
+          left: 1em;
+          display: flex;
+          flex-direction: column;
+          align-items: flex-start;
+          z-index: 1000;
+        }
+        /** Unfix <chops-snackbar> to allow stacking. */
+        chops-snackbar {
+          position: static;
+          margin-top: 0.5em;
+        }
+      </style>
+      <mr-header
+        .userDisplayName=${this.userDisplayName}
+        .loginUrl=${this.loginUrl}
+        .logoutUrl=${this.logoutUrl}
+      ></mr-header>
+      <chops-announcement service="monorail"></chops-announcement>
+      <mr-site-banner></mr-site-banner>
+      <mr-cue
+        cuePrefName=${cueNames.SWITCH_TO_PARENT_ACCOUNT}
+        .loginUrl=${this.loginUrl}
+        centered
+        nondismissible
+      ></mr-cue>
+      <mr-cue
+        cuePrefName=${cueNames.SEARCH_FOR_NUMBERS}
+        centered
+      ></mr-cue>
+      <main>${this._renderPage()}</main>
+      <div class="snackbar-container" aria-live="polite">
+        ${repeat(this._snackbars, (snackbar) => html`
+          <chops-snackbar
+            @close=${this._closeSnackbar.bind(this, snackbar.id)}
+          >${snackbar.text}</chops-snackbar>
+        `)}
+      </div>
+    `;
+  }
+
+  /**
+   * @param {string} id The name of the snackbar to close.
+   */
+  _closeSnackbar(id) {
+    store.dispatch(ui.hideSnackbar(id));
+  }
+
+  /**
+   * Helper for determiing which page component to render.
+   * @return {TemplateResult}
+   */
+  _renderPage() {
+    switch (this.page) {
+      case 'detail':
+        return html`
+          <mr-issue-page
+            .userDisplayName=${this.userDisplayName}
+            .loginUrl=${this.loginUrl}
+          ></mr-issue-page>
+        `;
+      case 'entry':
+        return html`
+          <mr-issue-entry-page
+            .userDisplayName=${this.userDisplayName}
+            .loginUrl=${this.loginUrl}
+          ></mr-issue-entry-page>
+        `;
+      case 'grid':
+        return html`
+          <mr-grid-page
+            .userDisplayName=${this.userDisplayName}
+          ></mr-grid-page>
+        `;
+      case 'list':
+        return html`
+          <mr-list-page
+            .userDisplayName=${this.userDisplayName}
+          ></mr-list-page>
+        `;
+      case 'chart':
+        return html`<mr-chart-page></mr-chart-page>`;
+      case 'projects':
+        return html`<mr-projects-page></mr-projects-page>`;
+      case 'hotlist-issues':
+        return html`<mr-hotlist-issues-page></mr-hotlist-issues-page>`;
+      case 'hotlist-people':
+        return html`<mr-hotlist-people-page></mr-hotlist-people-page>`;
+      case 'hotlist-settings':
+        return html`<mr-hotlist-settings-page></mr-hotlist-settings-page>`;
+      default:
+        return;
+    }
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Backend-generated URL for the page the user is directed to for login.
+       */
+      loginUrl: {type: String},
+      /**
+       * Backend-generated URL for the page the user is directed to for logout.
+       */
+      logoutUrl: {type: String},
+      /**
+       * The display name of the currently logged in user.
+       */
+      userDisplayName: {type: String},
+      /**
+       * The search parameters in the user's current URL.
+       */
+      queryParams: {type: Object},
+      /**
+       * A list of forms to check for "dirty" values when the user navigates
+       * across pages.
+       */
+      dirtyForms: {type: Array},
+      /**
+       * App Engine ID for the current version being viewed.
+       */
+      versionBase: {type: String},
+      /**
+       * A String identifier for the page that the user is viewing.
+       */
+      page: {type: String},
+      /**
+       * A String for the title of the page that the user will see in their
+       * browser tab. ie: equivalent to the <title> tag.
+       */
+      pageTitle: {type: String},
+      /**
+       * Array of snackbar objects to render.
+       */
+      _snackbars: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.queryParams = {};
+    this.dirtyForms = [];
+    this.userDisplayName = '';
+
+    /**
+     * @type {PageJS.Context}
+     * The context of the page. This should not be a LitElement property
+     * because we don't want to re-render when updating this.
+     */
+    this._lastContext = undefined;
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.dirtyForms = ui.dirtyForms(state);
+    this.queryParams = sitewide.queryParams(state);
+    this.pageTitle = sitewide.pageTitle(state);
+    this._snackbars = ui.snackbars(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('userDisplayName') && this.userDisplayName) {
+      // TODO(https://crbug.com/monorail/7238): Migrate userv0 calls to v3 API.
+      store.dispatch(userv0.fetch(this.userDisplayName));
+
+      // Typically we would prefer 'users/<userId>' instead.
+      store.dispatch(users.fetch(`users/${this.userDisplayName}`));
+    }
+
+    if (changedProperties.has('pageTitle')) {
+      // To ensure that changes to the page title are easy to reason about,
+      // we want to sync the current pageTitle in the Redux state to
+      // document.title in only one place in the code.
+      document.title = this.pageTitle;
+    }
+    if (changedProperties.has('page')) {
+      trackPageChange(this.page, this.userDisplayName);
+    }
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    // TODO(zhangtiff): Figure out some way to save Redux state between
+    // page loads.
+
+    // page doesn't handle users reloading the page or closing a tab.
+    window.onbeforeunload = this._confirmDiscardMessage.bind(this);
+
+    // Start a cron task to periodically request the status from the server.
+    getServerStatusCron.start();
+
+    const postRouteHandler = this._postRouteHandler.bind(this);
+
+    // Populate the project route parameter before _preRouteHandler runs.
+    page('/p/:project/*', (_ctx, next) => next());
+    page('*', this._preRouteHandler.bind(this));
+
+    page('/hotlists/:hotlist', (ctx) => {
+      page.redirect(`/hotlists/${ctx.params.hotlist}/issues`);
+    });
+    page('/hotlists/:hotlist/*', this._selectHotlist);
+    page('/hotlists/:hotlist/issues',
+        this._loadHotlistIssuesPage.bind(this), postRouteHandler);
+    page('/hotlists/:hotlist/people',
+        this._loadHotlistPeoplePage.bind(this), postRouteHandler);
+    page('/hotlists/:hotlist/settings',
+        this._loadHotlistSettingsPage.bind(this), postRouteHandler);
+
+    // Handle Monorail's landing page.
+    page('/p', '/');
+    page('/projects', '/');
+    page('/hosting', '/');
+    page('/', this._loadProjectsPage.bind(this), postRouteHandler);
+
+    page('/p/:project/issues/list', this._loadListPage.bind(this),
+        postRouteHandler);
+    page('/p/:project/issues/detail', this._loadIssuePage.bind(this),
+        postRouteHandler);
+    page('/p/:project/issues/entry_new', this._loadEntryPage.bind(this),
+        postRouteHandler);
+    page('/p/:project/issues/wizard', this._loadWizardPage.bind(this),
+        postRouteHandler);
+
+    // Redirects from old hotlist pages to SPA hotlist pages.
+    const hotlistRedirect = (pageName) => async (ctx) => {
+      const name =
+          await hotlists.getHotlistName(ctx.params.user, ctx.params.hotlist);
+      page.redirect(`/${name}/${pageName}`);
+    };
+    page('/users/:user/hotlists/:hotlist', hotlistRedirect('issues'));
+    page('/users/:user/hotlists/:hotlist/people', hotlistRedirect('people'));
+    page('/users/:user/hotlists/:hotlist/details', hotlistRedirect('settings'));
+
+    page();
+  }
+
+  /**
+   * Handler that runs on every single route change, before the new page has
+   * loaded. This function should not use store.dispatch() or assign properties
+   * on this because running these actions causes extra re-renders to happen.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  _preRouteHandler(ctx, next) {
+    // We're not really navigating anywhere, so don't do anything.
+    if (this._lastContext && this._lastContext.path &&
+      ctx.path === this._lastContext.path) {
+      Object.assign(ctx, this._lastContext);
+      // Set ctx.handled to false, so we don't push the state to browser's
+      // history.
+      ctx.handled = false;
+      return;
+    }
+
+    // Check if there were forms with unsaved data before loading the next
+    // page.
+    const discardMessage = this._confirmDiscardMessage();
+    if (discardMessage && !confirm(discardMessage)) {
+      Object.assign(ctx, this._lastContext);
+      // Set ctx.handled to false, so we don't push the state to browser's
+      // history.
+      ctx.handled = false;
+      // We don't call next to avoid loading whatever page was supposed to
+      // load next.
+      return;
+    }
+
+    // Run query string parsing on all routes. Query params must be parsed
+    // before routes are loaded because some routes use them to conditionally
+    // load bundles.
+    // Based on: https://visionmedia.github.io/page.js/#plugins
+    const params = qs.parse(ctx.querystring);
+
+    // Make sure queryParams are not case sensitive.
+    const lowerCaseParams = {};
+    Object.keys(params).forEach((key) => {
+      lowerCaseParams[key.toLowerCase()] = params[key];
+    });
+    ctx.queryParams = lowerCaseParams;
+
+    this._selectProject(ctx.params.project);
+
+    next();
+  }
+
+  /**
+   * Handler that runs on every single route change, after the new page has
+   * loaded.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  _postRouteHandler(ctx, next) {
+    // Scroll to the requested element if a hash is present.
+    if (ctx.hash) {
+      store.dispatch(ui.setFocusId(ctx.hash));
+    }
+
+    // Sync queryParams to Redux after the route has loaded, rather than before,
+    // to avoid having extra queryParams update on the previously loaded
+    // component.
+    store.dispatch(sitewide.setQueryParams(ctx.queryParams));
+
+    // Increment the count of navigations in the Redux store.
+    store.dispatch(ui.incrementNavigationCount());
+
+    // Clear dirty forms when entering a new page.
+    store.dispatch(ui.clearDirtyForms());
+
+
+    if (!this._lastContext || this._lastContext.pathname !== ctx.pathname ||
+        this._hasReleventParamChanges(ctx.queryParams,
+            this._lastContext.queryParams)) {
+      // Reset the scroll position after a new page has rendered.
+      window.scrollTo(0, 0);
+    }
+
+    // Save the context of this page to be compared to later.
+    this._lastContext = ctx;
+  }
+
+  /**
+   * Finds if a route change changed query params in a way that should cause
+   * scrolling to reset.
+   * @param {Object} currentParams
+   * @param {Object} oldParams
+   * @param {Array<string>=} paramsToCompare Which params to check.
+   * @return {boolean} Whether any of the relevant query params changed.
+   */
+  _hasReleventParamChanges(currentParams, oldParams,
+      paramsToCompare = QUERY_PARAMS_THAT_RESET_SCROLL) {
+    return paramsToCompare.some((paramName) => {
+      return currentParams[paramName] !== oldParams[paramName];
+    });
+  }
+
+  /**
+   * Helper to manage syncing project route state to Redux.
+   * @param {string=} project displayName for a referenced project.
+   *   Defaults to null for consistency with Redux.
+   */
+  _selectProject(project = null) {
+    if (projectV0.viewedProjectName(store.getState()) !== project) {
+      // Note: We want to update the project even if the new project
+      // is null.
+      store.dispatch(projectV0.select(project));
+      if (project) {
+        store.dispatch(projectV0.fetch(project));
+      }
+    }
+  }
+
+  /**
+   * Loads and triggers rendering for the list of all projects.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  async _loadProjectsPage(ctx, next) {
+    await import(/* webpackChunkName: "mr-projects-page" */
+        '../projects/mr-projects-page/mr-projects-page.js');
+    this.page = 'projects';
+    next();
+  }
+
+  /**
+   * Loads and triggers render for the issue detail page.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  async _loadIssuePage(ctx, next) {
+    performance.clearMarks('start load issue detail page');
+    performance.mark('start load issue detail page');
+
+    await import(/* webpackChunkName: "mr-issue-page" */
+        '../issue-detail/mr-issue-page/mr-issue-page.js');
+
+    const issueRef = {
+      localId: Number.parseInt(ctx.queryParams.id),
+      projectName: ctx.params.project,
+    };
+    store.dispatch(issueV0.viewIssue(issueRef));
+    store.dispatch(issueV0.fetchIssuePageData(issueRef));
+    this.page = 'detail';
+    next();
+  }
+
+  /**
+   * Loads and triggers render for the issue list page, including the list,
+   * grid, and chart modes.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  async _loadListPage(ctx, next) {
+    performance.clearMarks('start load issue list page');
+    performance.mark('start load issue list page');
+    switch (ctx.queryParams && ctx.queryParams.mode &&
+        ctx.queryParams.mode.toLowerCase()) {
+      case 'grid':
+        await import(/* webpackChunkName: "mr-grid-page" */
+            '../issue-list/mr-grid-page/mr-grid-page.js');
+        this.page = 'grid';
+        break;
+      case 'chart':
+        await import(/* webpackChunkName: "mr-chart-page" */
+            '../issue-list/mr-chart-page/mr-chart-page.js');
+        this.page = 'chart';
+        break;
+      default:
+        this.page = 'list';
+        break;
+    }
+    next();
+  }
+
+  /**
+   * Load the issue entry page
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  _loadEntryPage(ctx, next) {
+    this.page = 'entry';
+    next();
+  }
+
+  /**
+   * Load the issue wizard
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  async _loadWizardPage(ctx, next) {
+    const {renderWizard} = await import(
+        /* webpackChunkName: "IssueWizard" */ '../../react/IssueWizard.tsx');
+
+    this.page = 'wizard';
+    next();
+
+    await this.updateComplete;
+
+    const mount = document.getElementById('reactMount');
+
+    renderWizard(mount);
+  }
+
+  /**
+   * Gets the currently viewed HotlistRef from the URL, selects
+   * it in the Redux store, and fetches the Hotlist data.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  _selectHotlist(ctx, next) {
+    const name = 'hotlists/' + ctx.params.hotlist;
+    store.dispatch(hotlists.select(name));
+    store.dispatch(hotlists.fetch(name));
+    store.dispatch(hotlists.fetchItems(name));
+    store.dispatch(permissions.batchGet([name]));
+    next();
+  }
+
+  /**
+   * Loads mr-hotlist-issues-page.js and makes it the currently viewed page.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  async _loadHotlistIssuesPage(ctx, next) {
+    await import(/* webpackChunkName: "mr-hotlist-issues-page" */
+        `../hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js`);
+    this.page = 'hotlist-issues';
+    next();
+  }
+
+  /**
+   * Loads mr-hotlist-people-page.js and makes it the currently viewed page.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  async _loadHotlistPeoplePage(ctx, next) {
+    await import(/* webpackChunkName: "mr-hotlist-people-page" */
+        `../hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js`);
+    this.page = 'hotlist-people';
+    next();
+  }
+
+  /**
+   * Loads mr-hotlist-settings-page.js and makes it the currently viewed page.
+   * @param {PageJS.Context} ctx A page.js Context containing routing state.
+   * @param {function} next Passes execution on to the next registered callback.
+   */
+  async _loadHotlistSettingsPage(ctx, next) {
+    await import(/* webpackChunkName: "mr-hotlist-settings-page" */
+        `../hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js`);
+    this.page = 'hotlist-settings';
+    next();
+  }
+
+  /**
+   * Constructs a message to warn users about dirty forms when they navigate
+   * away from a page, to prevent them from loasing data.
+   * @return {string} Message shown to users to warn about in flight form
+   *   changes.
+   */
+  _confirmDiscardMessage() {
+    if (!this.dirtyForms.length) return null;
+    const dirtyFormsMessage =
+      'Discard your changes in the following forms?\n' +
+      arrayToEnglish(this.dirtyForms);
+    return dirtyFormsMessage;
+  }
+}
+
+customElements.define('mr-app', MrApp);
diff --git a/static_src/elements/mr-app/mr-app.test.js b/static_src/elements/mr-app/mr-app.test.js
new file mode 100644
index 0000000..47b953b
--- /dev/null
+++ b/static_src/elements/mr-app/mr-app.test.js
@@ -0,0 +1,300 @@
+// 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 {MrApp} from './mr-app.js';
+import {store, resetState} from 'reducers/base.js';
+import {select} from 'reducers/projectV0.js';
+
+let element;
+let next;
+
+window.CS_env = {
+  token: 'foo-token',
+};
+
+describe('mr-app', () => {
+  beforeEach(() => {
+    global.ga = sinon.spy();
+    store.dispatch(resetState());
+    element = document.createElement('mr-app');
+    document.body.appendChild(element);
+    element.formsToCheck = [];
+
+    next = sinon.stub();
+  });
+
+  afterEach(() => {
+    global.ga.resetHistory();
+    document.body.removeChild(element);
+    next.reset();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrApp);
+  });
+
+  describe('snackbar handling', () => {
+    beforeEach(() => {
+      sinon.spy(store, 'dispatch');
+    });
+
+    afterEach(() => {
+      store.dispatch.restore();
+    });
+
+    it('renders no snackbars', async () => {
+      element._snackbars = [];
+
+      await element.updateComplete;
+
+      const snackbars = element.querySelectorAll('chops-snackbar');
+
+      assert.equal(snackbars.length, 0);
+    });
+
+    it('renders multiple snackbars', async () => {
+      element._snackbars = [
+        {text: 'Snackbar one', id: 'one'},
+        {text: 'Snackbar two', id: 'two'},
+        {text: 'Snackbar three', id: 'thre'},
+      ];
+
+      await element.updateComplete;
+
+      const snackbars = element.querySelectorAll('chops-snackbar');
+
+      assert.equal(snackbars.length, 3);
+
+      assert.include(snackbars[0].textContent, 'Snackbar one');
+      assert.include(snackbars[1].textContent, 'Snackbar two');
+      assert.include(snackbars[2].textContent, 'Snackbar three');
+    });
+
+    it('closing snackbar hides snackbar', async () => {
+      element._snackbars = [
+        {text: 'Snackbar', id: 'one'},
+      ];
+
+      await element.updateComplete;
+
+      const snackbar = element.querySelector('chops-snackbar');
+
+      snackbar.close();
+
+      sinon.assert.calledWith(store.dispatch,
+          {type: 'HIDE_SNACKBAR', id: 'one'});
+    });
+  });
+
+  it('_preRouteHandler calls next()', () => {
+    const ctx = {params: {}};
+
+    element._preRouteHandler(ctx, next);
+
+    sinon.assert.calledOnce(next);
+  });
+
+  it('_preRouteHandler does not call next() on same page nav', () => {
+    element._lastContext = {path: '123'};
+    const ctx = {params: {}, path: '123'};
+
+    element._preRouteHandler(ctx, next);
+
+    assert.isFalse(ctx.handled);
+    sinon.assert.notCalled(next);
+  });
+
+  it('_preRouteHandler parses queryParams', () => {
+    const ctx = {params: {}, querystring: 'q=owner:me&colspec=Summary'};
+    element._preRouteHandler(ctx, next);
+
+    assert.deepEqual(ctx.queryParams, {q: 'owner:me', colspec: 'Summary'});
+  });
+
+  it('_preRouteHandler ignores case for queryParams keys', () => {
+    const ctx = {params: {},
+      querystring: 'Q=owner:me&ColSpeC=Summary&x=owner'};
+    element._preRouteHandler(ctx, next);
+
+    assert.deepEqual(ctx.queryParams, {q: 'owner:me', colspec: 'Summary',
+      x: 'owner'});
+  });
+
+  it('_preRouteHandler ignores case for queryParams keys', () => {
+    const ctx = {params: {},
+      querystring: 'Q=owner:me&ColSpeC=Summary&x=owner'};
+    element._preRouteHandler(ctx, next);
+
+    assert.deepEqual(ctx.queryParams, {q: 'owner:me', colspec: 'Summary',
+      x: 'owner'});
+  });
+
+  it('_postRouteHandler saves ctx.queryParams to Redux', () => {
+    const ctx = {queryParams: {q: '1234'}};
+    element._postRouteHandler(ctx, next);
+
+    assert.deepEqual(element.queryParams, {q: '1234'});
+  });
+
+  it('_postRouteHandler saves ctx to this._lastContext', () => {
+    const ctx = {path: '1234'};
+    element._postRouteHandler(ctx, next);
+
+    assert.deepEqual(element._lastContext, {path: '1234'});
+  });
+
+  describe('scroll to the top on page changes', () => {
+    beforeEach(() => {
+      sinon.stub(window, 'scrollTo');
+    });
+
+    afterEach(() => {
+      window.scrollTo.restore();
+    });
+
+    it('scrolls page to top on initial load', () => {
+      element._lastContext = null;
+      const ctx = {params: {}, path: '1234'};
+      element._postRouteHandler(ctx, next);
+
+      sinon.assert.calledWith(window.scrollTo, 0, 0);
+    });
+
+    it('scrolls page to top on parh change', () => {
+      element._lastContext = {params: {}, pathname: '/list',
+        path: '/list?q=123', querystring: '?q=123', queryParams: {q: '123'}};
+      const ctx = {params: {}, pathname: '/other',
+        path: '/other?q=123', querystring: '?q=123', queryParams: {q: '123'}};
+
+      element._postRouteHandler(ctx, next);
+
+      sinon.assert.calledWith(window.scrollTo, 0, 0);
+    });
+
+    it('does not scroll to top when on the same path', () => {
+      element._lastContext = {pathname: '/list', path: '/list?q=123',
+        querystring: '?a=123', queryParams: {a: '123'}};
+      const ctx = {pathname: '/list', path: '/list?q=456',
+        querystring: '?a=456', queryParams: {a: '456'}};
+
+      element._postRouteHandler(ctx, next);
+
+      sinon.assert.notCalled(window.scrollTo);
+    });
+
+    it('scrolls to the top on same path when q param changes', () => {
+      element._lastContext = {pathname: '/list', path: '/list?q=123',
+        querystring: '?q=123', queryParams: {q: '123'}};
+      const ctx = {pathname: '/list', path: '/list?q=456',
+        querystring: '?q=456', queryParams: {q: '456'}};
+
+      element._postRouteHandler(ctx, next);
+
+      sinon.assert.calledWith(window.scrollTo, 0, 0);
+    });
+  });
+
+
+  it('_postRouteHandler does not call next', () => {
+    const ctx = {path: '1234'};
+    element._postRouteHandler(ctx, next);
+
+    sinon.assert.notCalled(next);
+  });
+
+  it('_loadIssuePage loads issue page', async () => {
+    await element._loadIssuePage({
+      queryParams: {id: '234'},
+      params: {project: 'chromium'},
+    }, next);
+    await element.updateComplete;
+
+    // Check that only one page element is rendering at a time.
+    const main = element.querySelector('main');
+    assert.equal(main.children.length, 1);
+
+    const issuePage = element.querySelector('mr-issue-page');
+    assert.isDefined(issuePage, 'issue page is defined');
+    assert.equal(issuePage.issueRef.projectName, 'chromium');
+    assert.equal(issuePage.issueRef.localId, 234);
+  });
+
+  it('_loadListPage loads list page', async () => {
+    await element._loadListPage({
+      params: {project: 'chromium'},
+    }, next);
+    await element.updateComplete;
+
+    // Check that only one page element is rendering at a time.
+    const main = element.querySelector('main');
+    assert.equal(main.children.length, 1);
+
+    const listPage = element.querySelector('mr-list-page');
+    assert.isDefined(listPage, 'list page is defined');
+  });
+
+  it('_loadListPage loads grid page', async () => {
+    element.queryParams = {mode: 'grid'};
+    await element._loadListPage({
+      params: {project: 'chromium'},
+    }, next);
+    await element.updateComplete;
+
+    // Check that only one page element is rendering at a time.
+    const main = element.querySelector('main');
+    assert.equal(main.children.length, 1);
+
+    const gridPage = element.querySelector('mr-grid-page');
+    assert.isDefined(gridPage, 'grid page is defined');
+  });
+
+  describe('_selectProject', () => {
+    beforeEach(() => {
+      sinon.spy(store, 'dispatch');
+    });
+
+    afterEach(() => {
+      store.dispatch.restore();
+    });
+
+    it('selects and fetches project', () => {
+      const projectName = 'chromium';
+      assert.notEqual(store.getState().projectV0.name, projectName);
+
+      element._selectProject(projectName);
+
+      sinon.assert.calledTwice(store.dispatch);
+    });
+
+    it('skips selecting and fetching when project isn\'t changing', () => {
+      const projectName = 'chromium';
+
+      store.dispatch.restore();
+      store.dispatch(select(projectName));
+      sinon.spy(store, 'dispatch');
+
+      assert.equal(store.getState().projectV0.name, projectName);
+
+      element._selectProject(projectName);
+
+      sinon.assert.notCalled(store.dispatch);
+    });
+
+    it('selects without fetching when transitioning to null', () => {
+      const projectName = 'chromium';
+
+      store.dispatch.restore();
+      store.dispatch(select(projectName));
+      sinon.spy(store, 'dispatch');
+
+      assert.equal(store.getState().projectV0.name, projectName);
+
+      element._selectProject(null);
+
+      sinon.assert.calledOnce(store.dispatch);
+    });
+  });
+});
diff --git a/static_src/elements/projects/mr-projects-page/helpers.js b/static_src/elements/projects/mr-projects-page/helpers.js
new file mode 100644
index 0000000..5c12ae8
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/helpers.js
@@ -0,0 +1,30 @@
+// Copyright 2020 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 {projectMemberToProjectName} from 'shared/converters.js';
+
+// TODO(crbug.com/monorail/7910): Dedupe this with the similar "projectRoles"
+// constant in <mr-header>.
+const projectRoles = Object.freeze({
+  PROJECT_ROLE_UNSPECIFIED: '',
+  OWNER: 'Owner',
+  COMMITTER: 'Committer',
+  CONTRIBUTOR: 'Contributor',
+});
+
+/**
+ * Creates a mapping of project names to the user's role in that project.
+ * @param {Array<ProjectMember>} projectMembers Project memebrships
+ *   for a given user.
+ * @return {Object<ProjectName, string>} Mapping of a user's roles,
+ *   by project name.
+ */
+export function computeRoleByProjectName(projectMembers) {
+  const mapping = {};
+  if (!projectMembers) return mapping;
+  projectMembers.forEach(({name, role}) => {
+    mapping[projectMemberToProjectName(name)] = projectRoles[role];
+  });
+  return mapping;
+}
diff --git a/static_src/elements/projects/mr-projects-page/helpers.test.js b/static_src/elements/projects/mr-projects-page/helpers.test.js
new file mode 100644
index 0000000..9e3c5a2
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/helpers.test.js
@@ -0,0 +1,24 @@
+// Copyright 2020 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 {computeRoleByProjectName} from './helpers.js';
+
+describe('computeRoleByProjectName', () => {
+  it('handles empty project memberships', () => {
+    assert.deepEqual(computeRoleByProjectName(undefined), {});
+    assert.deepEqual(computeRoleByProjectName([]), {});
+  });
+
+  it('creates mapping', () => {
+    const projectMembers = [
+      {role: 'OWNER', name: 'projects/project-name/members/1234'},
+      {role: 'COMMITTER', name: 'projects/test/members/1234'},
+    ];
+    assert.deepEqual(computeRoleByProjectName(projectMembers), {
+      'projects/project-name': 'Owner',
+      'projects/test': 'Committer',
+    });
+  });
+});
diff --git a/static_src/elements/projects/mr-projects-page/mr-projects-page.js b/static_src/elements/projects/mr-projects-page/mr-projects-page.js
new file mode 100644
index 0000000..1124ef0
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/mr-projects-page.js
@@ -0,0 +1,297 @@
+// 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 {store, connectStore} from 'reducers/base.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'elements/framework/mr-star/mr-project-star.js';
+import 'shared/typedef.js';
+import 'elements/chops/chops-chip/chops-chip.js';
+
+import * as projects from 'reducers/projects.js';
+import {users} from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {computeRoleByProjectName} from './helpers.js';
+
+
+/**
+ * `<mr-projects-page>`
+ *
+ * Displays list of all projects.
+ *
+ */
+export class MrProjectsPage extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          box-sizing: border-box;
+          display: block;
+          padding: 1em 8px;
+          padding-left: 40px; /** 32px + 8px */
+          margin: auto;
+          max-width: 1280px;
+          width: 100%;
+        }
+        :host::after {
+          content: "";
+          background-image: url('/static/images/chromium.svg');
+          background-repeat: no-repeat;
+          background-position: right -100px bottom -150px;
+          background-size: 700px;
+          opacity: 0.09;
+          width: 100%;
+          height: 100%;
+          bottom: 0;
+          right: 0;
+          position: fixed;
+          z-index: -1;
+        }
+        h2 {
+          font-size: 20px;
+          letter-spacing: 0.1px;
+          font-weight: 500;
+          margin-top: 1em;
+        }
+        .project-header {
+          display: flex;
+          align-items: flex-start;
+          flex-direction: row;
+          justify-content: space-between;
+          font-size: 16px;
+          line-height: 24px;
+          margin: 0;
+          margin-bottom: 16px;
+          padding-top: 0.1em;
+          padding-bottom: 16px;
+          letter-spacing: 0.1px;
+          font-weight: 500;
+          width: 100%;
+          border-bottom: var(--chops-normal-border);
+          border-color: var(--chops-gray-400);
+        }
+        .project-title {
+          display: flex;
+          flex-direction: column;
+        }
+        h3 {
+          margin: 0;
+          padding: 0;
+          font-weight: inherit;
+          font-size: inherit;
+          transition: color var(--chops-transition-time) ease-in-out;
+        }
+        h3:hover {
+          color: var(--chops-link-color);
+        }
+        .subtitle {
+          color: var(--chops-gray-700);
+          font-size: var(--chops-main-font-size);
+          line-height: 100%;
+          font-weight: normal;
+        }
+        .project-container {
+          display: flex;
+          align-items: stretch;
+          flex-wrap: wrap;
+          width: 100%;
+          padding: 0.5em 0;
+          margin-bottom: 3em;
+        }
+        .project {
+          background: var(--chops-white);
+          width: 220px;
+          margin-right: 32px;
+          margin-bottom: 32px;
+          display: flex;
+          flex-direction: column;
+          align-items: flex-start;
+          justify-content: flex-start;
+          border-radius: 4px;
+          border: var(--chops-normal-border);
+          padding: 16px;
+          color: var(--chops-primary-font-color);
+          font-weight: normal;
+          line-height: 16px;
+          transition: all var(--chops-transition-time) ease-in-out;
+        }
+        .project:hover {
+          text-decoration: none;
+          cursor: pointer;
+          box-shadow: 0 2px 6px hsla(0,0%,0%,0.12),
+            0 1px 3px hsla(0,0%,0%,0.24);
+        }
+        .project > p {
+          margin: 0;
+          margin-bottom: 32px;
+          flex-grow: 1;
+        }
+        .view-project-link {
+          text-transform: uppercase;
+          margin: 0;
+          font-weight: 600;
+          flex-grow: 0;
+        }
+        .view-project-link:hover {
+          text-decoration: underline;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const myProjects = this.myProjects;
+    const otherProjects = this.otherProjects;
+    const noProjects = !myProjects.length && !otherProjects.length;
+
+    if (this._isFetchingProjects && noProjects) {
+      return html`Loading...`;
+    }
+
+    if (noProjects) {
+      return html`No projects found.`;
+    }
+
+    if (!myProjects.length) {
+      // Skip sorting projects into different sections if the user
+      // has no projects.
+      return html`
+        <h2>All projects</h2>
+        <div class="project-container all-projects">
+          ${otherProjects.map((project) => this._renderProject(project))}
+        </div>
+      `;
+    }
+
+    const myProjectsTemplate = myProjects.map((project) => this._renderProject(
+        project, this._roleByProjectName[project.name]));
+
+    return html`
+      <h2>My projects</h2>
+      <div class="project-container my-projects">
+        ${myProjectsTemplate}
+      </div>
+
+      <h2>Other projects</h2>
+      <div class="project-container other-projects">
+        ${otherProjects.map((project) => this._renderProject(project))}
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      _projects: {type: Array},
+      _isFetchingProjects: {type: Boolean},
+      _currentUser: {type: String},
+      _roleByProjectName: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /**
+     * @type {Array<Project>}
+     */
+    this._projects = [];
+    /**
+     * @type {boolean}
+     */
+    this._isFetchingProjects = false;
+    /**
+     * @type {string}
+     */
+    this._currentUser = undefined;
+    /**
+     * @type {Object<ProjectName, string>}
+     */
+    this._roleByProjectName = {};
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    store.dispatch(projects.list());
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('_currentUser') && this._currentUser) {
+      const userName = this._currentUser;
+      store.dispatch(users.gatherProjectMemberships(userName));
+      store.dispatch(stars.listProjects(userName));
+    }
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._projects = projects.all(state);
+    this._isFetchingProjects = projects.requests(state).list.requesting;
+    this._currentUser = users.currentUserName(state);
+    const allProjectMemberships = users.projectMemberships(state);
+    this._roleByProjectName = computeRoleByProjectName(
+        allProjectMemberships[this._currentUser]);
+  }
+
+  /**
+   * @param {Project} project
+   * @param {string=} role
+   * @return {TemplateResult}
+   */
+  _renderProject(project, role) {
+    return html`
+      <a href="/p/${project.displayName}/issues/list" class="project">
+        <div class="project-header">
+          <span class="project-title">
+            <h3>${project.displayName}</h3>
+            <span class="subtitle" ?hidden=${!role} title="My role: ${role}">
+              Role: ${role}
+            </span>
+          </span>
+
+          <mr-project-star .name=${project.name}></mr-project-star>
+        </div>
+        <p>
+          ${project.summary}
+        </p>
+        <button class="view-project-link linkify">
+          View project
+        </button>
+      </a>
+    `;
+  }
+
+  /**
+   * Projects the currently logged in user is a member of.
+   * @return {Array<Project>}
+   */
+  get myProjects() {
+    return this._projects.filter(
+        ({name}) => this._userIsMemberOfProject(name));
+  }
+
+  /**
+   * Projects the currently logged in user is not a member of.
+   * @return {Array<Project>}
+   */
+  get otherProjects() {
+    return this._projects.filter(
+        ({name}) => !this._userIsMemberOfProject(name));
+  }
+
+  /**
+   * Helper to check if a user is a member of a project.
+   * @param {ProjectName} project Resource name of a project.
+   * @return {boolean} Whether the user a member of the given project.
+   */
+  _userIsMemberOfProject(project) {
+    return project in this._roleByProjectName;
+  }
+}
+customElements.define('mr-projects-page', MrProjectsPage);
diff --git a/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js b/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js
new file mode 100644
index 0000000..1a9a1e4
--- /dev/null
+++ b/static_src/elements/projects/mr-projects-page/mr-projects-page.test.js
@@ -0,0 +1,248 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+import {stateUpdated} from 'reducers/base.js';
+import {users} from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {MrProjectsPage} from './mr-projects-page.js';
+
+let element;
+
+describe('mr-projects-page', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-projects-page');
+    document.body.appendChild(element);
+
+    sinon.stub(element, 'stateChanged');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrProjectsPage);
+  });
+
+  it('renders loading', async () => {
+    element._isFetchingProjects = true;
+
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.textContent.trim(), 'Loading...');
+  });
+
+  it('renders projects when refetching projects', async () => {
+    element._isFetchingProjects = true;
+    element._projects = [
+      {name: 'projects/chromium', displayName: 'chromium',
+        summary: 'Best project ever'},
+    ];
+
+    await element.updateComplete;
+
+    const headers = element.shadowRoot.querySelectorAll('h2');
+
+    assert.equal(headers.length, 1);
+    assert.equal(headers[0].textContent.trim(), 'All projects');
+
+    const projects = element.shadowRoot.querySelectorAll(
+        '.all-projects > .project');
+    assert.equal(projects.length, 1);
+
+    assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+    assert.include(projects[0].textContent, 'Best project ever');
+  });
+
+  it('renders all projects when no user projects', async () => {
+    element._isFetchingProjects = false;
+    element._projects = [
+      {name: 'projects/chromium', displayName: 'chromium',
+        summary: 'Best project ever'},
+      {name: 'projects/infra', displayName: 'infra',
+        summary: 'Make it work'},
+    ];
+
+    await element.updateComplete;
+
+    const headers = element.shadowRoot.querySelectorAll('h2');
+
+    assert.equal(headers.length, 1);
+    assert.equal(headers[0].textContent.trim(), 'All projects');
+
+    const projects = element.shadowRoot.querySelectorAll(
+        '.all-projects > .project');
+    assert.equal(projects.length, 2);
+
+    assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+    assert.include(projects[0].textContent, 'Best project ever');
+
+    assert.include(projects[1].querySelector('h3').textContent, 'infra');
+    assert.include(projects[1].textContent, 'Make it work');
+  });
+
+  it('renders no projects found', async () => {
+    element._isFetchingProjects = false;
+    sinon.stub(element, 'myProjects').get(() => []);
+    sinon.stub(element, 'otherProjects').get(() => []);
+
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.textContent.trim(), 'No projects found.');
+  });
+
+  describe('project grouping', () => {
+    beforeEach(() => {
+      element._projects = [
+        {name: 'projects/chromium', displayName: 'chromium',
+          summary: 'Best project ever'},
+        {name: 'projects/infra', displayName: 'infra',
+          summary: 'Make it work'},
+        {name: 'projects/test', displayName: 'test',
+          summary: 'Hmm'},
+        {name: 'projects/a-project', displayName: 'a-project',
+          summary: 'I am Monkeyrail'},
+      ];
+      element._roleByProjectName = {
+        'projects/chromium': 'Owner',
+        'projects/infra': 'Committer',
+      };
+      element._isFetchingProjects = false;
+    });
+
+    it('myProjects filters out non-member projects', () => {
+      assert.deepEqual(element.myProjects, [
+        {name: 'projects/chromium', displayName: 'chromium',
+          summary: 'Best project ever'},
+        {name: 'projects/infra', displayName: 'infra',
+          summary: 'Make it work'},
+      ]);
+    });
+
+    it('otherProjects filters out member projects', () => {
+      assert.deepEqual(element.otherProjects, [
+        {name: 'projects/test', displayName: 'test',
+          summary: 'Hmm'},
+        {name: 'projects/a-project', displayName: 'a-project',
+          summary: 'I am Monkeyrail'},
+      ]);
+    });
+
+    it('renders user projects', async () => {
+      await element.updateComplete;
+
+      const projects = element.shadowRoot.querySelectorAll(
+          '.my-projects > .project');
+
+      assert.equal(projects.length, 2);
+      assert.include(projects[0].querySelector('h3').textContent, 'chromium');
+      assert.include(projects[0].textContent, 'Best project ever');
+      assert.include(projects[0].querySelector('.subtitle').textContent,
+          'Owner');
+
+      assert.include(projects[1].querySelector('h3').textContent, 'infra');
+      assert.include(projects[1].textContent, 'Make it work');
+      assert.include(projects[1].querySelector('.subtitle').textContent,
+          'Committer');
+    });
+
+    it('renders other projects', async () => {
+      await element.updateComplete;
+
+      const projects = element.shadowRoot.querySelectorAll(
+          '.other-projects > .project');
+
+      assert.equal(projects.length, 2);
+      assert.include(projects[0].querySelector('h3').textContent, 'test');
+      assert.include(projects[0].textContent, 'Hmm');
+
+      assert.include(projects[1].querySelector('h3').textContent, 'a-project');
+      assert.include(projects[1].textContent, 'I am Monkeyrail');
+    });
+  });
+});
+
+describe('mr-projects-page (connected)', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    sinon.spy(users, 'gatherProjectMemberships');
+    sinon.spy(stars, 'listProjects');
+
+    element = document.createElement('mr-projects-page');
+  });
+
+  afterEach(() => {
+    if (document.body.contains(element)) {
+      document.body.removeChild(element);
+    }
+
+    prpcClient.call.restore();
+    users.gatherProjectMemberships.restore();
+    stars.listProjects.restore();
+  });
+
+  it('fetches projects when connected', async () => {
+    const promise = Promise.resolve({
+      projects: [{name: 'projects/proj', displayName: 'proj',
+        summary: 'test'}],
+    });
+    prpcClient.call.returns(promise);
+
+    assert.isFalse(element._isFetchingProjects);
+    sinon.assert.notCalled(prpcClient.call);
+
+    // Trigger connectedCallback().
+    document.body.appendChild(element);
+    await stateUpdated, element.updateComplete;
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.v3.Projects',
+        'ListProjects', {});
+
+    assert.isFalse(element._isFetchingProjects);
+    assert.deepEqual(element._projects,
+        [{name: 'projects/proj', displayName: 'proj',
+          summary: 'test'}]);
+  });
+
+  it('does not gather projects when user is logged out', async () => {
+    document.body.appendChild(element);
+    element._currentUser = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(users.gatherProjectMemberships);
+  });
+
+  it('gathers user projects when user is logged in', async () => {
+    document.body.appendChild(element);
+    element._currentUser = 'users/1234';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(users.gatherProjectMemberships);
+    sinon.assert.calledWith(users.gatherProjectMemberships, 'users/1234');
+  });
+
+  it('does not fetch stars user is logged out', async () => {
+    document.body.appendChild(element);
+    element._currentUser = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(stars.listProjects);
+  });
+
+  it('fetches stars when user is logged in', async () => {
+    document.body.appendChild(element);
+    element._currentUser = 'users/1234';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(stars.listProjects);
+    sinon.assert.calledWith(stars.listProjects, 'users/1234');
+  });
+});
diff --git a/static_src/monitoring/client-logger.js b/static_src/monitoring/client-logger.js
new file mode 100644
index 0000000..37959c0
--- /dev/null
+++ b/static_src/monitoring/client-logger.js
@@ -0,0 +1,272 @@
+/* Copyright 2018 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 MonorailTSMon from './monorail-ts-mon.js';
+
+/**
+ * ClientLogger is a JavaScript library for tracking events with Google
+ * Analytics and ts_mon.
+ *
+ * @example
+ * // Example usage (tracking time to create a new issue, including time spent
+ * // by the user editing stuff):
+ *
+ *
+ * // t0: on page load for /issues/new:
+ * let l = new Clientlogger('issues');
+ * l.logStart('new-issue', 'user-time');
+ *
+ * // t1: on submit for /issues/new:
+ *
+ * l.logStart('new-issue', 'server-time');
+ *
+ * // t2: on page load for /issues/detail:
+ *
+ * let l = new Clientlogger('issues');
+ *
+ * if (l.started('new-issue') {
+ *   l.logEnd('new-issue');
+ * }
+ *
+ * // This would record the following metrics:
+ *
+ * issues.new-issue {
+ *   time: t2-t0
+ * }
+ *
+ * issues.new-issue["server-time"] {
+ *   time: t2-t1
+ * }
+ *
+ * issues.new-issue["user-time"] {
+ *   time: t1-t0
+ * }
+ */
+export default class ClientLogger {
+  /**
+   * @param {string} category Arbitrary string for categorizing metrics in
+   *   this client. Used by Google Analytics for event logging.
+   */
+  constructor(category) {
+    this.category = category;
+    this.tsMon = MonorailTSMon.getGlobalClient();
+
+    const categoryKey = `ClientLogger.${category}.started`;
+    const startedEvtsStr = sessionStorage[categoryKey];
+    if (startedEvtsStr) {
+      this.startedEvents = JSON.parse(startedEvtsStr);
+    } else {
+      this.startedEvents = {};
+    }
+  }
+
+  /**
+   * @param {string} eventName Arbitrary string for the name of the event.
+   *   ie: "issue-load"
+   * @return {Object} Event object for the string checked.
+   */
+  started(eventName) {
+    return this.startedEvents[eventName];
+  }
+
+  /**
+   * Log events that bookend some activity whose duration we’re interested in.
+   * @param {string} eventName Name of the event to start.
+   * @param {string} eventLabel Arbitrary string label to tie to event.
+   */
+  logStart(eventName, eventLabel) {
+    // Tricky situation: initial new issue POST gets rejected
+    // due to form validation issues.  Start a new timer, or keep
+    // the original?
+
+    const startedEvent = this.startedEvents[eventName] || {
+      time: new Date().getTime(),
+    };
+
+    if (eventLabel) {
+      if (!startedEvent.labels) {
+        startedEvent.labels = {};
+      }
+      startedEvent.labels[eventLabel] = new Date().getTime();
+    }
+
+    this.startedEvents[eventName] = startedEvent;
+
+    sessionStorage[`ClientLogger.${this.category}.started`] =
+        JSON.stringify(this.startedEvents);
+
+    logEvent(this.category, `${eventName}-start`, eventLabel);
+  }
+
+  /**
+   * Pause the stopwatch for this event.
+   * @param {string} eventName Name of the event to pause.
+   * @param {string} eventLabel Arbitrary string label tied to the event.
+   */
+  logPause(eventName, eventLabel) {
+    if (!eventLabel) {
+      throw `logPause called for event with no label: ${eventName}`;
+    }
+
+    const startEvent = this.startedEvents[eventName];
+
+    if (!startEvent) {
+      console.warn(`logPause called for event with no logStart: ${eventName}`);
+      return;
+    }
+
+    if (!startEvent.labels[eventLabel]) {
+      console.warn(`logPause called for event label with no logStart: ` +
+        `${eventName}.${eventLabel}`);
+      return;
+    }
+
+    const elapsed = new Date().getTime() - startEvent.labels[eventLabel];
+    if (!startEvent.elapsed) {
+      startEvent.elapsed = {};
+      startEvent.elapsed[eventLabel] = 0;
+    }
+
+    // Save accumulated time.
+    startEvent.elapsed[eventLabel] += elapsed;
+
+    sessionStorage[`ClientLogger.${this.category}.started`] =
+        JSON.stringify(this.startedEvents);
+  }
+
+  /**
+   * Resume the stopwatch for this event.
+   * @param {string} eventName Name of the event to resume.
+   * @param {string} eventLabel Arbitrary string label tied to the event.
+   */
+  logResume(eventName, eventLabel) {
+    if (!eventLabel) {
+      throw `logResume called for event with no label: ${eventName}`;
+    }
+
+    const startEvent = this.startedEvents[eventName];
+
+    if (!startEvent) {
+      console.warn(`logResume called for event with no logStart: ${eventName}`);
+      return;
+    }
+
+    if (!startEvent.hasOwnProperty('elapsed') ||
+        !startEvent.elapsed.hasOwnProperty(eventLabel)) {
+      console.warn(`logResume called for event that was never paused:` +
+        `${eventName}.${eventLabel}`);
+      return;
+    }
+
+    // TODO(jeffcarp): Throw if an event is resumed twice.
+
+    startEvent.labels[eventLabel] = new Date().getTime();
+
+    sessionStorage[`ClientLogger.${this.category}.started`] =
+        JSON.stringify(this.startedEvents);
+  }
+
+  /**
+   * Stop ecording this event.
+   * @param {string} eventName Name of the event to stop recording.
+   * @param {string} eventLabel Arbitrary string label tied to the event.
+   * @param {number=} maxThresholdMs Avoid sending timing data if it took
+   *   longer than this threshold.
+   */
+  logEnd(eventName, eventLabel, maxThresholdMs=null) {
+    const startEvent = this.startedEvents[eventName];
+
+    if (!startEvent) {
+      console.warn(`logEnd called for event with no logStart: ${eventName}`);
+      return;
+    }
+
+    // If they've specified a label, report the elapsed since the start
+    // of that label.
+    if (eventLabel) {
+      if (!startEvent.labels.hasOwnProperty(eventLabel)) {
+        console.warn(`logEnd called for event + label with no logStart: ` +
+          `${eventName}/${eventLabel}`);
+        return;
+      }
+
+      this._sendTiming(startEvent, eventName, eventLabel, maxThresholdMs);
+
+      delete startEvent.labels[eventLabel];
+      if (startEvent.hasOwnProperty('elapsed')) {
+        delete startEvent.elapsed[eventLabel];
+      }
+    } else {
+      // If no label is specified, report timing for the whole event.
+      this._sendTiming(startEvent, eventName, null, maxThresholdMs);
+
+      // And also end and report any labels they had running.
+      for (const label in startEvent.labels) {
+        this._sendTiming(startEvent, eventName, label, maxThresholdMs);
+      }
+
+      delete this.startedEvents[eventName];
+    }
+
+    sessionStorage[`ClientLogger.${this.category}.started`] =
+        JSON.stringify(this.startedEvents);
+    logEvent(this.category, `${eventName}-end`, eventLabel);
+  }
+
+  /**
+   * Helper to send data on the event to TSMon.
+   * @param {Object} event Data for the event being sent.
+   * @param {string} eventName Name of the event being sent.
+   * @param {string} recordOnlyThisLabel Label to record.
+   * @param {number=} maxThresholdMs Optional threshold to drop events
+   *   if they took too long.
+   * @private
+   */
+  _sendTiming(event, eventName, recordOnlyThisLabel, maxThresholdMs=null) {
+    // Calculate elapsed.
+    let elapsed;
+    if (recordOnlyThisLabel) {
+      elapsed = new Date().getTime() - event.labels[recordOnlyThisLabel];
+      if (event.elapsed && event.elapsed[recordOnlyThisLabel]) {
+        elapsed += event.elapsed[recordOnlyThisLabel];
+      }
+    } else {
+      elapsed = new Date().getTime() - event.time;
+    }
+
+    // Return if elapsed exceeds maxThresholdMs.
+    if (maxThresholdMs !== null && elapsed > maxThresholdMs) {
+      return;
+    }
+
+    const options = {
+      'timingCategory': this.category,
+      'timingVar': eventName,
+      'timingValue': elapsed,
+    };
+    if (recordOnlyThisLabel) {
+      options['timingLabel'] = recordOnlyThisLabel;
+    }
+    ga('send', 'timing', options);
+    this.tsMon.recordUserTiming(
+        this.category, eventName, recordOnlyThisLabel, elapsed);
+  }
+}
+
+/**
+ * Log single usr events with Google Analytics.
+ * @param {string} category Category of the event.
+ * @param {string} eventAction Name of the event.
+ * @param {string=} eventLabel Optional custom string value tied to the event.
+ * @param {number=} eventValue Optional custom number value tied to the event.
+ */
+export function logEvent(category, eventAction, eventLabel, eventValue) {
+  ga('send', 'event', category, eventAction, eventLabel,
+      eventValue);
+}
+
+// Until the rest of the app is in modules, this must be exposed on window.
+window.ClientLogger = ClientLogger;
diff --git a/static_src/monitoring/client-logger.test.js b/static_src/monitoring/client-logger.test.js
new file mode 100644
index 0000000..5c88355
--- /dev/null
+++ b/static_src/monitoring/client-logger.test.js
@@ -0,0 +1,627 @@
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import ClientLogger from './client-logger.js';
+import MonorailTSMon from './monorail-ts-mon.js';
+
+describe('ClientLogger', () => {
+  const startedKey = 'ClientLogger.rutabaga.started';
+  let c;
+
+  beforeEach(() => {
+    window.CS_env = {
+      token: 'rutabaga-token',
+      tokenExpiresSec: 1234,
+      app_version: 'rutabaga-version',
+    };
+    window.chops = {rpc: {PrpcClient: sinon.spy()}};
+    window.ga = sinon.spy();
+    MonorailTSMon.prototype.disableAfterNextFlush = sinon.spy();
+    c = new ClientLogger('rutabaga');
+  });
+
+  afterEach(() => {
+    sessionStorage.clear();
+  });
+
+  describe('constructor', () => {
+    it('assigns this.category', () => {
+      assert.equal(c.category, 'rutabaga');
+    });
+
+    it('gets started events from sessionStorage', () => {
+      const startedEvents = {
+        event1: {
+          time: 12345678,
+          labels: ['label1', 'label2'],
+        },
+        event2: {
+          time: 87654321,
+          labels: ['label2'],
+        },
+      };
+      sessionStorage[startedKey] = JSON.stringify(startedEvents);
+
+      c = new ClientLogger('rutabaga');
+      assert.deepEqual(startedEvents, c.startedEvents);
+    });
+  });
+
+  describe('records ts_mon metrics', () => {
+    let issueCreateMetric;
+    let issueUpdateMetric;
+    let autocompleteMetric;
+    let c;
+
+    beforeEach(() => {
+      window.ga = sinon.spy();
+      c = new ClientLogger('issues');
+      issueCreateMetric = c.tsMon._userTimingMetrics[0].metric;
+      issueCreateMetric.add = sinon.spy();
+
+      issueUpdateMetric = c.tsMon._userTimingMetrics[1].metric;
+      issueUpdateMetric.add = sinon.spy();
+
+      autocompleteMetric = c.tsMon._userTimingMetrics[2].metric;
+      autocompleteMetric.add = sinon.spy();
+    });
+
+    it('bogus', () => {
+      c.logStart('rutabaga');
+      c.logEnd('rutabaga');
+      sinon.assert.notCalled(issueCreateMetric.add);
+      sinon.assert.notCalled(issueUpdateMetric.add);
+      sinon.assert.notCalled(autocompleteMetric.add);
+    });
+
+    it('new-issue', () => {
+      c.logStart('new-issue', 'server-time');
+      c.logEnd('new-issue', 'server-time');
+      sinon.assert.notCalled(issueUpdateMetric.add);
+      sinon.assert.notCalled(autocompleteMetric.add);
+
+      sinon.assert.calledOnce(issueCreateMetric.add);
+      assert.isNumber(issueCreateMetric.add.getCall(0).args[0]);
+      assert.isString(issueCreateMetric.add.getCall(0).args[1].get('client_id'));
+      assert.equal(issueCreateMetric.add.getCall(0).args[1].get('host_name'),
+          'rutabaga-version');
+    });
+
+    it('issue-update', () => {
+      c.logStart('issue-update', 'computer-time');
+      c.logEnd('issue-update', 'computer-time');
+      sinon.assert.notCalled(issueCreateMetric.add);
+      sinon.assert.notCalled(autocompleteMetric.add);
+
+      sinon.assert.calledOnce(issueUpdateMetric.add);
+      assert.isNumber(issueUpdateMetric.add.getCall(0).args[0]);
+      assert.isString(issueUpdateMetric.add.getCall(0).args[1].get('client_id'));
+      assert.equal(issueUpdateMetric.add.getCall(0).args[1].get('host_name'),
+          'rutabaga-version');
+    });
+
+    it('populate-options', () => {
+      c.logStart('populate-options');
+      c.logEnd('populate-options');
+      sinon.assert.notCalled(issueCreateMetric.add);
+      sinon.assert.notCalled(issueUpdateMetric.add);
+      // Autocomplete is not called in issues category.
+      sinon.assert.notCalled(autocompleteMetric.add);
+
+      c = new ClientLogger('autocomplete');
+      autocompleteMetric = c.tsMon._userTimingMetrics[2].metric;
+      autocompleteMetric.add = sinon.spy();
+
+      c.logStart('populate-options', 'user-time');
+      c.logEnd('populate-options', 'user-time');
+      sinon.assert.notCalled(issueCreateMetric.add);
+      sinon.assert.notCalled(issueUpdateMetric.add);
+
+      sinon.assert.calledOnce(autocompleteMetric.add);
+      assert.isNumber(autocompleteMetric.add.getCall(0).args[0]);
+      assert.isString(autocompleteMetric.add.getCall(0).args[1].get('client_id'));
+      assert.equal(autocompleteMetric.add.getCall(0).args[1].get('host_name'),
+          'rutabaga-version');
+    });
+  });
+
+  describe('logStart', () => {
+    let c;
+    let clock;
+    const currentTime = 5000;
+
+    beforeEach(() => {
+      c = new ClientLogger('rutabaga');
+      clock = sinon.useFakeTimers(currentTime);
+    });
+
+    afterEach(() => {
+      clock.restore();
+      sessionStorage.clear();
+    });
+
+    it('creates a new startedEvent if none', () => {
+      c.logStart('event-name', 'event-label');
+
+      sinon.assert.calledOnce(ga);
+      sinon.assert.calledWith(ga, 'send', 'event', 'rutabaga',
+          'event-name-start', 'event-label');
+
+      const expectedStartedEvents = {
+        'event-name': {
+          time: currentTime,
+          labels: {
+            'event-label': currentTime,
+          },
+        },
+      };
+      assert.deepEqual(c.startedEvents, expectedStartedEvents);
+      assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+          expectedStartedEvents);
+    });
+
+    it('uses an existing startedEvent', () => {
+      c.startedEvents['event-name'] = {
+        time: 1234,
+        labels: {
+          'event-label': 1000,
+        },
+      };
+      c.logStart('event-name', 'event-label');
+
+      sinon.assert.calledOnce(ga);
+      sinon.assert.calledWith(ga, 'send', 'event', 'rutabaga',
+          'event-name-start', 'event-label');
+
+      // TODO(jeffcarp): Audit is this wanted behavior? Replacing event time
+      // but not label time?
+      const expectedStartedEvents = {
+        'event-name': {
+          time: 1234,
+          labels: {
+            'event-label': currentTime,
+          },
+        },
+      };
+      assert.deepEqual(c.startedEvents, expectedStartedEvents);
+      assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+          expectedStartedEvents);
+    });
+  });
+
+  describe('logPause', () => {
+    const startTime = 1234;
+    const currentTime = 5000;
+    let c;
+    let clock;
+
+    beforeEach(() => {
+      clock = sinon.useFakeTimers(currentTime);
+      c = new ClientLogger('rutabaga');
+      c.startedEvents['event-name'] = {
+        time: startTime,
+        labels: {
+          'event-label': startTime,
+        },
+      };
+    });
+
+    afterEach(() => {
+      clock.restore();
+      sessionStorage.clear();
+    });
+
+    it('throws if no label given', () => {
+      assert.throws(() => {
+        c.logPause('bogus');
+      }, 'event with no label');
+    });
+
+    it('exits early if no start event exists', () => {
+      const originalStartedEvents = Object.assign(c.startedEvents, {});
+      c.logPause('bogus', 'fogus');
+      assert.deepEqual(c.startedEvents, originalStartedEvents);
+    });
+
+    it('exits early if no label exists', () => {
+      const originalStartedEvents = Object.assign(c.startedEvents, {});
+      c.logPause('event-name', 'fogus');
+      assert.deepEqual(c.startedEvents, originalStartedEvents);
+    });
+
+    it('adds elapsed time to start event', () => {
+      c.logPause('event-name', 'event-label');
+
+      const expectedStartedEvents = {
+        'event-name': {
+          time: startTime,
+          labels: {
+            'event-label': startTime,
+          },
+          elapsed: {
+            'event-label': currentTime - startTime,
+          },
+        },
+      };
+      assert.deepEqual(c.startedEvents, expectedStartedEvents);
+      assert.deepEqual(
+          JSON.parse(sessionStorage['ClientLogger.rutabaga.started']),
+          expectedStartedEvents);
+    });
+  });
+
+  describe('logResume', () => {
+    let c;
+    let clock;
+    const startTimeEvent = 1234;
+    const startTimeLabel = 2345;
+    const labelElapsed = 4321;
+    const currentTime = 6000;
+
+    beforeEach(() => {
+      clock = sinon.useFakeTimers(currentTime);
+      c = new ClientLogger('rutabaga');
+      c.startedEvents['event-name'] = {
+        time: startTimeEvent,
+        labels: {
+          'event-label': startTimeLabel,
+        },
+        elapsed: {
+          'event-label': labelElapsed,
+        },
+      };
+    });
+
+    afterEach(() => {
+      clock.restore();
+      sessionStorage.clear();
+    });
+
+    it('throws if no label given', () => {
+      assert.throws(() => {
+        c.logResume('bogus');
+      }, 'no label');
+    });
+
+    it('exits early if no start event exists', () => {
+      const originalStartedEvents = Object.assign(c.startedEvents, {});
+      c.logResume('bogus', 'fogus');
+      assert.deepEqual(c.startedEvents, originalStartedEvents);
+    });
+
+    it('exits early if the label was never paused', () => {
+      c.startedEvents['event-name'] = {
+        time: startTimeEvent,
+        labels: {
+          'event-label': startTimeLabel,
+        },
+        elapsed: {},
+      };
+
+      const originalStartedEvents = Object.assign(c.startedEvents, {});
+      c.logResume('event-name', 'event-label');
+      assert.deepEqual(c.startedEvents, originalStartedEvents);
+    });
+
+    it('sets start event time to current time', () => {
+      c.logResume('event-name', 'event-label');
+
+      const expectedStartedEvents = {
+        'event-name': {
+          time: startTimeEvent,
+          labels: {
+            'event-label': currentTime,
+          },
+          elapsed: {
+            'event-label': labelElapsed,
+          },
+        },
+      };
+      assert.deepEqual(c.startedEvents, expectedStartedEvents);
+      assert.deepEqual(
+          JSON.parse(sessionStorage['ClientLogger.rutabaga.started']),
+          expectedStartedEvents);
+    });
+  });
+
+  describe('logEnd', () => {
+    let c;
+    let clock;
+    const startTimeEvent = 1234;
+    const startTimeLabel1 = 2345;
+    const startTimeLabel2 = 3456;
+    const currentTime = 10000;
+
+    beforeEach(() => {
+      c = new ClientLogger('rutabaga');
+      clock = sinon.useFakeTimers(currentTime);
+      c.tsMon.recordUserTiming = sinon.spy();
+      c.startedEvents = {
+        someEvent: {
+          time: startTimeEvent,
+          labels: {
+            label1: startTimeLabel1,
+            label2: startTimeLabel2,
+          },
+        },
+      };
+    });
+
+    afterEach(() => {
+      clock.restore();
+    });
+
+    it('returns early if no event was started', () => {
+      c.startedEvents = {someEvent: {}};
+      const originalStartedEvents = Object.assign(c.startedEvents, {});
+      c.logEnd('bogus');
+      sinon.assert.notCalled(window.ga);
+      assert.isNull(sessionStorage.getItem(startedKey));
+      assert.deepEqual(c.startedEvents, originalStartedEvents);
+    });
+
+    it('returns early if label was not started', () => {
+      c.startedEvents = {someEvent: {labels: {}}};
+      const originalStartedEvents = Object.assign(c.startedEvents, {});
+      c.logEnd('someEvent', 'bogus');
+      sinon.assert.notCalled(window.ga);
+      assert.isNull(sessionStorage.getItem(startedKey));
+      assert.deepEqual(c.startedEvents, originalStartedEvents);
+    });
+
+    it('does not log non-labeled events over threshold', () => {
+      c.startedEvents = {someEvent: {time: currentTime - 1000}};
+      c.logEnd('someEvent', null, 999);
+
+      sinon.assert.calledOnce(window.ga);
+      sinon.assert.calledWith(window.ga, 'send', 'event', 'rutabaga',
+          'someEvent-end', null, undefined);
+      sinon.assert.notCalled(c.tsMon.recordUserTiming);
+      assert.equal(sessionStorage.getItem(startedKey), '{}');
+    });
+
+    it('does not log labeled events over threshold', () => {
+      const elapsedLabel2 = 2000;
+      c.startedEvents.someEvent.elapsed = {
+        label1: currentTime - 1000,
+        label2: elapsedLabel2,
+      };
+      c.logEnd('someEvent', 'label1', 999);
+
+      sinon.assert.calledOnce(window.ga);
+      sinon.assert.calledWith(window.ga, 'send', 'event', 'rutabaga',
+          'someEvent-end', 'label1', undefined);
+      // TODO(jeffcarp): Feature: add GA event if over threshold.
+      sinon.assert.notCalled(c.tsMon.recordUserTiming);
+
+      const expectedStartedEvents = {
+        someEvent: {
+          time: startTimeEvent,
+          labels: {
+            label2: startTimeLabel2,
+          },
+          elapsed: {
+            label2: elapsedLabel2,
+          },
+        },
+      };
+      assert.deepEqual(c.startedEvents, expectedStartedEvents);
+      assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+          expectedStartedEvents);
+    });
+
+    it('calls ga() with timing and event info for all labels', () => {
+      const label1Elapsed = 1000;
+      const label2Elapsed = 2500;
+      c.startedEvents.someEvent.elapsed = {
+        label1: label1Elapsed,
+        label2: label2Elapsed,
+      };
+      c.logEnd('someEvent');
+
+      assert.deepEqual(ga.getCall(0).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: currentTime - startTimeEvent,
+          timingVar: 'someEvent',
+        }]);
+
+      assert.deepEqual(ga.getCall(1).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: (currentTime - startTimeLabel1) + label1Elapsed,
+          timingVar: 'someEvent',
+          timingLabel: 'label1',
+        }]);
+      assert.deepEqual(ga.getCall(2).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: (currentTime - startTimeLabel2) + label2Elapsed,
+          timingVar: 'someEvent',
+          timingLabel: 'label2',
+        }]);
+      assert.deepEqual(ga.getCall(3).args, [
+        'send', 'event', 'rutabaga', 'someEvent-end', undefined, undefined,
+      ]);
+      assert.deepEqual(c.tsMon.recordUserTiming.getCall(0).args, [
+        'rutabaga', 'someEvent', null, currentTime - startTimeEvent,
+      ]);
+      assert.deepEqual(c.tsMon.recordUserTiming.getCall(1).args, [
+        'rutabaga', 'someEvent', 'label1',
+        (currentTime - startTimeLabel1) + label1Elapsed,
+      ]);
+      assert.deepEqual(c.tsMon.recordUserTiming.getCall(2).args, [
+        'rutabaga', 'someEvent', 'label2',
+        (currentTime - startTimeLabel2) + label2Elapsed,
+      ]);
+
+      assert.deepEqual(c.startedEvents, {});
+      assert.equal(sessionStorage.getItem(startedKey), '{}');
+    });
+
+    it('calling with a label calls ga() only for that label', () => {
+      const label1Elapsed = 1000;
+      const label2Elapsed = 2500;
+      c.startedEvents.someEvent.elapsed = {
+        label1: label1Elapsed,
+        label2: label2Elapsed,
+      };
+      c.logEnd('someEvent', 'label2');
+
+      assert.deepEqual(ga.getCall(0).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: (currentTime - startTimeLabel2) + label2Elapsed,
+          timingVar: 'someEvent',
+          timingLabel: 'label2',
+        }]);
+      assert.deepEqual(window.ga.getCall(1).args, [
+        'send', 'event', 'rutabaga', 'someEvent-end', 'label2', undefined,
+      ]);
+      sinon.assert.calledOnce(c.tsMon.recordUserTiming);
+      sinon.assert.calledWith(c.tsMon.recordUserTiming, 'rutabaga',
+          'someEvent', 'label2', (currentTime - startTimeLabel2) + label2Elapsed);
+
+      const expectedStartedEvents = {
+        someEvent: {
+          time: startTimeEvent,
+          labels: {
+            label1: startTimeLabel1,
+          },
+          elapsed: {
+            label1: label1Elapsed,
+          },
+        },
+      };
+      assert.deepEqual(c.startedEvents, expectedStartedEvents);
+      assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+          expectedStartedEvents);
+    });
+
+    it('calling logStart, logPause, logResume, and logEnd works for labels',
+        () => {
+          let countedElapsedTime = 0;
+          c.logStart('someEvent', 'label1');
+          clock.tick(1000);
+          countedElapsedTime += 1000;
+          c.logPause('someEvent', 'label1');
+          clock.tick(1000);
+          c.logResume('someEvent', 'label1');
+          clock.tick(1000);
+          countedElapsedTime += 1000;
+          c.logEnd('someEvent', 'label1');
+
+          assert.deepEqual(ga.getCall(0).args, [
+            'send', 'event', 'rutabaga', 'someEvent-start', 'label1', undefined,
+          ]);
+          assert.deepEqual(ga.getCall(1).args, [
+            'send', 'timing', {
+              timingCategory: 'rutabaga',
+              timingValue: countedElapsedTime,
+              timingVar: 'someEvent',
+              timingLabel: 'label1',
+            }]);
+          assert.deepEqual(window.ga.getCall(2).args, [
+            'send', 'event', 'rutabaga', 'someEvent-end', 'label1', undefined,
+          ]);
+          sinon.assert.calledOnce(c.tsMon.recordUserTiming);
+          sinon.assert.calledWith(c.tsMon.recordUserTiming, 'rutabaga',
+              'someEvent', 'label1', countedElapsedTime);
+
+          const expectedStartedEvents = {
+            someEvent: {
+              time: startTimeEvent,
+              labels: {
+                label2: startTimeLabel2,
+              },
+              elapsed: {},
+            },
+          };
+          assert.deepEqual(c.startedEvents, expectedStartedEvents);
+          assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+              expectedStartedEvents);
+        });
+
+    it('logs some events when others are above threshold', () => {
+      c.startedEvents = {
+        someEvent: {
+          time: 9500,
+          labels: {
+            overThresholdWithoutElapsed: 8000,
+            overThresholdWithElapsed: 9500,
+            underThresholdWithoutElapsed: 9750,
+            underThresholdWithElapsed: 9650,
+            exactlyOnThresholdWithoutElapsed: 9001,
+            exactlyOnThresholdWithElapsed: 9002,
+          },
+          elapsed: {
+            overThresholdWithElapsed: 1000,
+            underThresholdWithElapsed: 100,
+            exactlyOnThresholdWithElapsed: 1,
+          },
+        },
+      };
+      c.logEnd('someEvent', null, 999);
+
+      // Verify ga() calls.
+      assert.equal(window.ga.getCalls().length, 6);
+      assert.deepEqual(ga.getCall(0).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: 500,
+          timingVar: 'someEvent',
+        }]);
+      assert.deepEqual(ga.getCall(1).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: 250,
+          timingVar: 'someEvent',
+          timingLabel: 'underThresholdWithoutElapsed',
+        }]);
+      assert.deepEqual(ga.getCall(2).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: 450,
+          timingVar: 'someEvent',
+          timingLabel: 'underThresholdWithElapsed',
+        }]);
+      assert.deepEqual(ga.getCall(3).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: 999,
+          timingVar: 'someEvent',
+          timingLabel: 'exactlyOnThresholdWithoutElapsed',
+        }]);
+      assert.deepEqual(ga.getCall(4).args, [
+        'send', 'timing', {
+          timingCategory: 'rutabaga',
+          timingValue: 999,
+          timingVar: 'someEvent',
+          timingLabel: 'exactlyOnThresholdWithElapsed',
+        }]);
+      assert.deepEqual(ga.getCall(5).args, [
+        'send', 'event', 'rutabaga', 'someEvent-end', null, undefined,
+      ]);
+
+      // Verify ts_mon.recordUserTiming() calls.
+      assert.equal(c.tsMon.recordUserTiming.getCalls().length, 5);
+      assert.deepEqual(c.tsMon.recordUserTiming.getCall(0).args, [
+        'rutabaga', 'someEvent', null, 500,
+      ]);
+      assert.deepEqual(c.tsMon.recordUserTiming.getCall(1).args, [
+        'rutabaga', 'someEvent', 'underThresholdWithoutElapsed', 250,
+      ]);
+      assert.deepEqual(c.tsMon.recordUserTiming.getCall(2).args, [
+        'rutabaga', 'someEvent', 'underThresholdWithElapsed', 450,
+      ]);
+      assert.deepEqual(c.tsMon.recordUserTiming.getCall(3).args, [
+        'rutabaga', 'someEvent', 'exactlyOnThresholdWithoutElapsed', 999,
+      ]);
+      assert.deepEqual(c.tsMon.recordUserTiming.getCall(4).args, [
+        'rutabaga', 'someEvent', 'exactlyOnThresholdWithElapsed', 999,
+      ]);
+      assert.deepEqual(c.startedEvents, {});
+      assert.deepEqual(JSON.parse(sessionStorage[startedKey]), {});
+    });
+  });
+});
diff --git a/static_src/monitoring/monorail-ts-mon.js b/static_src/monitoring/monorail-ts-mon.js
new file mode 100644
index 0000000..2d90e3e
--- /dev/null
+++ b/static_src/monitoring/monorail-ts-mon.js
@@ -0,0 +1,266 @@
+/* Copyright 2018 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 {TSMonClient} from '@chopsui/tsmon-client';
+
+export const tsMonClient = new TSMonClient();
+import AutoRefreshPrpcClient from 'prpc.js';
+
+const TS_MON_JS_PATH = '/_/jstsmon.do';
+const TS_MON_CLIENT_GLOBAL_NAME = '__tsMonClient';
+const PAGE_LOAD_MAX_THRESHOLD = 60000;
+export const PAGE_TYPES = Object.freeze({
+  ISSUE_DETAIL_SPA: 'issue_detail_spa',
+  ISSUE_ENTRY: 'issue_entry',
+  ISSUE_LIST_SPA: 'issue_list_spa',
+});
+
+export default class MonorailTSMon extends TSMonClient {
+  /** @override */
+  constructor() {
+    super(TS_MON_JS_PATH);
+    this.clientId = MonorailTSMon.generateClientId();
+    this.disableAfterNextFlush();
+    // Create an instance of pRPC client for refreshing XSRF tokens.
+    this.prpcClient = new AutoRefreshPrpcClient(
+        window.CS_env.token, window.CS_env.tokenExpiresSec);
+
+    // TODO(jeffcarp, 4415): Deduplicate metric defs.
+    const standardFields = new Map([
+      ['client_id', TSMonClient.stringField('client_id')],
+      ['host_name', TSMonClient.stringField('host_name')],
+      ['document_visible', TSMonClient.boolField('document_visible')],
+    ]);
+    this._userTimingMetrics = [
+      {
+        category: 'issues',
+        eventName: 'new-issue',
+        eventLabel: 'server-time',
+        metric: this.cumulativeDistribution(
+            'monorail/frontend/issue_create_latency',
+            'Latency between issue entry form submit and issue detail page load.',
+            null, standardFields,
+        ),
+      },
+      {
+        category: 'issues',
+        eventName: 'issue-update',
+        eventLabel: 'computer-time',
+        metric: this.cumulativeDistribution(
+            'monorail/frontend/issue_update_latency',
+            'Latency between issue update form submit and issue detail page load.',
+            null, standardFields,
+        ),
+      },
+      {
+        category: 'autocomplete',
+        eventName: 'populate-options',
+        eventLabel: 'user-time',
+        metric: this.cumulativeDistribution(
+            'monorail/frontend/autocomplete_populate_latency',
+            'Latency between page load and autocomplete options loading.',
+            null, standardFields,
+        ),
+      },
+    ];
+
+    this.dateRangeMetric = this.counter(
+        'monorail/frontend/charts/switch_date_range',
+        'Number of times user changes date range.',
+        null, (new Map([
+          ['client_id', TSMonClient.stringField('client_id')],
+          ['host_name', TSMonClient.stringField('host_name')],
+          ['document_visible', TSMonClient.boolField('document_visible')],
+          ['date_range', TSMonClient.intField('date_range')],
+        ])),
+    );
+
+    this.issueCommentsLoadMetric = this.cumulativeDistribution(
+        'monorail/frontend/issue_comments_load_latency',
+        'Time from navigation or click to issue comments loaded.',
+        null, (new Map([
+          ['client_id', TSMonClient.stringField('client_id')],
+          ['host_name', TSMonClient.stringField('host_name')],
+          ['template_name', TSMonClient.stringField('template_name')],
+          ['document_visible', TSMonClient.boolField('document_visible')],
+          ['full_app_load', TSMonClient.boolField('full_app_load')],
+        ])),
+    );
+
+    this.issueListLoadMetric = this.cumulativeDistribution(
+        'monorail/frontend/issue_list_load_latency',
+        'Time from navigation or click to search issues list loaded.',
+        null, (new Map([
+          ['client_id', TSMonClient.stringField('client_id')],
+          ['host_name', TSMonClient.stringField('host_name')],
+          ['template_name', TSMonClient.stringField('template_name')],
+          ['document_visible', TSMonClient.boolField('document_visible')],
+          ['full_app_load', TSMonClient.boolField('full_app_load')],
+        ])),
+    );
+
+
+    this.pageLoadMetric = this.cumulativeDistribution(
+        'frontend/dom_content_loaded',
+        'domContentLoaded performance timing.',
+        null, (new Map([
+          ['client_id', TSMonClient.stringField('client_id')],
+          ['host_name', TSMonClient.stringField('host_name')],
+          ['template_name', TSMonClient.stringField('template_name')],
+          ['document_visible', TSMonClient.boolField('document_visible')],
+        ])),
+    );
+  }
+
+  fetchImpl(rawMetricValues) {
+    return this.prpcClient.ensureTokenIsValid().then(() => {
+      return fetch(this._reportPath, {
+        method: 'POST',
+        credentials: 'same-origin',
+        body: JSON.stringify({
+          metrics: rawMetricValues,
+          token: this.prpcClient.token,
+        }),
+      });
+    });
+  }
+
+  recordUserTiming(category, eventName, eventLabel, elapsed) {
+    const metricFields = new Map([
+      ['client_id', this.clientId],
+      ['host_name', window.CS_env.app_version],
+      ['document_visible', MonorailTSMon.isPageVisible()],
+    ]);
+    for (const metric of this._userTimingMetrics) {
+      if (category === metric.category &&
+          eventName === metric.eventName &&
+          eventLabel === metric.eventLabel) {
+        metric.metric.add(elapsed, metricFields);
+      }
+    }
+  }
+
+  recordDateRangeChange(dateRange) {
+    const metricFields = new Map([
+      ['client_id', this.clientId],
+      ['host_name', window.CS_env.app_version],
+      ['document_visible', MonorailTSMon.isPageVisible()],
+      ['date_range', dateRange],
+    ]);
+    this.dateRangeMetric.add(1, metricFields);
+  }
+
+  // Make sure this function runs after the page is loaded.
+  recordPageLoadTiming(pageType, maxThresholdMs=null) {
+    if (!pageType) return;
+    // See timing definitions here:
+    // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming
+    const t = window.performance.timing;
+    const domContentLoadedMs = t.domContentLoadedEventEnd - t.navigationStart;
+
+    const measurePageTypes = new Set([
+      PAGE_TYPES.ISSUE_DETAIL_SPA,
+      PAGE_TYPES.ISSUE_ENTRY,
+    ]);
+
+    if (measurePageTypes.has(pageType)) {
+      if (maxThresholdMs !== null && domContentLoadedMs > maxThresholdMs) {
+        return;
+      }
+      const metricFields = new Map([
+        ['client_id', this.clientId],
+        ['host_name', window.CS_env.app_version],
+        ['template_name', pageType],
+        ['document_visible', MonorailTSMon.isPageVisible()],
+      ]);
+      this.pageLoadMetric.add(domContentLoadedMs, metricFields);
+    }
+  }
+
+  recordIssueCommentsLoadTiming(value, fullAppLoad) {
+    const metricFields = new Map([
+      ['client_id', this.clientId],
+      ['host_name', window.CS_env.app_version],
+      ['template_name', PAGE_TYPES.ISSUE_DETAIL_SPA],
+      ['document_visible', MonorailTSMon.isPageVisible()],
+      ['full_app_load', fullAppLoad],
+    ]);
+    this.issueCommentsLoadMetric.add(value, metricFields);
+  }
+
+  recordIssueEntryTiming(maxThresholdMs=PAGE_LOAD_MAX_THRESHOLD) {
+    this.recordPageLoadTiming(PAGE_TYPES.ISSUE_ENTRY, maxThresholdMs);
+  }
+
+  recordIssueDetailSpaTiming(maxThresholdMs=PAGE_LOAD_MAX_THRESHOLD) {
+    this.recordPageLoadTiming(PAGE_TYPES.ISSUE_DETAIL_SPA, maxThresholdMs);
+  }
+
+
+  /**
+   * Adds a value to the 'issue_list_load_latency' metric.
+   * @param {timestamp} value duration of the load time.
+   * @param {Boolean} fullAppLoad true if this metric was collected from
+   *     a full app load (cold) rather than from navigation within the
+   *     app (hot).
+   */
+  recordIssueListLoadTiming(value, fullAppLoad) {
+    const metricFields = new Map([
+      ['client_id', this.clientId],
+      ['host_name', window.CS_env.app_version],
+      ['template_name', PAGE_TYPES.ISSUE_LIST_SPA],
+      ['document_visible', MonorailTSMon.isPageVisible()],
+      ['full_app_load', fullAppLoad],
+    ]);
+    this.issueListLoadMetric.add(value, metricFields);
+  }
+
+  // Uses the window object to ensure that only one ts_mon JS client
+  // exists on the page at any given time. Returns the object on window,
+  // instantiating it if it doesn't exist yet.
+  static getGlobalClient() {
+    const key = TS_MON_CLIENT_GLOBAL_NAME;
+    if (!window.hasOwnProperty(key)) {
+      window[key] = new MonorailTSMon();
+    }
+    return window[key];
+  }
+
+  static generateClientId() {
+    /**
+     * Returns a random string used as the client_id field in ts_mon metrics.
+     *
+     * Rationale:
+     * If we assume Monorail has sustained 40 QPS, assume every request
+     * generates a new ClientLogger (likely an overestimation), and we want
+     * the likelihood of a client ID collision to be 0.01% for all IDs
+     * generated in any given year (in other words, 1 collision every 10K
+     * years), we need to generate a random string with at least 2^30 different
+     * possible values (i.e. 30 bits of entropy, see log2(d) in Wolfram link
+     * below). Using an unsigned integer gives us 32 bits of entropy, more than
+     * enough.
+     *
+     * Returns:
+     *   A string (the base-32 representation of a random 32-bit integer).
+
+     * References:
+     * - https://en.wikipedia.org/wiki/Birthday_problem
+     * - https://www.wolframalpha.com/input/?i=d%3D40+*+60+*+60+*+24+*+365,+p%3D0.0001,+n+%3D+sqrt(2d+*+ln(1%2F(1-p))),+d,+log2(d),+n
+     * - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString
+     */
+    const randomvalues = new Uint32Array(1);
+    window.crypto.getRandomValues(randomvalues);
+    return randomvalues[0].toString(32);
+  }
+
+  // Returns a Boolean, true if document is visible.
+  static isPageVisible(path) {
+    return document.visibilityState === 'visible';
+  }
+}
+
+// For integration with EZT pages, which don't use ES modules.
+window.getTSMonClient = MonorailTSMon.getGlobalClient;
diff --git a/static_src/monitoring/monorail-ts-mon.test.js b/static_src/monitoring/monorail-ts-mon.test.js
new file mode 100644
index 0000000..fdf3e81
--- /dev/null
+++ b/static_src/monitoring/monorail-ts-mon.test.js
@@ -0,0 +1,152 @@
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import MonorailTSMon, {PAGE_TYPES} from './monorail-ts-mon.js';
+
+describe('MonorailTSMon', () => {
+  let mts;
+
+  beforeEach(() => {
+    window.CS_env = {
+      token: 'rutabaga-token',
+      tokenExpiresSec: 1234,
+      app_version: 'rutabaga-version',
+    };
+    window.chops = {rpc: {PrpcClient: sinon.spy()}};
+    MonorailTSMon.prototype.disableAfterNextFlush = sinon.spy();
+    mts = new MonorailTSMon();
+  });
+
+  afterEach(() => {
+    delete window.CS_env;
+  });
+
+  describe('constructor', () => {
+    it('initializes a prpcClient', () => {
+      assert.equal(mts.prpcClient.constructor.name, 'AutoRefreshPrpcClient');
+    });
+
+    it('sets a client ID', () => {
+      assert.isNotNull(mts.clientId);
+    });
+
+    it('disables sending after next flush', () => {
+      sinon.assert.calledOnce(mts.disableAfterNextFlush);
+    });
+  });
+
+  it('generateClientId', () => {
+    const clientID = MonorailTSMon.generateClientId();
+    assert.isNotNumber(clientID);
+    const clientIDNum = parseInt(clientID, 32);
+    assert.isNumber(clientIDNum);
+    assert.isAtLeast(clientIDNum, 0);
+    assert.isAtMost(clientIDNum, Math.pow(2, 32));
+  });
+
+  describe('recordUserTiming', () => {
+    it('records a timing metric only if matches', () => {
+      const metric = {add: sinon.spy()};
+      mts._userTimingMetrics = [{
+        category: 'rutabaga',
+        eventName: 'rutabaga-name',
+        eventLabel: 'rutabaga-label',
+        metric: metric,
+      }];
+
+      mts.recordUserTiming('kohlrabi', 'rutabaga-name', 'rutabaga-label', 1);
+      sinon.assert.notCalled(metric.add);
+      metric.add.resetHistory();
+
+      mts.recordUserTiming('rutabaga', 'is-a-tuber', 'rutabaga-label', 1);
+      sinon.assert.notCalled(metric.add);
+      metric.add.resetHistory();
+
+      mts.recordUserTiming('rutabaga', 'rutabaga-name', 'went bad', 1);
+      sinon.assert.notCalled(metric.add);
+      metric.add.resetHistory();
+
+      mts.recordUserTiming('rutabaga', 'rutabaga-name', 'rutabaga-label', 1);
+      sinon.assert.calledOnce(metric.add);
+      assert.equal(metric.add.args[0][0], 1);
+      const argsKeys = Array.from(metric.add.args[0][1].keys());
+      assert.deepEqual(argsKeys, ['client_id', 'host_name', 'document_visible']);
+    });
+  });
+
+  describe('recordPageLoadTiming', () => {
+    beforeEach(() => {
+      mts.pageLoadMetric = {add: sinon.spy()};
+      sinon.stub(MonorailTSMon, 'isPageVisible').callsFake(() => (true));
+    });
+
+    afterEach(() => {
+      MonorailTSMon.isPageVisible.restore();
+    });
+
+    it('records page load on issue entry page', () => {
+      mts.recordIssueEntryTiming();
+      sinon.assert.calledOnce(mts.pageLoadMetric.add);
+      assert.isNumber(mts.pageLoadMetric.add.getCall(0).args[0]);
+      assert.isString(mts.pageLoadMetric.add.getCall(0).args[1].get(
+          'client_id'));
+      assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+          'host_name'), 'rutabaga-version');
+      assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+          'template_name'), 'issue_entry');
+      assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+          'document_visible'), true);
+    });
+
+    it('does not record page load timing on other pages', () => {
+      mts.recordPageLoadTiming();
+      sinon.assert.notCalled(mts.pageLoadMetric.add);
+    });
+
+    it('does not record page load timing if over max threshold', () => {
+      window.performance = {
+        timing: {
+          navigationStart: 1000,
+          domContentLoadedEventEnd: 2001,
+        },
+      };
+      mts.recordIssueEntryTiming(1000);
+      sinon.assert.notCalled(mts.pageLoadMetric.add);
+    });
+
+    it('records page load on issue entry page if under threshold', () => {
+      MonorailTSMon.isPageVisible.restore();
+      sinon.stub(MonorailTSMon, 'isPageVisible').callsFake(() => (false));
+      window.performance = {
+        timing: {
+          navigationStart: 1000,
+          domContentLoadedEventEnd: 1999,
+        },
+      };
+      mts.recordIssueEntryTiming(1000);
+      sinon.assert.calledOnce(mts.pageLoadMetric.add);
+      assert.isNumber(mts.pageLoadMetric.add.getCall(0).args[0]);
+      assert.equal(mts.pageLoadMetric.add.getCall(0).args[0], 999);
+      assert.isString(mts.pageLoadMetric.add.getCall(0).args[1].get(
+          'client_id'));
+      assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+          'host_name'), 'rutabaga-version');
+      assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+          'template_name'), 'issue_entry');
+      assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+          'document_visible'), false);
+    });
+  });
+
+  describe('getGlobalClient', () => {
+    it('only creates one global client', () => {
+      delete window.__tsMonClient;
+      const client1 = MonorailTSMon.getGlobalClient();
+      assert.equal(client1, window.__tsMonClient);
+
+      const client2 = MonorailTSMon.getGlobalClient();
+      assert.equal(client2, window.__tsMonClient);
+      assert.equal(client2, client1);
+    });
+  });
+});
diff --git a/static_src/monitoring/track-copy.js b/static_src/monitoring/track-copy.js
new file mode 100644
index 0000000..7123965
--- /dev/null
+++ b/static_src/monitoring/track-copy.js
@@ -0,0 +1,28 @@
+/* Copyright 2018 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.
+ */
+
+// This counts copy and paste events.
+
+function labelForElement(el) {
+  let label = el.localName;
+  if (el.id) {
+    label = label + '#' + el.id;
+  }
+  return label;
+}
+
+window.addEventListener('copy', function(evt) {
+  const label = labelForElement(evt.srcElement);
+  const len = window.getSelection().toString().length;
+  ga('send', 'event', window.location.pathname, 'copy', label, len);
+});
+
+window.addEventListener('paste', function(evt) {
+  const label = labelForElement(evt.srcElement);
+  const text = evt.clipboardData.getData('text/plain');
+  const len = text ? text.length : 0;
+  ga('send', 'event', window.location.pathname, 'paste', label, len);
+});
diff --git a/static_src/prpc-client-instance.js b/static_src/prpc-client-instance.js
new file mode 100644
index 0000000..103b9c6
--- /dev/null
+++ b/static_src/prpc-client-instance.js
@@ -0,0 +1,16 @@
+// 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.
+
+/**
+ * @fileoverview Creates a globally shared instance of AutoRefreshPrpcClient
+ * to be used across the frontend, to share state and allow easy test stubbing.
+ */
+
+import AutoRefreshPrpcClient from 'prpc.js';
+
+// TODO(crbug.com/monorail/5049): Remove usage of window.CS_env here.
+export const prpcClient = new AutoRefreshPrpcClient(
+  window.CS_env ? window.CS_env.token : '',
+  window.CS_env ? window.CS_env.tokenExpiresSec : 0,
+);
diff --git a/static_src/prpc.js b/static_src/prpc.js
new file mode 100644
index 0000000..5b36c7a
--- /dev/null
+++ b/static_src/prpc.js
@@ -0,0 +1,67 @@
+// 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 '@chopsui/prpc-client/prpc-client.js';
+
+/**
+ * @fileoverview pRPC-related helper functions.
+ */
+export default class AutoRefreshPrpcClient {
+  constructor(token, tokenExpiresSec) {
+    this.token = token;
+    this.tokenExpiresSec = tokenExpiresSec;
+    this.prpcClient = new window.chops.rpc.PrpcClient({
+      insecure: Boolean(location.hostname === 'localhost'),
+      fetchImpl: (url, options) => {
+        options.credentials = 'same-origin';
+        return fetch(url, options);
+      },
+    });
+  }
+
+  /**
+   * Refresh the XSRF token if necessary.
+   * TODO(ehmaldonado): Figure out how to handle failures to refresh tokens.
+   * Maybe fire an event that a root page handler could use to show a message.
+   * @async
+   */
+  async ensureTokenIsValid() {
+    if (AutoRefreshPrpcClient.isTokenExpired(this.tokenExpiresSec)) {
+      const headers = {'X-Xsrf-Token': this.token};
+      const message = {
+        token: this.token,
+        tokenPath: 'xhr',
+      };
+      const freshToken = await this.prpcClient.call(
+          'monorail.Sitewide', 'RefreshToken', message, headers);
+      this.token = freshToken.token;
+      this.tokenExpiresSec = freshToken.tokenExpiresSec;
+    }
+  }
+
+  /**
+   * Sends a pRPC request. Adds this.token to the request message after making
+   * sure it is fresh.
+   * @param {string} service Full service name, including package name.
+   * @param {string} method Service method name.
+   * @param {Object} message The protobuf message to send.
+   * @return {Object} The pRPC API response.
+   */
+  call(service, method, message) {
+    return this.ensureTokenIsValid().then(() => {
+      const headers = {'X-Xsrf-Token': this.token};
+      return this.prpcClient.call(service, method, message, headers);
+    });
+  }
+
+  /**
+   * Check if the token is expired.
+   * @param {number} tokenExpiresSec: the expiration time of the token.
+   * @return {boolean} Whether the token is expired.
+   */
+  static isTokenExpired(tokenExpiresSec) {
+    const tokenExpiresDate = new Date(tokenExpiresSec * 1000);
+    return tokenExpiresDate < new Date();
+  }
+}
diff --git a/static_src/react/IssueWizard.css b/static_src/react/IssueWizard.css
new file mode 100644
index 0000000..46b1ff2
--- /dev/null
+++ b/static_src/react/IssueWizard.css
@@ -0,0 +1,18 @@
+.container {
+  margin-left: 50px;
+  max-width: 70vw;
+  width: 100%;
+  font-family: 'Poppins', serif;
+}
+
+.yellowBox {
+  height: 10vh;
+  border-style: solid;
+  border-color: #ea8600;
+  border-radius: 8px;
+  background: #fef7e0;
+}
+
+.poppins {
+  font-family: 'Poppins', serif;
+}
\ No newline at end of file
diff --git a/static_src/react/IssueWizard.test.tsx b/static_src/react/IssueWizard.test.tsx
new file mode 100644
index 0000000..07016ce
--- /dev/null
+++ b/static_src/react/IssueWizard.test.tsx
@@ -0,0 +1,19 @@
+// Copyright 2021 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 React from 'react';
+import {assert} from 'chai';
+import {render} from '@testing-library/react';
+
+import {IssueWizard} from './IssueWizard.tsx';
+
+describe('IssueWizard', () => {
+  it('renders', async () => {
+    render(<IssueWizard />);
+
+    const stepper = document.getElementById("mobile-stepper")
+
+    assert.isNotNull(stepper);
+  });
+});
diff --git a/static_src/react/IssueWizard.tsx b/static_src/react/IssueWizard.tsx
new file mode 100644
index 0000000..de5e8fb
--- /dev/null
+++ b/static_src/react/IssueWizard.tsx
@@ -0,0 +1,67 @@
+// 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 {ReactElement} from 'react';
+import * as React from 'react'
+import ReactDOM from 'react-dom';
+import styles from './IssueWizard.css';
+import DotMobileStepper from './issue-wizard/DotMobileStepper.tsx';
+import LandingStep from './issue-wizard/LandingStep.tsx';
+import DetailsStep from './issue-wizard/DetailsStep.tsx'
+
+/**
+ * Base component for the issue filing wizard, wrapper for other components.
+ * @return Issue wizard JSX.
+ */
+export function IssueWizard(): ReactElement {
+  const [checkExisting, setCheckExisting] = React.useState(false);
+  const [userType, setUserType] = React.useState('End User');
+  const [activeStep, setActiveStep] = React.useState(0);
+  const [category, setCategory] = React.useState('');
+  const [textValues, setTextValues] = React.useState(
+    {
+      oneLineSummary: '',
+      stepsToReproduce: '',
+      describeProblem: '',
+      additionalComments: ''
+    });
+
+  let nextEnabled;
+  let page;
+  if (activeStep === 0){
+    page = <LandingStep
+        checkExisting={checkExisting}
+        setCheckExisting={setCheckExisting}
+        userType={userType}
+        setUserType={setUserType}
+        category={category}
+        setCategory={setCategory}
+        />;
+    nextEnabled = checkExisting && userType && (category != '');
+  } else if (activeStep === 1){
+    page = <DetailsStep textValues={textValues} setTextValues={setTextValues} category={category}/>;
+    nextEnabled = (textValues.oneLineSummary.trim() !== '') &&
+                  (textValues.stepsToReproduce.trim() !== '') &&
+                  (textValues.describeProblem.trim() !== '');
+  }
+
+  return (
+    <>
+      <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Poppins"></link>
+      <div className={styles.container}>
+        {page}
+        <DotMobileStepper nextEnabled={nextEnabled} activeStep={activeStep} setActiveStep={setActiveStep}/>
+      </div>
+    </>
+  );
+}
+
+/**
+ * Renders the issue filing wizard page.
+ * @param mount HTMLElement that the React component should be
+ *   added to.
+ */
+export function renderWizard(mount: HTMLElement): void {
+  ReactDOM.render(<IssueWizard />, mount);
+}
diff --git a/static_src/react/ReactAutocomplete.test.tsx b/static_src/react/ReactAutocomplete.test.tsx
new file mode 100644
index 0000000..a1e7c62
--- /dev/null
+++ b/static_src/react/ReactAutocomplete.test.tsx
@@ -0,0 +1,311 @@
+// Copyright 2021 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 React from 'react';
+import sinon from 'sinon';
+import {fireEvent, render} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import {ReactAutocomplete, MAX_AUTOCOMPLETE_OPTIONS}
+  from './ReactAutocomplete.tsx';
+
+/**
+ * Cleans autocomplete dropdown from the DOM for the next test.
+ * @param input The autocomplete element to remove the dropdown for.
+ */
+ const cleanAutocomplete = (input: ReactAutocomplete) => {
+  fireEvent.change(input, {target: {value: ''}});
+  fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+};
+
+xdescribe('ReactAutocomplete', () => {
+  it('renders', async () => {
+    const {container} = render(<ReactAutocomplete label="cool" options={[]} />);
+
+    assert.isNotNull(container.querySelector('input'));
+  });
+
+  it('placeholder renders', async () => {
+    const {container} = render(<ReactAutocomplete
+      placeholder="penguins"
+      options={['']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.placeholder, 'penguins');
+  });
+
+  it('filterOptions empty input value', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['option 1 label']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.value, '');
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+    assert.strictEqual(input?.value, '');
+  });
+
+  it('filterOptions truncates values', async () => {
+    const options = [];
+
+    // a0@test.com, a1@test.com, a2@test.com, ...
+    for (let i = 0; i <= MAX_AUTOCOMPLETE_OPTIONS; i++) {
+      options.push(`a${i}@test.com`);
+    }
+
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={options}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'a');
+
+    const results = document.querySelectorAll('.autocomplete-option');
+
+    assert.equal(results.length, MAX_AUTOCOMPLETE_OPTIONS);
+
+    // Clean up autocomplete dropdown from the DOM for the next test.
+    cleanAutocomplete(input);
+  });
+
+  it('filterOptions label matching', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['option 1 label']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.value, '');
+
+    userEvent.type(input, 'lab');
+    assert.strictEqual(input?.value, 'lab');
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+
+    assert.strictEqual(input?.value, 'option 1 label');
+  });
+
+  it('filterOptions description matching', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      getOptionDescription={() => 'penguin apples'}
+      options={['lol']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.value, '');
+
+    userEvent.type(input, 'app');
+    assert.strictEqual(input?.value, 'app');
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+    assert.strictEqual(input?.value, 'lol');
+  });
+
+  it('filterOptions no match', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={[]}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.value, '');
+
+    userEvent.type(input, 'foobar');
+    assert.strictEqual(input?.value, 'foobar');
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+    assert.strictEqual(input?.value, 'foobar');
+  });
+
+  it('onChange callback is called', async () => {
+    const onChangeStub = sinon.stub();
+
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={[]}
+      onChange={onChangeStub}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    sinon.assert.notCalled(onChangeStub);
+
+    userEvent.type(input, 'foobar');
+    sinon.assert.notCalled(onChangeStub);
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+    sinon.assert.calledOnce(onChangeStub);
+
+    assert.equal(onChangeStub.getCall(0).args[1], 'foobar');
+  });
+
+  it('onChange excludes fixed values', async () => {
+    const onChangeStub = sinon.stub();
+
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute owl']}
+      multiple={true}
+      fixedValues={['immortal penguin']}
+      onChange={onChangeStub}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+
+    sinon.assert.calledWith(onChangeStub, sinon.match.any, []);
+  });
+
+  it('pressing space creates new chips', async () => {
+    const onChangeStub = sinon.stub();
+
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute owl']}
+      multiple={true}
+      onChange={onChangeStub}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    sinon.assert.notCalled(onChangeStub);
+
+    userEvent.type(input, 'foobar');
+    sinon.assert.notCalled(onChangeStub);
+
+    fireEvent.keyDown(input, {key: ' ', code: 'Space'});
+    sinon.assert.calledOnce(onChangeStub);
+
+    assert.deepEqual(onChangeStub.getCall(0).args[1], ['foobar']);
+  });
+
+  it('_renderOption shows user input', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute@owl.com']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'ow');
+
+    const options = document.querySelectorAll('.autocomplete-option');
+
+    // Options: cute@owl.com
+    assert.deepEqual(options.length, 1);
+    assert.equal(options[0].textContent, 'cute@owl.com');
+
+    cleanAutocomplete(input);
+  });
+
+  it('_renderOption hides duplicate user input', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute@owl.com']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'cute@owl.com');
+
+    const options = document.querySelectorAll('.autocomplete-option');
+
+    // Options: cute@owl.com
+    assert.equal(options.length, 1);
+
+    assert.equal(options[0].textContent, 'cute@owl.com');
+
+    cleanAutocomplete(input);
+  });
+
+  it('_renderOption highlights matching text', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute@owl.com']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'ow');
+
+    const option = document.querySelector('.autocomplete-option');
+    const match = option?.querySelector('strong');
+
+    assert.isNotNull(match);
+    assert.equal(match?.innerText, 'ow');
+
+    // Description is not rendered.
+    assert.equal(option?.querySelectorAll('span').length, 1);
+    assert.equal(option?.querySelectorAll('strong').length, 1);
+
+    cleanAutocomplete(input);
+  });
+
+  it('_renderOption highlights matching description', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      getOptionDescription={() => 'penguin of-doom'}
+      options={['cute owl']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'do');
+
+    const option = document.querySelector('.autocomplete-option');
+    const match = option?.querySelector('strong');
+
+    assert.isNotNull(match);
+    assert.equal(match?.innerText, 'do');
+
+    assert.equal(option?.querySelectorAll('span').length, 2);
+    assert.equal(option?.querySelectorAll('strong').length, 1);
+
+    cleanAutocomplete(input);
+  });
+
+  it('_renderTags disables fixedValues', async () => {
+    // TODO(crbug.com/monorail/9393): Add this test once we have a way to stub
+    // out dependent components.
+  });
+});
diff --git a/static_src/react/ReactAutocomplete.tsx b/static_src/react/ReactAutocomplete.tsx
new file mode 100644
index 0000000..27fdc32
--- /dev/null
+++ b/static_src/react/ReactAutocomplete.tsx
@@ -0,0 +1,276 @@
+// Copyright 2021 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 React from 'react';
+
+import {FilterOptionsState} from '@material-ui/core';
+import Autocomplete, {
+  AutocompleteChangeDetails, AutocompleteChangeReason,
+  AutocompleteRenderGetTagProps, AutocompleteRenderInputParams,
+  AutocompleteRenderOptionState,
+} from '@material-ui/core/Autocomplete';
+import Chip, {ChipProps} from '@material-ui/core/Chip';
+import TextField from '@material-ui/core/TextField';
+import {Value} from '@material-ui/core/useAutocomplete';
+
+export const MAX_AUTOCOMPLETE_OPTIONS = 100;
+
+interface AutocompleteProps<T> {
+  label: string;
+  options: T[];
+  value?: Value<T, boolean, false, true>;
+  fixedValues?: T[];
+  inputType?: React.InputHTMLAttributes<unknown>['type'];
+  multiple?: boolean;
+  placeholder?: string;
+  onChange?: (
+    event: React.SyntheticEvent,
+    value: Value<T, boolean, false, true>,
+    reason: AutocompleteChangeReason,
+    details?: AutocompleteChangeDetails<T>
+  ) => void;
+  getOptionDescription?: (option: T) => string;
+  getOptionLabel?: (option: T) => string;
+}
+
+/**
+ * A wrapper around Material UI Autocomplete that customizes and extends it for
+ * Monorail's theme and options. Adds support for:
+ * - Fixed values that render as disabled chips.
+ * - Option descriptions that render alongside the option labels.
+ * - Matching on word boundaries in both the labels and descriptions.
+ * - Highlighting of the matching substrings.
+ * @return Autocomplete instance with Monorail-specific properties set.
+ */
+export function ReactAutocomplete<T>(
+  {
+    label, options, value = undefined, fixedValues = [], inputType = 'text',
+    multiple = false, placeholder = '', onChange = () => {},
+    getOptionDescription = () => '', getOptionLabel = (o) => String(o)
+  }: AutocompleteProps<T>
+): React.ReactNode {
+  value = value || (multiple ? [] : '');
+
+  return <Autocomplete
+    id={label}
+    autoHighlight
+    autoSelect
+    filterOptions={_filterOptions(getOptionDescription)}
+    filterSelectedOptions={multiple}
+    freeSolo
+    getOptionLabel={getOptionLabel}
+    multiple={multiple}
+    onChange={_onChange(fixedValues, multiple, onChange)}
+    onKeyDown={_onKeyDown}
+    options={options}
+    renderInput={_renderInput(inputType, placeholder)}
+    renderOption={_renderOption(getOptionDescription, getOptionLabel)}
+    renderTags={_renderTags(fixedValues, getOptionLabel)}
+    style={{width: 'var(--mr-edit-field-width)'}}
+    value={multiple ? [...fixedValues, ...value] : value}
+  />;
+}
+
+/**
+ * Modifies the default option matching behavior to match on all Regex word
+ * boundaries and to match on both label and description.
+ * @param getOptionDescription Function to get the description for an option.
+ * @return The text for a given option.
+ */
+function _filterOptions<T>(getOptionDescription: (option: T) => string) {
+  return (
+    options: T[],
+    {inputValue, getOptionLabel}: FilterOptionsState<T>
+  ): T[] => {
+    if (!inputValue.length) {
+      return [];
+    }
+    const regex = _matchRegex(inputValue);
+    const predicate = (option: T) => {
+      return getOptionLabel(option).match(regex) ||
+        getOptionDescription(option).match(regex);
+    }
+    return options.filter(predicate).slice(0, MAX_AUTOCOMPLETE_OPTIONS);
+  }
+}
+
+/**
+ * Computes an onChange handler for Autocomplete. Adds logic to make sure
+ * fixedValues are preserved and wraps whatever onChange handler the parent
+ * passed in.
+ * @param fixedValues Values that display in the edit field but can't be
+ *   edited by the user. Usually set by filter rules in Monorail.
+ * @param multiple Whether this input takes multiple values or not.
+ * @param onChange onChange property passed in by parent, used to sync value
+ *   changes to parent.
+ * @return Function that's run on Autocomplete changes.
+ */
+function _onChange<T, Multiple, DisableClearable, FreeSolo>(
+  fixedValues: T[],
+  multiple: Multiple,
+  onChange: (
+    event: React.SyntheticEvent,
+    value: Value<T, Multiple, DisableClearable, FreeSolo>,
+    reason: AutocompleteChangeReason,
+    details?: AutocompleteChangeDetails<T>
+  ) => void,
+) {
+  return (
+    event: React.SyntheticEvent,
+    newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
+    reason: AutocompleteChangeReason,
+    details?: AutocompleteChangeDetails<T>
+  ): void => {
+    // Ensure that fixed values can't be removed.
+    if (multiple) {
+      newValue = newValue.filter((option: T) => !fixedValues.includes(option));
+    }
+
+    // Propagate onChange callback.
+    onChange(event, newValue, reason, details);
+  }
+}
+
+/**
+ * Custom keydown handler.
+ * @param e Keyboard event.
+ */
+function _onKeyDown(e: React.KeyboardEvent) {
+  // Convert spaces to Enter events to allow users to type space to create new
+  // chips.
+  if (e.key === ' ') {
+    e.key = 'Enter';
+  }
+}
+
+/**
+ * @param inputType A valid HTML 5 input type for the `input` element.
+ * @param placeholder Placeholder text for the input.
+ * @return A function that renders the input element used by
+ *   ReactAutocomplete.
+ */
+function _renderInput(inputType = 'text', placeholder = ''):
+    (params: AutocompleteRenderInputParams) => React.ReactNode {
+  return (params: AutocompleteRenderInputParams): React.ReactNode =>
+    <TextField
+      {...params} variant="standard" size="small"
+      type={inputType} placeholder={placeholder}
+    />;
+}
+
+/**
+ * Renders a single instance of an option for Autocomplete.
+ * @param getOptionDescription Function to get the description text shown.
+ * @param getOptionLabel Function to get the name of the option shown to the
+ *   user.
+ * @return ReactNode containing the JSX to be rendered.
+ */
+function _renderOption<T>(
+  getOptionDescription: (option: T) => string,
+  getOptionLabel: (option: T) => string
+): React.ReactNode {
+  return (
+    props: React.HTMLAttributes<HTMLLIElement>,
+    option: T,
+    {inputValue}: AutocompleteRenderOptionState
+  ): React.ReactNode => {
+    // Render the option label.
+    const label = getOptionLabel(option);
+    const matchValue = label.match(_matchRegex(inputValue));
+    let optionTemplate = <>{label}</>;
+    if (matchValue) {
+      // Highlight the matching text.
+      optionTemplate = <>
+        {matchValue[1]}
+        <strong>{matchValue[2]}</strong>
+        {matchValue[3]}
+      </>;
+    }
+
+    // Render the option description.
+    const description = getOptionDescription(option);
+    const matchDescription =
+      description && description.match(_matchRegex(inputValue));
+    let descriptionTemplate = <>{description}</>;
+    if (matchDescription) {
+      // Highlight the matching text.
+      descriptionTemplate = <>
+        {matchDescription[1]}
+        <strong>{matchDescription[2]}</strong>
+        {matchDescription[3]}
+      </>;
+    }
+
+    // Put the label and description together into one <li>.
+    return <li
+      {...props}
+      className={`${props.className} autocomplete-option`}
+      style={{display: 'flex', flexDirection: 'row', wordWrap: 'break-word'}}
+    >
+      <span style={{display: 'block', width: (description ? '40%' : '100%')}}>
+        {optionTemplate}
+      </span>
+      {description &&
+        <span style={{display: 'block', boxSizing: 'border-box',
+            paddingLeft: '8px', width: '60%'}}>
+          {descriptionTemplate}
+        </span>
+      }
+    </li>;
+  };
+}
+
+/**
+ * Helper to render the Chips elements used by Autocomplete. Ensures that
+ * fixedValues are disabled.
+ * @param fixedValues Undeleteable values in an issue usually set by filter
+ *   rules.
+ * @param getOptionLabel Function to compute text for the option.
+ * @return Function to render the ReactNode for all the chips.
+ */
+function _renderTags<T>(
+  fixedValues: T[], getOptionLabel: (option: T) => string
+) {
+  return (
+    value: T[],
+    getTagProps: AutocompleteRenderGetTagProps
+  ): React.ReactNode => {
+    return value.map((option, index) => {
+      const props: ChipProps = {...getTagProps({index})};
+      const disabled = fixedValues.includes(option);
+      if (disabled) {
+        delete props.onDelete;
+      }
+
+      const label = getOptionLabel(option);
+      return <Chip
+        {...props}
+        key={label}
+        label={label}
+        disabled={disabled}
+        size="small"
+      />;
+    });
+  }
+}
+
+/**
+ * Generates a RegExp to match autocomplete values.
+ * @param needle The string the user is searching for.
+ * @return A RegExp to find matching values.
+ */
+function _matchRegex(needle: string): RegExp {
+  // This code copied from ac.js.
+  // Since we use needle to build a regular expression, we need to escape RE
+  // characters. We match '-', '{', '$' and others in the needle and convert
+  // them into "\-", "\{", "\$".
+  const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
+  const modifiedPrefix = needle.replace(regexForRegexCharacters, '\\$1');
+
+  // Match the modifiedPrefix anywhere as long as it is either at the very
+  // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
+  // such as "Ga" -> "The-Great-Gatsby".
+  const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
+  return new RegExp(patternRegex, 'i' /* ignore case */);
+}
diff --git a/static_src/react/issue-wizard/DetailsStep.test.tsx b/static_src/react/issue-wizard/DetailsStep.test.tsx
new file mode 100644
index 0000000..eaef0e7
--- /dev/null
+++ b/static_src/react/issue-wizard/DetailsStep.test.tsx
@@ -0,0 +1,34 @@
+// Copyright 2021 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 React from 'react';
+import {render, cleanup} from '@testing-library/react';
+import {assert} from 'chai';
+
+import DetailsStep from './DetailsStep.tsx';
+
+describe('DetailsStep', () => {
+  afterEach(cleanup);
+
+  it('renders', async () => {
+    const {container} = render(<DetailsStep />);
+
+    // this is checking for the first question
+    const input = container.querySelector('input');
+    assert.isNotNull(input)
+
+    // this is checking for the rest
+    const count = document.querySelectorAll('textarea').length;
+    assert.equal(count, 3)
+  });
+
+  it('renders category in title', async () => {
+    const {container} = render(<DetailsStep category='UI'/>);
+
+    // this is checking the title contains our category
+    const title = container.querySelector('h2');
+    assert.include(title?.innerText, 'Details for problems with UI');
+  });
+
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/DetailsStep.tsx b/static_src/react/issue-wizard/DetailsStep.tsx
new file mode 100644
index 0000000..1a69cc1
--- /dev/null
+++ b/static_src/react/issue-wizard/DetailsStep.tsx
@@ -0,0 +1,65 @@
+// Copyright 2021 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 React from 'react';
+import {createStyles, createTheme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import TextField from '@material-ui/core/TextField';
+import {red, grey} from '@material-ui/core/colors';
+
+/**
+ * The detail step is the second step on the dot
+ * stepper. This react component provides the users with
+ * specific questions about their bug to be filled out.
+ */
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles((theme: Theme) =>
+  createStyles({
+    root: {
+      '& > *': {
+        margin: theme.spacing(1),
+        width: '100%',
+      },
+    },
+    head: {
+        marginTop: '25px',
+    },
+    red: {
+        color: red[600],
+    },
+    grey: {
+        color: grey[600],
+    },
+  }), {defaultTheme: theme}
+);
+
+export default function DetailsStep({textValues, setTextValues, category}:
+  {textValues: Object, setTextValues: Function, category: string}): React.ReactElement {
+  const classes = useStyles();
+
+  const handleChange = (valueName: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
+    const textInput = e.target.value;
+    setTextValues({...textValues, [valueName]: textInput});
+  };
+
+  return (
+    <>
+        <h2 className={classes.grey}>Details for problems with {category}</h2>
+        <form className={classes.root} noValidate autoComplete="off">
+            <h3 className={classes.head}>Please enter a one line summary <span className={classes.red}>*</span></h3>
+            <TextField id="outlined-basic-1" variant="outlined" onChange={handleChange('oneLineSummary')}/>
+
+            <h3 className={classes.head}>Steps to reproduce problem <span className={classes.red}>*</span></h3>
+            <TextField multiline rows={4} id="outlined-basic-2" variant="outlined" onChange={handleChange('stepsToReproduce')}/>
+
+            <h3 className={classes.head}>Please describe the problem <span className={classes.red}>*</span></h3>
+            <TextField multiline rows={3} id="outlined-basic-3" variant="outlined" onChange={handleChange('describeProblem')}/>
+
+            <h3 className={classes.head}>Additional Comments</h3>
+            <TextField multiline rows={3} id="outlined-basic-4" variant="outlined" onChange={handleChange('additionalComments')}/>
+        </form>
+    </>
+  );
+}
diff --git a/static_src/react/issue-wizard/DotMobileStepper.test.tsx b/static_src/react/issue-wizard/DotMobileStepper.test.tsx
new file mode 100644
index 0000000..5203110
--- /dev/null
+++ b/static_src/react/issue-wizard/DotMobileStepper.test.tsx
@@ -0,0 +1,59 @@
+// Copyright 2021 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 React from 'react';
+import {render, screen, cleanup} from '@testing-library/react';
+import {assert} from 'chai';
+
+import DotMobileStepper from './DotMobileStepper.tsx';
+
+describe('DotMobileStepper', () => {
+  let container: HTMLElement;
+
+  afterEach(cleanup);
+
+  it('renders', () => {
+    container = render(<DotMobileStepper activeStep={0} nextEnabled={true}/>).container;
+
+    // this is checking the buttons for the stepper rendered
+      const count = document.querySelectorAll('button').length;
+      assert.equal(count, 2)
+  });
+
+  it('back button disabled on first step', () => {
+    render(<DotMobileStepper activeStep={0} nextEnabled={true}/>).container;
+
+    // Finds a button on the page with "back" as text using React testing library.
+    const backButton = screen.getByRole('button', {name: /backButton/i}) as HTMLButtonElement;
+
+    // Back button is disabled on the first step.
+    assert.isTrue(backButton.disabled);
+  });
+
+  it('both buttons enabled on second step', () => {
+    render(<DotMobileStepper activeStep={1} nextEnabled={true}/>).container;
+
+    // Finds a button on the page with "back" as text using React testing library.
+    const backButton = screen.getByRole('button', {name: /backButton/i}) as HTMLButtonElement;
+
+    // Finds a button on the page with "next" as text using React testing library.
+    const nextButton = screen.getByRole('button', {name: /nextButton/i}) as HTMLButtonElement;
+
+    // Back button is not disabled on the second step.
+    assert.isFalse(backButton.disabled);
+
+    // Next button is not disabled on the second step.
+    assert.isFalse(nextButton.disabled);
+  });
+
+  it('next button disabled on last step', () => {
+    render(<DotMobileStepper activeStep={2}/>).container;
+
+    // Finds a button on the page with "next" as text using React testing library.
+    const nextButton = screen.getByRole('button', {name: /nextButton/i}) as HTMLButtonElement;
+
+    // Next button is disabled on the second step.
+    assert.isTrue(nextButton.disabled);
+  });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/DotMobileStepper.tsx b/static_src/react/issue-wizard/DotMobileStepper.tsx
new file mode 100644
index 0000000..9870f03
--- /dev/null
+++ b/static_src/react/issue-wizard/DotMobileStepper.tsx
@@ -0,0 +1,72 @@
+// Copyright 2021 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 React from 'react';
+import {createTheme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import MobileStepper from '@material-ui/core/MobileStepper';
+import Button from '@material-ui/core/Button';
+import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
+import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
+
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles({
+  root: {
+    width: '100%',
+    flexGrow: 1,
+  },
+}, {defaultTheme: theme});
+
+/**
+ * `<DotMobileStepper />`
+ *
+ * React component for rendering the linear dot stepper of the issue wizard.
+ *
+ *  @return ReactElement.
+ */
+export default function DotsMobileStepper({nextEnabled, activeStep, setActiveStep} : {nextEnabled: boolean, activeStep: number, setActiveStep: Function}) : React.ReactElement {
+  const classes = useStyles();
+
+  const handleNext = () => {
+    setActiveStep((prevActiveStep: number) => prevActiveStep + 1);
+  };
+
+  const handleBack = () => {
+    setActiveStep((prevActiveStep: number) => prevActiveStep - 1);
+  };
+
+  let label;
+  let icon;
+
+  if (activeStep === 2){
+    label = 'Submit';
+    icon = '';
+  } else {
+    label = 'Next';
+    icon = <KeyboardArrowRight />;
+  }
+  return (
+    <MobileStepper
+      id="mobile-stepper"
+      variant="dots"
+      steps={3}
+      position="static"
+      activeStep={activeStep}
+      className={classes.root}
+      nextButton={
+        <Button aria-label="nextButton" size="medium" onClick={handleNext} disabled={activeStep === 2 || !nextEnabled}>
+          {label}
+          {icon}
+        </Button>
+      }
+      backButton={
+        <Button aria-label="backButton" size="medium" onClick={handleBack} disabled={activeStep === 0}>
+          <KeyboardArrowLeft />
+          Back
+        </Button>
+      }
+    />
+  );
+}
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/LandingStep.tsx b/static_src/react/issue-wizard/LandingStep.tsx
new file mode 100644
index 0000000..efe6491
--- /dev/null
+++ b/static_src/react/issue-wizard/LandingStep.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import {makeStyles, withStyles} from '@material-ui/styles';
+import {blue, yellow, red, grey} from '@material-ui/core/colors';
+import FormControlLabel from '@material-ui/core/FormControlLabel';
+import Checkbox, {CheckboxProps} from '@material-ui/core/Checkbox';
+import SelectMenu from './SelectMenu.tsx';
+import RadioDescription from './RadioDescription.tsx';
+
+const CustomCheckbox = withStyles({
+  root: {
+    color: blue[400],
+    '&$checked': {
+      color: blue[600],
+    },
+  },
+  checked: {},
+})((props: CheckboxProps) => <Checkbox color="default" {...props} />);
+
+const useStyles = makeStyles({
+  pad: {
+    margin: '10px, 20px',
+    display: 'inline-block',
+  },
+  flex: {
+    display: 'flex',
+  },
+  inlineBlock: {
+    display: 'inline-block',
+  },
+  warningBox: {
+    minHeight: '10vh',
+    borderStyle: 'solid',
+    borderWidth: '2px',
+    borderColor: yellow[800],
+    borderRadius: '8px',
+    background: yellow[50],
+    padding: '0px 20px 1em',
+    margin: '30px 0px'
+  },
+  warningHeader: {
+    color: yellow[800],
+    fontSize: '16px',
+    fontWeight: '500',
+  },
+  star:{
+    color: red[700],
+    marginRight: '8px',
+    fontSize: '16px',
+    display: 'inline-block',
+  },
+  header: {
+    color: grey[900],
+    fontSize: '28px',
+    marginTop: '6vh',
+  },
+  subheader: {
+    color: grey[700],
+    fontSize: '18px',
+    lineHeight: '32px',
+  },
+  red: {
+    color: red[600],
+  },
+});
+
+export default function LandingStep({checkExisting, setCheckExisting, userType, setUserType, category, setCategory}:
+  {checkExisting: boolean, setCheckExisting: Function, userType: string, setUserType: Function, category: string, setCategory: Function}) {
+  const classes = useStyles();
+
+  const handleCheckChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setCheckExisting(event.target.checked);
+  };
+
+  return (
+    <>
+      <p className={classes.header}>Report an issue with Chromium</p>
+      <p className={classes.subheader}>
+        We want you to enter the best possible issue report so that the project team members
+        can act on it effectively. The following steps will help route your issue to the correct
+        people.
+      </p>
+      <p className={classes.subheader}>
+        Please select your following role: <span className={classes.red}>*</span>
+      </p>
+      <RadioDescription value={userType} setValue={setUserType}/>
+      <div className={classes.subheader}>
+        Which of the following best describes the issue that you are reporting? <span className={classes.red}>*</span>
+      </div>
+      <SelectMenu option={category} setOption={setCategory}/>
+      <div className={classes.warningBox}>
+        <p className={classes.warningHeader}> Avoid duplicate issue reports:</p>
+        <div>
+          <div className={classes.star}>*</div>
+          <FormControlLabel className={classes.pad}
+            control={
+              <CustomCheckbox
+                checked={checkExisting}
+                onChange={handleCheckChange}
+                name="warningCheck"
+              />
+            }
+            label="By checking this box, I'm acknowledging that I have searched for existing issues that already report this problem."
+          />
+        </div>
+      </div>
+    </>
+  );
+}
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/RadioDescription.test.tsx b/static_src/react/issue-wizard/RadioDescription.test.tsx
new file mode 100644
index 0000000..ff65eae
--- /dev/null
+++ b/static_src/react/issue-wizard/RadioDescription.test.tsx
@@ -0,0 +1,54 @@
+// Copyright 2021 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 React from 'react';
+import {render, screen, cleanup} from '@testing-library/react';
+import userEvent from '@testing-library/user-event'
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import RadioDescription from './RadioDescription.tsx';
+
+describe('RadioDescription', () => {
+  afterEach(cleanup);
+
+  it('renders', () => {
+    render(<RadioDescription />);
+    // look for blue radios
+      const radioOne = screen.getByRole('radio', {name: /Web Developer/i});
+      assert.isNotNull(radioOne)
+
+      const radioTwo = screen.getByRole('radio', {name: /End User/i});
+      assert.isNotNull(radioTwo)
+
+      const radioThree = screen.getByRole('radio', {name: /Chromium Contributor/i});
+      assert.isNotNull(radioThree)
+  });
+
+  it('checks selected radio value', () => {
+    // We're passing in the "Web Developer" value here manually
+    // to tell our code that that radio button is selected.
+    render(<RadioDescription value={'Web Developer'} />);
+
+    const checkedRadio = screen.getByRole('radio', {name: /Web Developer/i});
+    assert.isTrue(checkedRadio.checked);
+
+    // Extra check to make sure we haven't checked every single radio button.
+    const uncheckedRadio = screen.getByRole('radio', {name: /End User/i});
+    assert.isFalse(uncheckedRadio.checked);
+  });
+
+  it('sets radio value when clicked', () => {
+    // Using the sinon.js testing library to create a function for testing.
+    const setValue = sinon.stub();
+
+    render(<RadioDescription setValue={setValue} />);
+
+    const radio = screen.getByRole('radio', {name: /Web Developer/i});
+    userEvent.click(radio);
+
+    // Asserts that "Web Developer" was passed into our "setValue" function.
+    sinon.assert.calledWith(setValue, 'Web Developer');
+  });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/RadioDescription.tsx b/static_src/react/issue-wizard/RadioDescription.tsx
new file mode 100644
index 0000000..ad78c78
--- /dev/null
+++ b/static_src/react/issue-wizard/RadioDescription.tsx
@@ -0,0 +1,117 @@
+// Copyright 2021 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 React from 'react';
+import {makeStyles, withStyles} from '@material-ui/styles';
+import {blue, grey} from '@material-ui/core/colors';
+import Radio, {RadioProps} from '@material-ui/core/Radio';
+
+const userGroups = Object.freeze({
+  END_USER: 'End User',
+  WEB_DEVELOPER: 'Web Developer',
+  CONTRIBUTOR: 'Chromium Contributor',
+});
+
+const BlueRadio = withStyles({
+  root: {
+    color: blue[400],
+    '&$checked': {
+      color: blue[600],
+    },
+  },
+  checked: {},
+})((props: RadioProps) => <Radio color="default" {...props} />);
+
+const useStyles = makeStyles({
+  flex: {
+    display: 'flex',
+    justifyContent: 'space-between',
+  },
+  container: {
+    width: '320px',
+    height: '150px',
+    position: 'relative',
+    display: 'inline-block',
+  },
+  text: {
+    position: 'absolute',
+    display: 'inline-block',
+    left: '55px',
+  },
+  title: {
+    marginTop: '7px',
+    fontSize: '20px',
+    color: grey[900],
+  },
+  subheader: {
+    fontSize: '16px',
+    color: grey[800],
+  },
+  line: {
+    position: 'absolute',
+    bottom: 0,
+    width: '300px',
+    left: '20px',
+  }
+});
+
+/**
+ * `<RadioDescription />`
+ *
+ * React component for radio buttons and their descriptions
+ * on the landing step of the Issue Wizard.
+ *
+ *  @return ReactElement.
+ */
+export default function RadioDescription({value, setValue} : {value: string, setValue: Function}): React.ReactElement {
+  const classes = useStyles();
+
+  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setValue(event.target.value);
+  };
+
+  return (
+    <div className={classes.flex}>
+      <div className={classes.container}>
+        <BlueRadio
+          checked={value === userGroups.END_USER}
+          onChange={handleChange}
+          value={userGroups.END_USER}
+          inputProps={{ 'aria-label': userGroups.END_USER}}
+        />
+        <div className={classes.text}>
+          <p className={classes.title}>{userGroups.END_USER}</p>
+          <p className={classes.subheader}>I am a user trying to do something on a website.</p>
+        </div>
+        <hr color={grey[200]} className={classes.line}/>
+      </div>
+      <div className={classes.container}>
+        <BlueRadio
+          checked={value === userGroups.WEB_DEVELOPER}
+          onChange={handleChange}
+          value={userGroups.WEB_DEVELOPER}
+          inputProps={{ 'aria-label': userGroups.WEB_DEVELOPER }}
+        />
+        <div className={classes.text}>
+          <p className={classes.title}>{userGroups.WEB_DEVELOPER}</p>
+          <p className={classes.subheader}>I am a web developer trying to build something.</p>
+        </div>
+        <hr color={grey[200]} className={classes.line}/>
+      </div>
+      <div className={classes.container}>
+        <BlueRadio
+          checked={value === userGroups.CONTRIBUTOR}
+          onChange={handleChange}
+          value={userGroups.CONTRIBUTOR}
+          inputProps={{ 'aria-label': userGroups.CONTRIBUTOR }}
+        />
+        <div className={classes.text}>
+          <p className={classes.title}>{userGroups.CONTRIBUTOR}</p>
+          <p className={classes.subheader}>I know about a problem in specific tests or code.</p>
+        </div>
+        <hr color={grey[200]} className={classes.line}/>
+      </div>
+    </div>
+    );
+  }
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/SelectMenu.test.tsx b/static_src/react/issue-wizard/SelectMenu.test.tsx
new file mode 100644
index 0000000..13efef6
--- /dev/null
+++ b/static_src/react/issue-wizard/SelectMenu.test.tsx
@@ -0,0 +1,38 @@
+// Copyright 2021 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 React from 'react';
+import {render} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {screen} from '@testing-library/dom';
+import {assert} from 'chai';
+
+import SelectMenu from './SelectMenu.tsx';
+
+describe('SelectMenu', () => {
+  let container: React.RenderResult;
+
+  beforeEach(() => {
+    container = render(<SelectMenu />).container;
+  });
+
+  it('renders', () => {
+    const form = container.querySelector('form');
+    assert.isNotNull(form)
+  });
+
+  it('renders options on click', () => {
+    const input = document.getElementById('outlined-select-category');
+    if (!input) {
+      throw new Error('Input is undefined');
+    }
+
+    userEvent.click(input)
+
+    // 14 is the current number of options in the select menu
+    const count = screen.getAllByTestId('select-menu-item').length;
+
+    assert.equal(count, 14);
+  });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/SelectMenu.tsx b/static_src/react/issue-wizard/SelectMenu.tsx
new file mode 100644
index 0000000..3b0b96d
--- /dev/null
+++ b/static_src/react/issue-wizard/SelectMenu.tsx
@@ -0,0 +1,133 @@
+// Copyright 2021 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 React from 'react';
+import {createTheme, Theme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import MenuItem from '@material-ui/core/MenuItem';
+import TextField from '@material-ui/core/TextField';
+
+const CATEGORIES = [
+  {
+    value: 'UI',
+    label: 'UI',
+  },
+  {
+    value: 'Accessibility',
+    label: 'Accessibility',
+  },
+  {
+    value: 'Network/Downloading',
+    label: 'Network/Downloading',
+  },
+  {
+    value: 'Audio/Video',
+    label: 'Audio/Video',
+  },
+  {
+    value: 'Content',
+    label: 'Content',
+  },
+  {
+    value: 'Apps',
+    label: 'Apps',
+  },
+  {
+    value: 'Extensions/Themes',
+    label: 'Extensions/Themes',
+  },
+  {
+    value: 'Webstore',
+    label: 'Webstore',
+  },
+  {
+    value: 'Sync',
+    label: 'Sync',
+  },
+  {
+    value: 'Enterprise',
+    label: 'Enterprise',
+  },
+  {
+    value: 'Installation',
+    label: 'Installation',
+  },
+  {
+    value: 'Crashes',
+    label: 'Crashes',
+  },
+  {
+    value: 'Security',
+    label: 'Security',
+  },
+  {
+    value: 'Other',
+    label: 'Other',
+  },
+];
+
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles((theme: Theme) => ({
+  container: {
+    display: 'flex',
+    flexWrap: 'wrap',
+    maxWidth: '65%',
+  },
+  textField: {
+    marginLeft: theme.spacing(1),
+    marginRight: theme.spacing(1),
+  },
+  menu: {
+    width: '100%',
+    minWidth: '300px',
+  },
+}), {defaultTheme: theme});
+
+/**
+ * Select menu component that is located on the landing step if the
+ * Issue Wizard. The menu is used for the user to indicate the category
+ * of their bug when filing an issue.
+ *
+ * @return ReactElement.
+ */
+export default function SelectMenu({option, setOption}: {option: string, setOption: Function}) {
+  const classes = useStyles();
+  const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
+    setOption(event.target.value as string);
+  };
+
+  return (
+    <form className={classes.container} noValidate autoComplete="off">
+      <TextField
+        id="outlined-select-category"
+        select
+        label=''
+        className={classes.textField}
+        value={option}
+        onChange={handleChange}
+        InputLabelProps={{shrink: false}}
+        SelectProps={{
+          MenuProps: {
+            className: classes.menu,
+          },
+        }}
+        margin="normal"
+        variant="outlined"
+        fullWidth={true}
+      >
+      {CATEGORIES.map(option => (
+        <MenuItem
+          className={classes.menu}
+          key={option.value}
+          value={option.value}
+          data-testid="select-menu-item"
+        >
+           {option.label}
+        </MenuItem>
+       ))}
+      </TextField>
+    </form>
+  );
+}
\ No newline at end of file
diff --git a/static_src/react/mr-react-autocomplete.test.ts b/static_src/react/mr-react-autocomplete.test.ts
new file mode 100644
index 0000000..8553c36
--- /dev/null
+++ b/static_src/react/mr-react-autocomplete.test.ts
@@ -0,0 +1,158 @@
+// Copyright 2021 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 {MrReactAutocomplete} from './mr-react-autocomplete.tsx';
+
+let element: MrReactAutocomplete;
+
+describe('mr-react-autocomplete', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-react-autocomplete');
+    element.vocabularyName = 'member';
+    document.body.appendChild(element);
+
+    sinon.stub(element, 'stateChanged');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrReactAutocomplete);
+  });
+
+  it('ReactDOM renders on update', async () => {
+    element.value = 'Penguin Island';
+
+    await element.updateComplete;
+
+    const input = element.querySelector('input');
+
+    assert.equal(input?.value, 'Penguin Island');
+  });
+
+  it('does not update on new copies of the same values', async () => {
+    element.fixedValues = ['test'];
+    element.value = ['hah'];
+
+    sinon.spy(element, 'updated');
+
+    await element.updateComplete;
+    sinon.assert.calledOnce(element.updated);
+
+    element.fixedValues = ['test'];
+    element.value = ['hah'];
+
+    await element.updateComplete;
+    sinon.assert.calledOnce(element.updated);
+  });
+
+  it('_getOptionDescription with component vocabulary gets docstring', () => {
+    element.vocabularyName = 'component';
+    element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+    element._labels = new Map([['M-84', {docstring: 'Test label docs'}]]);
+
+    assert.equal(element._getOptionDescription('Infra>UI'), 'Test docs');
+    assert.equal(element._getOptionDescription('M-84'), '');
+    assert.equal(element._getOptionDescription('NoMatch'), '');
+  });
+
+  it('_getOptionDescription with label vocabulary gets docstring', () => {
+    element.vocabularyName = 'label';
+    element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+    element._labels = new Map([['m-84', {docstring: 'Test label docs'}]]);
+
+    assert.equal(element._getOptionDescription('Infra>UI'), '');
+    assert.equal(element._getOptionDescription('M-84'), 'Test label docs');
+    assert.equal(element._getOptionDescription('NoMatch'), '');
+  });
+
+  it('_getOptionDescription with other vocabulary gets empty docstring', () => {
+    element.vocabularyName = 'owner';
+    element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+    element._labels = new Map([['M-84', {docstring: 'Test label docs'}]]);
+
+    assert.equal(element._getOptionDescription('Infra>UI'), '');
+    assert.equal(element._getOptionDescription('M-84'), '');
+    assert.equal(element._getOptionDescription('NoMatch'), '');
+  });
+
+  it('_options gets component names', () => {
+    element.vocabularyName = 'component';
+    element._components = new Map([
+      ['Infra>UI', {docstring: 'Test docs'}],
+      ['Bird>Penguin', {docstring: 'Test docs'}],
+    ]);
+
+    assert.deepEqual(element._options(), ['Infra>UI', 'Bird>Penguin']);
+  });
+
+  it('_options gets label names', () => {
+    element.vocabularyName = 'label';
+    element._labels = new Map([
+      ['M-84', {label: 'm-84', docstring: 'Test docs'}],
+      ['Restrict-View-Bagel', {label: 'restrict-VieW-bAgEl', docstring: 'T'}],
+    ]);
+
+    assert.deepEqual(element._options(), ['m-84', 'restrict-VieW-bAgEl']);
+  });
+
+  it('_options gets member names with groups', () => {
+    element.vocabularyName = 'member';
+    element._members = {
+      userRefs: [
+        {displayName: 'penguin@island.com'},
+        {displayName: 'google@monorail.com'},
+        {displayName: 'group@birds.com'},
+      ],
+      groupRefs: [{displayName: 'group@birds.com'}],
+    };
+
+    assert.deepEqual(element._options(),
+        ['penguin@island.com', 'google@monorail.com', 'group@birds.com']);
+  });
+
+  it('_options gets owner names without groups', () => {
+    element.vocabularyName = 'owner';
+    element._members = {
+      userRefs: [
+        {displayName: 'penguin@island.com'},
+        {displayName: 'google@monorail.com'},
+        {displayName: 'group@birds.com'},
+      ],
+      groupRefs: [{displayName: 'group@birds.com'}],
+    };
+
+    assert.deepEqual(element._options(),
+        ['penguin@island.com', 'google@monorail.com']);
+  });
+
+  it('_options gets owner names without groups', () => {
+    element.vocabularyName = 'project';
+    element._projects = {
+      ownerOf: ['penguins'],
+      memberOf: ['birds'],
+      contributorTo: ['canary', 'owl-island'],
+    };
+
+    assert.deepEqual(element._options(),
+        ['penguins', 'birds', 'canary', 'owl-island']);
+  });
+
+  it('_options gives empty array for empty vocabulary name', () => {
+    element.vocabularyName = '';
+    assert.deepEqual(element._options(), []);
+  });
+
+  it('_options throws error on unknown vocabulary', () => {
+    element.vocabularyName = 'whatever';
+
+    assert.throws(element._options.bind(element),
+        'Unknown vocabulary name: whatever');
+  });
+});
diff --git a/static_src/react/mr-react-autocomplete.tsx b/static_src/react/mr-react-autocomplete.tsx
new file mode 100644
index 0000000..8cc5f84
--- /dev/null
+++ b/static_src/react/mr-react-autocomplete.tsx
@@ -0,0 +1,176 @@
+// Copyright 2021 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, property, internalProperty} from 'lit-element';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import deepEqual from 'deep-equal';
+
+import {AutocompleteChangeDetails, AutocompleteChangeReason}
+  from '@material-ui/core/Autocomplete';
+import {ThemeProvider, createTheme} from '@material-ui/core/styles';
+
+import {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {userRefsToDisplayNames} from 'shared/convertersV0.js';
+import {arrayDifference} from 'shared/helpers.js';
+
+import {ReactAutocomplete} from 'react/ReactAutocomplete.tsx';
+
+type Vocabulary = 'component' | 'label' | 'member' | 'owner' | 'project' | '';
+
+
+/**
+ * A normal text input enhanced by a panel of suggested options.
+ * `<mr-react-autocomplete>` wraps a React implementation of autocomplete
+ * in a web component, suitable for embedding in a LitElement component
+ * hierarchy. All parents must not use Shadow DOM. The supported autocomplete
+ * option types are defined in type Vocabulary.
+ */
+export class MrReactAutocomplete extends connectStore(LitElement) {
+  // Required properties passed in from the parent element.
+  /** The `<input id>` attribute. Called "label" to avoid name conflicts. */
+  @property() label: string = '';
+  /** The autocomplete option type. See type Vocabulary for the full list. */
+  @property() vocabularyName: Vocabulary = '';
+
+  // Optional properties passed in from the parent element.
+  /** The value (or values, if `multiple === true`). */
+  @property({
+    hasChanged: (oldVal, newVal) => !deepEqual(oldVal, newVal),
+  }) value?: string | string[] = undefined;
+  /** Values that show up as disabled chips. */
+  @property({
+    hasChanged: (oldVal, newVal) => !deepEqual(oldVal, newVal),
+  }) fixedValues: string[] = [];
+  /** A valid HTML 5 input type for the `input` element. */
+  @property() inputType: string = 'text';
+  /** True for chip input that takes multiple values, false for single input. */
+  @property() multiple: boolean = false;
+  /** Placeholder for the form input. */
+  @property() placeholder?: string = '';
+  /** Callback for input value changes. */
+  @property() onChange: (
+    event: React.SyntheticEvent,
+    newValue: string | string[] | null,
+    reason: AutocompleteChangeReason,
+    details?: AutocompleteChangeDetails
+  ) => void = () => {};
+
+  // Internal state properties from the Redux store.
+  @internalProperty() protected _components:
+    Map<string, ComponentDef> = new Map();
+  @internalProperty() protected _labels: Map<string, LabelDef> = new Map();
+  @internalProperty() protected _members:
+    {userRefs?: UserRef[], groupRefs?: UserRef[]} = {};
+  @internalProperty() protected _projects:
+    {contributorTo?: string[], memberOf?: string[], ownerOf?: string[]} = {};
+
+  /** @override */
+  createRenderRoot(): LitElement {
+    return this;
+  }
+
+  /** @override */
+  updated(changedProperties: Map<string | number | symbol, unknown>): void {
+    super.updated(changedProperties);
+
+    const theme = createTheme({
+      components: {
+        MuiChip: {
+          styleOverrides: {
+            root: {fontSize: 13},
+          },
+        },
+      },
+      palette: {
+        action: {disabledOpacity: 0.6},
+        primary: {
+          // Same as var(--chops-primary-accent-color).
+          main: '#1976d2',
+        },
+      },
+      typography: {fontSize: 11.375},
+    });
+    const element = <ThemeProvider theme={theme}>
+      <ReactAutocomplete
+        label={this.label}
+        options={this._options()}
+        value={this.value}
+        fixedValues={this.fixedValues}
+        inputType={this.inputType}
+        multiple={this.multiple}
+        placeholder={this.placeholder}
+        onChange={this.onChange}
+        getOptionDescription={this._getOptionDescription.bind(this)}
+        getOptionLabel={(option: string) => option}
+      />
+    </ThemeProvider>;
+    ReactDOM.render(element, this);
+  }
+
+  /** @override */
+  stateChanged(state: any): void {
+    super.stateChanged(state);
+
+    this._components = projectV0.componentsMap(state);
+    this._labels = projectV0.labelDefMap(state);
+    this._members = projectV0.viewedVisibleMembers(state);
+    this._projects = userV0.projects(state);
+  }
+
+  /**
+   * Computes which description belongs to given autocomplete option.
+   * Different data is shown depending on the autocomplete vocabulary.
+   * @param option The option to find a description for.
+   * @return The description for the option.
+   */
+  _getOptionDescription(option: string): string {
+    switch (this.vocabularyName) {
+      case 'component': {
+        const component = this._components.get(option);
+        return component && component.docstring || '';
+      } case 'label': {
+        const label = this._labels.get(option.toLowerCase());
+        return label && label.docstring || '';
+      } default: {
+        return '';
+      }
+    }
+  }
+
+  /**
+   * Computes the set of options used by the autocomplete instance.
+   * @return Array of strings that the user can try to match.
+   */
+  _options(): string[] {
+    switch (this.vocabularyName) {
+      case 'component': {
+        return [...this._components.keys()];
+      } case 'label': {
+        // The label map keys are lowercase. Use the LabelDef label name instead.
+        return [...this._labels.values()].map((labelDef: LabelDef) => labelDef.label);
+      } case 'member': {
+        const {userRefs = []} = this._members;
+        const users = userRefsToDisplayNames(userRefs);
+        return users;
+      } case 'owner': {
+        const {userRefs = [], groupRefs = []} = this._members;
+        const users = userRefsToDisplayNames(userRefs);
+        const groups = userRefsToDisplayNames(groupRefs);
+        // Remove groups from the list of all members.
+        return arrayDifference(users, groups);
+      } case 'project': {
+        const {ownerOf = [], memberOf = [], contributorTo = []} = this._projects;
+        return [...ownerOf, ...memberOf, ...contributorTo];
+      } case '': {
+        return [];
+      } default: {
+        throw new Error(`Unknown vocabulary name: ${this.vocabularyName}`);
+      }
+    }
+  }
+}
+customElements.define('mr-react-autocomplete', MrReactAutocomplete);
diff --git a/static_src/reducers/base.js b/static_src/reducers/base.js
new file mode 100644
index 0000000..f4603b7
--- /dev/null
+++ b/static_src/reducers/base.js
@@ -0,0 +1,109 @@
+// 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 {connect} from 'pwa-helpers/connect-mixin.js';
+import {applyMiddleware, combineReducers, compose, createStore} from 'redux';
+import thunk from 'redux-thunk';
+import {hotlists} from './hotlists.js';
+import * as issueV0 from './issueV0.js';
+import * as permissions from './permissions.js';
+import * as projects from './projects.js';
+import * as projectV0 from './projectV0.js';
+import * as sitewide from './sitewide.js';
+import {stars} from './stars.js';
+import * as users from './users.js';
+import * as userV0 from './userV0.js';
+import * as ui from './ui.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+const RESET_STATE = 'RESET_STATE';
+
+/* State Shape
+{
+  hotlists: Object,
+  permissions: Object,
+  projects: Object,
+  sitewide: Object,
+  users: Object,
+
+  ui: Object,
+
+  // To be deprecated
+  issue: Object,
+  projectV0: Object,
+  userV0: Object,
+}
+*/
+
+// Reducers
+const reducer = combineReducers({
+  hotlists: hotlists.reducer,
+  issue: issueV0.reducer,
+  permissions: permissions.reducer,
+  projects: projects.reducer,
+  projectV0: projectV0.reducer,
+  users: users.reducer,
+  userV0: userV0.reducer,
+  sitewide: sitewide.reducer,
+  stars: stars.reducer,
+
+  ui: ui.reducer,
+});
+
+/**
+ * The top level reducer function that all actions pass through.
+ * @param {any} state
+ * @param {AnyAction} action
+ * @return {any}
+ */
+export function rootReducer(state, action) {
+  if (action.type === RESET_STATE) {
+    state = undefined;
+  }
+  return reducer(state, action);
+}
+
+// Selectors
+
+// Action Creators
+
+/**
+ * Changes Redux state back to its default initial state. Primarily
+ * used in testing.
+ * @return {AnyAction} An action to reset Redux state to default.
+ */
+export const resetState = () => ({type: RESET_STATE});
+
+// Store
+
+// For debugging with the Redux Devtools extension:
+// https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd/
+const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+export const store = createStore(rootReducer, composeEnhancers(
+    applyMiddleware(thunk),
+));
+
+/**
+ * Class mixin function that connects a given HTMLElement class to our
+ * store instance.
+ * @link https://pwa-starter-kit.polymer-project.org/redux-and-state-management#connecting-an-element-to-the-store
+ * @param {typeof HTMLElement} class
+ * @return {function} New class type with connected features.
+ */
+export const connectStore = connect(store);
+
+/**
+ * Promise to allow waiting for a state update. Useful in testing.
+ * @example
+ * store.dispatch(updateState());
+ * await stateUpdated;
+ * doThingWithUpdatedState();
+ *
+ * @type {Promise}
+ */
+export const stateUpdated = new Promise((resolve) => {
+  store.subscribe(resolve);
+});
diff --git a/static_src/reducers/hotlists.js b/static_src/reducers/hotlists.js
new file mode 100644
index 0000000..95989cc
--- /dev/null
+++ b/static_src/reducers/hotlists.js
@@ -0,0 +1,517 @@
+// 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.
+
+/**
+ * @fileoverview Hotlist actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving hotlist state
+ * on the frontend.
+ *
+ * The Hotlist data is stored in a normalized format.
+ * `name` is a reference to the currently viewed Hotlist.
+ * `hotlists` stores all Hotlist data indexed by Hotlist name.
+ * `hotlistItems` stores all Hotlist items indexed by Hotlist name.
+ * `hotlist` is a selector that gets the currently viewed Hotlist data.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {userIdOrDisplayNameToUserRef, issueNameToRef}
+  from 'shared/convertersV0.js';
+import {pathsToFieldMask} from 'shared/converters.js';
+
+import * as issueV0 from './issueV0.js';
+import * as permissions from './permissions.js';
+import * as sitewide from './sitewide.js';
+import * as ui from './ui.js';
+import * as users from './users.js';
+
+import 'shared/typedef.js';
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+/** @type {Array<string>} */
+export const DEFAULT_COLUMNS = [
+  'Rank', 'ID', 'Status', 'Owner', 'Summary', 'Modified',
+];
+
+// Permissions
+// TODO(crbug.com/monorail/7879): Move these to a permissions constants file.
+export const EDIT = 'HOTLIST_EDIT';
+export const ADMINISTER = 'HOTLIST_ADMINISTER';
+
+// Actions
+export const SELECT = 'hotlist/SELECT';
+export const RECEIVE_HOTLIST = 'hotlist/RECEIVE_HOTLIST';
+
+export const DELETE_START = 'hotlist/DELETE_START';
+export const DELETE_SUCCESS = 'hotlist/DELETE_SUCCESS';
+export const DELETE_FAILURE = 'hotlist/DELETE_FAILURE';
+
+export const FETCH_START = 'hotlist/FETCH_START';
+export const FETCH_SUCCESS = 'hotlist/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'hotlist/FETCH_FAILURE';
+
+export const FETCH_ITEMS_START = 'hotlist/FETCH_ITEMS_START';
+export const FETCH_ITEMS_SUCCESS = 'hotlist/FETCH_ITEMS_SUCCESS';
+export const FETCH_ITEMS_FAILURE = 'hotlist/FETCH_ITEMS_FAILURE';
+
+export const REMOVE_EDITORS_START = 'hotlist/REMOVE_EDITORS_START';
+export const REMOVE_EDITORS_SUCCESS = 'hotlist/REMOVE_EDITORS_SUCCESS';
+export const REMOVE_EDITORS_FAILURE = 'hotlist/REMOVE_EDITORS_FAILURE';
+
+export const REMOVE_ITEMS_START = 'hotlist/REMOVE_ITEMS_START';
+export const REMOVE_ITEMS_SUCCESS = 'hotlist/REMOVE_ITEMS_SUCCESS';
+export const REMOVE_ITEMS_FAILURE = 'hotlist/REMOVE_ITEMS_FAILURE';
+
+export const RERANK_ITEMS_START = 'hotlist/RERANK_ITEMS_START';
+export const RERANK_ITEMS_SUCCESS = 'hotlist/RERANK_ITEMS_SUCCESS';
+export const RERANK_ITEMS_FAILURE = 'hotlist/RERANK_ITEMS_FAILURE';
+
+export const UPDATE_START = 'hotlist/UPDATE_START';
+export const UPDATE_SUCCESS = 'hotlist/UPDATE_SUCCESS';
+export const UPDATE_FAILURE = 'hotlist/UPDATE_FAILURE';
+
+/* State Shape
+{
+  name: string,
+
+  byName: Object<string, Hotlist>,
+  hotlistItems: Object<string, Array<HotlistItem>>,
+
+  requests: {
+    fetch: ReduxRequestState,
+    fetchItems: ReduxRequestState,
+    update: ReduxRequestState,
+  },
+}
+*/
+
+// Reducers
+
+/**
+ * A reference to the currently viewed Hotlist.
+ * @param {?string} state The existing Hotlist resource name.
+ * @param {AnyAction} action
+ * @return {?string}
+ */
+export const nameReducer = createReducer(null, {
+  [SELECT]: (_state, {name}) => name,
+});
+
+/**
+ * All Hotlist data indexed by Hotlist resource name.
+ * @param {Object<string, Hotlist>} state The existing Hotlist data.
+ * @param {AnyAction} action
+ * @param {Hotlist} action.hotlist The Hotlist that was fetched.
+ * @return {Object<string, Hotlist>}
+ */
+export const byNameReducer = createReducer({}, {
+  [RECEIVE_HOTLIST]: (state, {hotlist}) => {
+    if (!hotlist.defaultColumns) hotlist.defaultColumns = [];
+    if (!hotlist.editors) hotlist.editors = [];
+    return {...state, [hotlist.name]: hotlist};
+  },
+});
+
+/**
+ * All Hotlist items indexed by Hotlist resource name.
+ * @param {Object<string, Array<HotlistItem>>} state The existing items.
+ * @param {AnyAction} action
+ * @param {name} action.name The Hotlist resource name.
+ * @param {Array<HotlistItem>} action.items The Hotlist items fetched.
+ * @return {Object<string, Array<HotlistItem>>}
+ */
+export const hotlistItemsReducer = createReducer({}, {
+  [FETCH_ITEMS_SUCCESS]: (state, {name, items}) => ({...state, [name]: items}),
+});
+
+export const requestsReducer = combineReducers({
+  deleteHotlist: createRequestReducer(
+      DELETE_START, DELETE_SUCCESS, DELETE_FAILURE),
+  fetch: createRequestReducer(
+      FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+  fetchItems: createRequestReducer(
+      FETCH_ITEMS_START, FETCH_ITEMS_SUCCESS, FETCH_ITEMS_FAILURE),
+  removeEditors: createRequestReducer(
+      REMOVE_EDITORS_START, REMOVE_EDITORS_SUCCESS, REMOVE_EDITORS_FAILURE),
+  removeItems: createRequestReducer(
+      REMOVE_ITEMS_START, REMOVE_ITEMS_SUCCESS, REMOVE_ITEMS_FAILURE),
+  rerankItems: createRequestReducer(
+      RERANK_ITEMS_START, RERANK_ITEMS_SUCCESS, RERANK_ITEMS_FAILURE),
+  update: createRequestReducer(
+      UPDATE_START, UPDATE_SUCCESS, UPDATE_FAILURE),
+});
+
+export const reducer = combineReducers({
+  name: nameReducer,
+
+  byName: byNameReducer,
+  hotlistItems: hotlistItemsReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns the currently viewed Hotlist resource name, or null if there is none.
+ * @param {any} state
+ * @return {?string}
+ */
+export const name = (state) => state.hotlists.name;
+
+/**
+ * Returns all the Hotlist data in the store as a mapping from name to Hotlist.
+ * @param {any} state
+ * @return {Object<string, Hotlist>}
+ */
+export const byName = (state) => state.hotlists.byName;
+
+/**
+ * Returns all the Hotlist items in the store as a mapping from a
+ * Hotlist resource name to its respective array of HotlistItems.
+ * @param {any} state
+ * @return {Object<string, Array<HotlistItem>>}
+ */
+export const hotlistItems = (state) => state.hotlists.hotlistItems;
+
+/**
+ * Returns the currently viewed Hotlist, or null if there is none.
+ * @param {any} state
+ * @return {?Hotlist}
+ */
+export const viewedHotlist = createSelector(
+    [byName, name],
+    (byName, name) => name && byName[name] || null);
+
+/**
+ * Returns the owner of the currently viewed Hotlist, or null if there is none.
+ * @param {any} state
+ * @return {?User}
+ */
+export const viewedHotlistOwner = createSelector(
+    [viewedHotlist, users.byName],
+    (hotlist, usersByName) => {
+      return hotlist && usersByName[hotlist.owner] || null;
+    });
+
+/**
+ * Returns the editors of the currently viewed Hotlist. Returns null if there
+ * is no hotlist data. Includes a null in the array for each editor whose User
+ * data is not in the store.
+ * @param {any} state
+ * @return {Array<User>}
+ */
+export const viewedHotlistEditors = createSelector(
+    [viewedHotlist, users.byName],
+    (hotlist, usersByName) => {
+      if (!hotlist) return null;
+      return hotlist.editors.map((editor) => usersByName[editor] || null);
+    });
+
+/**
+ * Returns an Array containing the items in the currently viewed Hotlist,
+ * or [] if there is no current Hotlist or no Hotlist data.
+ * @param {any} state
+ * @return {Array<HotlistItem>}
+ */
+export const viewedHotlistItems = createSelector(
+    [hotlistItems, name],
+    (hotlistItems, name) => name && hotlistItems[name] || []);
+
+/**
+ * Returns an Array containing the HotlistIssues in the currently viewed
+ * Hotlist, or [] if there is no current Hotlist or no Hotlist data.
+ * A HotlistIssue merges the HotlistItem and Issue into one flat object.
+ * @param {any} state
+ * @return {Array<HotlistIssue>}
+ */
+export const viewedHotlistIssues = createSelector(
+    [viewedHotlistItems, issueV0.issue, users.byName],
+    (items, getIssue, usersByName) => {
+      // Filter out issues that haven't been fetched yet or failed to fetch.
+      // Example: if the user doesn't have permissions to view the issue.
+      // <mr-issue-list> assumes that every Issue is populated.
+      const itemsWithData = items.filter((item) => getIssue(item.issue));
+      return itemsWithData.map((item) => ({
+        ...getIssue(item.issue),
+        ...item,
+        adder: usersByName[item.adder],
+      }));
+    });
+
+/**
+ * Returns the currently viewed Hotlist columns.
+ * @param {any} state
+ * @return {Array<string>}
+ */
+export const viewedHotlistColumns = createSelector(
+    [viewedHotlist, sitewide.currentColumns],
+    (hotlist, sitewideCurrentColumns) => {
+      if (sitewideCurrentColumns) return sitewideCurrentColumns;
+      if (!hotlist) return DEFAULT_COLUMNS;
+      if (!hotlist.defaultColumns.length) return DEFAULT_COLUMNS;
+      return hotlist.defaultColumns.map((col) => col.column);
+    });
+
+/**
+ * Returns the currently viewed Hotlist permissions, or [] if there is none.
+ * @param {any} state
+ * @return {Array<Permission>}
+ */
+export const viewedHotlistPermissions = createSelector(
+    [viewedHotlist, permissions.byName],
+    (hotlist, permissionsByName) => {
+      if (!hotlist) return [];
+      const permissionSet = permissionsByName[hotlist.name];
+      if (!permissionSet) return [];
+      return permissionSet.permissions;
+    });
+
+/**
+ * Returns the Hotlist requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.hotlists.requests;
+
+// Action Creators
+
+/**
+ * Action creator to delete the Hotlist. We would have liked to have named this
+ * `delete` but it's a reserved word in JS.
+ * @param {string} name The resource name of the Hotlist to delete.
+ * @return {function(function): Promise<void>}
+ */
+export const deleteHotlist = (name) => async (dispatch) => {
+  dispatch({type: DELETE_START});
+
+  try {
+    const args = {name};
+    await prpcClient.call('monorail.v3.Hotlists', 'DeleteHotlist', args);
+
+    dispatch({type: DELETE_SUCCESS});
+  } catch (error) {
+    dispatch({type: DELETE_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to fetch a Hotlist object.
+ * @param {string} name The resource name of the Hotlist to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (name) => async (dispatch) => {
+  dispatch({type: FETCH_START});
+
+  try {
+    /** @type {Hotlist} */
+    const hotlist = await prpcClient.call(
+        'monorail.v3.Hotlists', 'GetHotlist', {name});
+    dispatch({type: FETCH_SUCCESS});
+    dispatch({type: RECEIVE_HOTLIST, hotlist});
+
+    const editors = hotlist.editors.map((editor) => editor);
+    editors.push(hotlist.owner);
+    await dispatch(users.batchGet(editors));
+  } catch (error) {
+    dispatch({type: FETCH_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to fetch the items in a Hotlist.
+ * @param {string} name The resource name of the Hotlist to fetch.
+ * @return {function(function): Promise<Array<HotlistItem>>}
+ */
+export const fetchItems = (name) => async (dispatch) => {
+  dispatch({type: FETCH_ITEMS_START});
+
+  try {
+    const args = {parent: name, orderBy: 'rank'};
+    /** @type {{items: Array<HotlistItem>}} */
+    const {items} = await prpcClient.call(
+        'monorail.v3.Hotlists', 'ListHotlistItems', args);
+    if (!items) {
+      dispatch({type: FETCH_ITEMS_SUCCESS, name, items: []});
+    }
+    const itemsWithRank =
+        items.map((item) => item.rank ? item : {...item, rank: 0});
+
+    const issueRefs = items.map((item) => issueNameToRef(item.issue));
+    await dispatch(issueV0.fetchIssues(issueRefs));
+
+    const adderNames = [...new Set(items.map((item) => item.adder))];
+    await dispatch(users.batchGet(adderNames));
+
+    dispatch({type: FETCH_ITEMS_SUCCESS, name, items: itemsWithRank});
+    return itemsWithRank;
+  } catch (error) {
+    dispatch({type: FETCH_ITEMS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to remove editors from a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} editors The resource names of the Users to remove.
+ * @return {function(function): Promise<void>}
+ */
+export const removeEditors = (name, editors) => async (dispatch) => {
+  dispatch({type: REMOVE_EDITORS_START});
+
+  try {
+    const args = {name, editors};
+    await prpcClient.call('monorail.v3.Hotlists', 'RemoveHotlistEditors', args);
+
+    dispatch({type: REMOVE_EDITORS_SUCCESS});
+
+    await dispatch(fetch(name));
+  } catch (error) {
+    dispatch({type: REMOVE_EDITORS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to remove items from a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} issues The resource names of the Issues to remove.
+ * @return {function(function): Promise<void>}
+ */
+export const removeItems = (name, issues) => async (dispatch) => {
+  dispatch({type: REMOVE_ITEMS_START});
+
+  try {
+    const args = {parent: name, issues};
+    await prpcClient.call('monorail.v3.Hotlists', 'RemoveHotlistItems', args);
+
+    dispatch({type: REMOVE_ITEMS_SUCCESS});
+
+    await dispatch(fetchItems(name));
+  } catch (error) {
+    dispatch({type: REMOVE_ITEMS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to rerank the items in a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} items The resource names of the HotlistItems to move.
+ * @param {number} index The index to insert the moved items.
+ * @return {function(function): Promise<void>}
+ */
+export const rerankItems = (name, items, index) => async (dispatch) => {
+  dispatch({type: RERANK_ITEMS_START});
+
+  try {
+    const args = {name, hotlistItems: items, targetPosition: index};
+    await prpcClient.call('monorail.v3.Hotlists', 'RerankHotlistItems', args);
+
+    dispatch({type: RERANK_ITEMS_SUCCESS});
+
+    await dispatch(fetchItems(name));
+  } catch (error) {
+    dispatch({type: RERANK_ITEMS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to set the currently viewed Hotlist.
+ * @param {string} name The resource name of the Hotlist to select.
+ * @return {AnyAction}
+ */
+export const select = (name) => ({type: SELECT, name});
+
+/**
+ * Action creator to update the Hotlist metadata.
+ * @param {string} name The resource name of the Hotlist to delete.
+ * @param {Hotlist} hotlist This represents the updated version of the Hotlist
+ *    with only the fields that need to be updated.
+ * @return {function(function): Promise<void>}
+ */
+export const update = (name, hotlist) => async (dispatch) => {
+  dispatch({type: UPDATE_START});
+  try {
+    const paths = pathsToFieldMask(Object.keys(hotlist));
+    const hotlistArg = {...hotlist, name};
+    const args = {hotlist: hotlistArg, updateMask: paths};
+
+    /** @type {Hotlist} */
+    const updatedHotlist = await prpcClient.call(
+        'monorail.v3.Hotlists', 'UpdateHotlist', args);
+    dispatch({type: UPDATE_SUCCESS});
+    dispatch({type: RECEIVE_HOTLIST, hotlist: updatedHotlist});
+
+    const editors = updatedHotlist.editors.map((editor) => editor);
+    editors.push(updatedHotlist.owner);
+    await dispatch(users.batchGet(editors));
+  } catch (error) {
+    dispatch({type: UPDATE_FAILURE, error});
+    dispatch(ui.showSnackbar(UPDATE_FAILURE, error.description));
+    throw error;
+  }
+};
+
+// Helpers
+
+/**
+ * Helper to fetch a Hotlist ID given its owner and display name.
+ * @param {string} owner The Hotlist owner's user id or display name.
+ * @param {string} hotlist The Hotlist's id or display name.
+ * @return {Promise<?string>}
+ */
+export const getHotlistName = async (owner, hotlist) => {
+  const hotlistRef = {
+    owner: userIdOrDisplayNameToUserRef(owner),
+    name: hotlist,
+  };
+
+  try {
+    /** @type {{hotlistId: number}} */
+    const {hotlistId} = await prpcClient.call(
+        'monorail.Features', 'GetHotlistID', {hotlistRef});
+    return 'hotlists/' + hotlistId;
+  } catch (error) {
+    return null;
+  };
+};
+
+export const hotlists = {
+  // Permissions
+  EDIT,
+  ADMINISTER,
+
+  // Reducer
+  reducer,
+
+  // Selectors
+  name,
+  byName,
+  hotlistItems,
+  viewedHotlist,
+  viewedHotlistOwner,
+  viewedHotlistEditors,
+  viewedHotlistItems,
+  viewedHotlistIssues,
+  viewedHotlistColumns,
+  viewedHotlistPermissions,
+  requests,
+
+  // Action creators
+  deleteHotlist,
+  fetch,
+  fetchItems,
+  removeEditors,
+  removeItems,
+  rerankItems,
+  select,
+  update,
+
+  // Helpers
+  getHotlistName,
+};
diff --git a/static_src/reducers/hotlists.test.js b/static_src/reducers/hotlists.test.js
new file mode 100644
index 0000000..4aa42a2
--- /dev/null
+++ b/static_src/reducers/hotlists.test.js
@@ -0,0 +1,568 @@
+// 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 * as hotlists from './hotlists.js';
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('hotlist reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = hotlists.reducer(undefined, {type: null});
+    const expected = {
+      name: null,
+      byName: {},
+      hotlistItems: {},
+      requests: {
+        deleteHotlist: {error: null, requesting: false},
+        fetch: {error: null, requesting: false},
+        fetchItems: {error: null, requesting: false},
+        removeEditors: {error: null, requesting: false},
+        removeItems: {error: null, requesting: false},
+        rerankItems: {error: null, requesting: false},
+        update: {error: null, requesting: false},
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('name updates on SELECT', () => {
+    const action = {type: hotlists.SELECT, name: example.NAME};
+    const actual = hotlists.nameReducer(null, action);
+    assert.deepEqual(actual, example.NAME);
+  });
+
+  it('byName updates on RECEIVE_HOTLIST', () => {
+    const action = {type: hotlists.RECEIVE_HOTLIST, hotlist: example.HOTLIST};
+    const actual = hotlists.byNameReducer({}, action);
+    assert.deepEqual(actual, example.BY_NAME);
+  });
+
+  it('byName fills in missing fields on RECEIVE_HOTLIST', () => {
+    const action = {
+      type: hotlists.RECEIVE_HOTLIST,
+      hotlist: {name: example.NAME},
+    };
+    const actual = hotlists.byNameReducer({}, action);
+
+    const hotlist = {name: example.NAME, defaultColumns: [], editors: []};
+    assert.deepEqual(actual, {[example.NAME]: hotlist});
+  });
+
+  it('hotlistItems updates on FETCH_ITEMS_SUCCESS', () => {
+    const action = {
+      type: hotlists.FETCH_ITEMS_SUCCESS,
+      name: example.NAME,
+      items: [example.HOTLIST_ITEM],
+    };
+    const actual = hotlists.hotlistItemsReducer({}, action);
+    assert.deepEqual(actual, example.HOTLIST_ITEMS);
+  });
+});
+
+describe('hotlist selectors', () => {
+  it('name', () => {
+    const state = {hotlists: {name: example.NAME}};
+    assert.deepEqual(hotlists.name(state), example.NAME);
+  });
+
+  it('byName', () => {
+    const state = {hotlists: {byName: example.BY_NAME}};
+    assert.deepEqual(hotlists.byName(state), example.BY_NAME);
+  });
+
+  it('hotlistItems', () => {
+    const state = {hotlists: {hotlistItems: example.HOTLIST_ITEMS}};
+    assert.deepEqual(hotlists.hotlistItems(state), example.HOTLIST_ITEMS);
+  });
+
+  describe('viewedHotlist', () => {
+    it('normal case', () => {
+      const state = {hotlists: {name: example.NAME, byName: example.BY_NAME}};
+      assert.deepEqual(hotlists.viewedHotlist(state), example.HOTLIST);
+    });
+
+    it('no name', () => {
+      const state = {hotlists: {name: null, byName: example.BY_NAME}};
+      assert.deepEqual(hotlists.viewedHotlist(state), null);
+    });
+
+    it('hotlist not found', () => {
+      const state = {hotlists: {name: example.NAME, byName: {}}};
+      assert.deepEqual(hotlists.viewedHotlist(state), null);
+    });
+  });
+
+  describe('viewedHotlistOwner', () => {
+    it('normal case', () => {
+      const state = {
+        hotlists: {name: example.NAME, byName: example.BY_NAME},
+        users: {byName: exampleUsers.BY_NAME},
+      };
+      assert.deepEqual(hotlists.viewedHotlistOwner(state), exampleUsers.USER);
+    });
+
+    it('no hotlist', () => {
+      const state = {hotlists: {}, users: {}};
+      assert.deepEqual(hotlists.viewedHotlistOwner(state), null);
+    });
+  });
+
+  describe('viewedHotlistEditors', () => {
+    it('normal case', () => {
+      const state = {
+        hotlists: {
+          name: example.NAME,
+          byName: {[example.NAME]: {
+            ...example.HOTLIST,
+            editors: [exampleUsers.NAME, exampleUsers.NAME_2],
+          }},
+        },
+        users: {byName: exampleUsers.BY_NAME},
+      };
+
+      const editors = [exampleUsers.USER, exampleUsers.USER_2];
+      assert.deepEqual(hotlists.viewedHotlistEditors(state), editors);
+    });
+
+    it('no user data', () => {
+      const editors = [exampleUsers.NAME, exampleUsers.NAME_2];
+      const state = {
+        hotlists: {
+          name: example.NAME,
+          byName: {[example.NAME]: {...example.HOTLIST, editors}},
+        },
+        users: {byName: {}},
+      };
+      assert.deepEqual(hotlists.viewedHotlistEditors(state), [null, null]);
+    });
+
+    it('no hotlist', () => {
+      const state = {hotlists: {}, users: {}};
+      assert.deepEqual(hotlists.viewedHotlistEditors(state), null);
+    });
+  });
+
+  describe('viewedHotlistItems', () => {
+    it('normal case', () => {
+      const state = {hotlists: {
+        name: example.NAME,
+        hotlistItems: example.HOTLIST_ITEMS,
+      }};
+      const actual = hotlists.viewedHotlistItems(state);
+      assert.deepEqual(actual, [example.HOTLIST_ITEM]);
+    });
+
+    it('no name', () => {
+      const state = {hotlists: {
+        name: null,
+        hotlistItems: example.HOTLIST_ITEMS,
+      }};
+      assert.deepEqual(hotlists.viewedHotlistItems(state), []);
+    });
+
+    it('hotlist not found', () => {
+      const state = {hotlists: {name: example.NAME, hotlistItems: {}}};
+      assert.deepEqual(hotlists.viewedHotlistItems(state), []);
+    });
+  });
+
+  describe('viewedHotlistIssues', () => {
+    it('normal case', () => {
+      const state = {
+        hotlists: {
+          name: example.NAME,
+          hotlistItems: example.HOTLIST_ITEMS,
+        },
+        issue: {
+          issuesByRefString: {
+            [exampleIssues.ISSUE_REF_STRING]: exampleIssues.ISSUE,
+          },
+        },
+        users: {byName: {[exampleUsers.NAME]: exampleUsers.USER}},
+      };
+      const actual = hotlists.viewedHotlistIssues(state);
+      assert.deepEqual(actual, [example.HOTLIST_ISSUE]);
+    });
+
+    it('no issue', () => {
+      const state = {
+        hotlists: {
+          name: example.NAME,
+          hotlistItems: example.HOTLIST_ITEMS,
+        },
+        issue: {
+          issuesByRefString: {
+            [exampleIssues.ISSUE_OTHER_PROJECT_REF_STRING]: exampleIssues.ISSUE,
+          },
+        },
+        users: {byName: {}},
+      };
+      assert.deepEqual(hotlists.viewedHotlistIssues(state), []);
+    });
+  });
+
+  describe('viewedHotlistColumns', () => {
+    it('sitewide currentColumns overrides hotlist defaultColumns', () => {
+      const state = {
+        sitewide: {queryParams: {colspec: 'Summary+ColumnName'}},
+        hotlists: {},
+      };
+      const actual = hotlists.viewedHotlistColumns(state);
+      assert.deepEqual(actual, ['Summary', 'ColumnName']);
+    });
+
+    it('uses DEFAULT_COLUMNS when no hotlist', () => {
+      const actual = hotlists.viewedHotlistColumns({hotlists: {}});
+      assert.deepEqual(actual, hotlists.DEFAULT_COLUMNS);
+    });
+
+    it('uses DEFAULT_COLUMNS when hotlist has empty defaultColumns', () => {
+      const state = {hotlists: {
+        name: example.HOTLIST.name,
+        byName: {
+          [example.HOTLIST.name]: {...example.HOTLIST, defaultColumns: []},
+        },
+      }};
+      const actual = hotlists.viewedHotlistColumns(state);
+      assert.deepEqual(actual, hotlists.DEFAULT_COLUMNS);
+    });
+
+    it('uses hotlist defaultColumns', () => {
+      const state = {hotlists: {
+        name: example.HOTLIST.name,
+        byName: {[example.HOTLIST.name]: {
+          ...example.HOTLIST,
+          defaultColumns: [{column: 'ID'}, {column: 'ColumnName'}],
+        }},
+      }};
+      const actual = hotlists.viewedHotlistColumns(state);
+      assert.deepEqual(actual, ['ID', 'ColumnName']);
+    });
+  });
+
+  describe('viewedHotlistPermissions', () => {
+    it('normal case', () => {
+      const permissions = [hotlists.ADMINISTER, hotlists.EDIT];
+      const state = {
+        hotlists: {name: example.NAME, byName: example.BY_NAME},
+        permissions: {byName: {[example.NAME]: {permissions}}},
+      };
+      assert.deepEqual(hotlists.viewedHotlistPermissions(state), permissions);
+    });
+
+    it('no issue', () => {
+      const state = {hotlists: {}, permissions: {}};
+      assert.deepEqual(hotlists.viewedHotlistPermissions(state), []);
+    });
+  });
+});
+
+describe('hotlist action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  it('select', () => {
+    const actual = hotlists.select(example.NAME);
+    const expected = {type: hotlists.SELECT, name: example.NAME};
+    assert.deepEqual(actual, expected);
+  });
+
+  describe('deleteHotlist', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({}));
+
+      await hotlists.deleteHotlist(example.NAME)(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.DELETE_START});
+
+      const args = {name: example.NAME};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'DeleteHotlist', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.DELETE_SUCCESS});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.deleteHotlist(example.NAME)(dispatch);
+
+      const action = {
+        type: hotlists.DELETE_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('fetch', () => {
+    it('success', async () => {
+      const hotlist = example.HOTLIST;
+      prpcClient.call.returns(Promise.resolve(hotlist));
+
+      await hotlists.fetch(example.NAME)(dispatch);
+
+      const args = {name: example.NAME};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'GetHotlist', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_START});
+      sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_SUCCESS});
+      sinon.assert.calledWith(
+          dispatch, {type: hotlists.RECEIVE_HOTLIST, hotlist});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.fetch(example.NAME)(dispatch);
+
+      const action = {type: hotlists.FETCH_FAILURE, error: sinon.match.any};
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('fetchItems', () => {
+    it('success', async () => {
+      const response = {items: [example.HOTLIST_ITEM]};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      const returnValue = await hotlists.fetchItems(example.NAME)(dispatch);
+      assert.deepEqual(returnValue, [{...example.HOTLIST_ITEM, rank: 0}]);
+
+      const args = {parent: example.NAME, orderBy: 'rank'};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'ListHotlistItems', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_ITEMS_START});
+      const action = {
+        type: hotlists.FETCH_ITEMS_SUCCESS,
+        name: example.NAME,
+        items: [{...example.HOTLIST_ITEM, rank: 0}],
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.fetchItems(example.NAME)(dispatch);
+
+      const action = {
+        type: hotlists.FETCH_ITEMS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('success with empty hotlist', async () => {
+      const response = {items: []};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      const returnValue = await hotlists.fetchItems(example.NAME)(dispatch);
+      assert.deepEqual(returnValue, []);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_ITEMS_START});
+
+      const args = {parent: example.NAME, orderBy: 'rank'};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'ListHotlistItems', args);
+
+      const action = {
+        type: hotlists.FETCH_ITEMS_SUCCESS,
+        name: example.NAME,
+        items: [],
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('removeEditors', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({}));
+
+      const editors = [exampleUsers.NAME];
+      await hotlists.removeEditors(example.NAME, editors)(dispatch);
+
+      const args = {name: example.NAME, editors};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists',
+          'RemoveHotlistEditors', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_EDITORS_START});
+      const action = {type: hotlists.REMOVE_EDITORS_SUCCESS};
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.removeEditors(example.NAME, [])(dispatch);
+
+      const action = {
+        type: hotlists.REMOVE_EDITORS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('removeItems', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({}));
+
+      const issues = [exampleIssues.NAME];
+      await hotlists.removeItems(example.NAME, issues)(dispatch);
+
+      const args = {parent: example.NAME, issues};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists',
+          'RemoveHotlistItems', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_ITEMS_START});
+      sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_ITEMS_SUCCESS});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.removeItems(example.NAME, [])(dispatch);
+
+      const action = {
+        type: hotlists.REMOVE_ITEMS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('rerankItems', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({}));
+
+      const items = [example.HOTLIST_ITEM_NAME];
+      await hotlists.rerankItems(example.NAME, items, 0)(dispatch);
+
+      const args = {
+        name: example.NAME,
+        hotlistItems: items,
+        targetPosition: 0,
+      };
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists',
+          'RerankHotlistItems', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.RERANK_ITEMS_START});
+      sinon.assert.calledWith(dispatch, {type: hotlists.RERANK_ITEMS_SUCCESS});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.rerankItems(example.NAME, [], 0)(dispatch);
+
+      const action = {
+        type: hotlists.RERANK_ITEMS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('update', () => {
+    it('success', async () => {
+      const hotlistOnlyWithUpdates = {
+        displayName: example.HOTLIST.displayName + 'foo',
+        summary: example.HOTLIST.summary + 'abc',
+      };
+      const hotlist = {...example.HOTLIST, ...hotlistOnlyWithUpdates};
+      prpcClient.call.returns(Promise.resolve(hotlist));
+
+      await hotlists.update(
+          example.HOTLIST.name, hotlistOnlyWithUpdates)(dispatch);
+
+      const hotlistArg = {
+        ...hotlistOnlyWithUpdates,
+        name: example.HOTLIST.name,
+      };
+      const fieldMask = 'displayName,summary';
+      const args = {hotlist: hotlistArg, updateMask: fieldMask};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'UpdateHotlist', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.UPDATE_START});
+      sinon.assert.calledWith(dispatch, {type: hotlists.UPDATE_SUCCESS});
+      sinon.assert.calledWith(
+          dispatch, {type: hotlists.RECEIVE_HOTLIST, hotlist});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+      const hotlistOnlyWithUpdates = {
+        displayName: example.HOTLIST.displayName + 'foo',
+        summary: example.HOTLIST.summary + 'abc',
+      };
+      try {
+        // TODO(crbug.com/monorail/7883): Use Chai Promises plugin
+        // to assert promise rejected.
+        await hotlists.update(
+            example.HOTLIST.name, hotlistOnlyWithUpdates)(dispatch);
+      } catch (e) {}
+
+      const action = {
+        type: hotlists.UPDATE_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
+
+describe('helpers', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('getHotlistName', () => {
+    it('success', async () => {
+      const response = {hotlistId: '1234'};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      const name = await hotlists.getHotlistName('foo@bar.com', 'hotlist');
+      assert.deepEqual(name, 'hotlists/1234');
+
+      const args = {hotlistRef: {
+        owner: {displayName: 'foo@bar.com'},
+        name: 'hotlist',
+      }};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.Features', 'GetHotlistID', args);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      assert.isNull(await hotlists.getHotlistName('foo@bar.com', 'hotlist'));
+    });
+  });
+});
diff --git a/static_src/reducers/issueV0.js b/static_src/reducers/issueV0.js
new file mode 100644
index 0000000..36c446d
--- /dev/null
+++ b/static_src/reducers/issueV0.js
@@ -0,0 +1,1411 @@
+// 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.
+
+/**
+ * @fileoverview Issue actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving issue state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {autolink} from 'autolink.js';
+import {fieldTypes, extractTypeForIssue,
+  fieldValuesToMap} from 'shared/issue-fields.js';
+import {removePrefix, objectToMap} from 'shared/helpers.js';
+import {issueRefToString, issueToIssueRefString,
+  issueStringToRef, issueNameToRefString} from 'shared/convertersV0.js';
+import {fromShortlink} from 'shared/federated.js';
+import {createReducer, createRequestReducer,
+  createKeyedRequestReducer} from './redux-helpers.js';
+import * as projectV0 from './projectV0.js';
+import * as userV0 from './userV0.js';
+import {fieldValueMapKey} from 'shared/metadata-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import loadGapi, {fetchGapiEmail} from 'shared/gapi-loader.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const VIEW_ISSUE = 'VIEW_ISSUE';
+
+export const FETCH_START = 'issueV0/FETCH_START';
+export const FETCH_SUCCESS = 'issueV0/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'issueV0/FETCH_FAILURE';
+
+export const FETCH_ISSUES_START = 'issueV0/FETCH_ISSUES_START';
+export const FETCH_ISSUES_SUCCESS = 'issueV0/FETCH_ISSUES_SUCCESS';
+export const FETCH_ISSUES_FAILURE = 'issueV0/FETCH_ISSUES_FAILURE';
+
+const FETCH_HOTLISTS_START = 'FETCH_HOTLISTS_START';
+export const FETCH_HOTLISTS_SUCCESS = 'FETCH_HOTLISTS_SUCCESS';
+const FETCH_HOTLISTS_FAILURE = 'FETCH_HOTLISTS_FAILURE';
+
+const FETCH_ISSUE_LIST_START = 'FETCH_ISSUE_LIST_START';
+export const FETCH_ISSUE_LIST_UPDATE = 'FETCH_ISSUE_LIST_UPDATE';
+const FETCH_ISSUE_LIST_SUCCESS = 'FETCH_ISSUE_LIST_SUCCESS';
+const FETCH_ISSUE_LIST_FAILURE = 'FETCH_ISSUE_LIST_FAILURE';
+
+const FETCH_PERMISSIONS_START = 'FETCH_PERMISSIONS_START';
+const FETCH_PERMISSIONS_SUCCESS = 'FETCH_PERMISSIONS_SUCCESS';
+const FETCH_PERMISSIONS_FAILURE = 'FETCH_PERMISSIONS_FAILURE';
+
+export const STAR_START = 'STAR_START';
+export const STAR_SUCCESS = 'STAR_SUCCESS';
+const STAR_FAILURE = 'STAR_FAILURE';
+
+const PRESUBMIT_START = 'PRESUBMIT_START';
+const PRESUBMIT_SUCCESS = 'PRESUBMIT_SUCCESS';
+const PRESUBMIT_FAILURE = 'PRESUBMIT_FAILURE';
+
+export const FETCH_IS_STARRED_START = 'FETCH_IS_STARRED_START';
+export const FETCH_IS_STARRED_SUCCESS = 'FETCH_IS_STARRED_SUCCESS';
+const FETCH_IS_STARRED_FAILURE = 'FETCH_IS_STARRED_FAILURE';
+
+const FETCH_ISSUES_STARRED_START = 'FETCH_ISSUES_STARRED_START';
+export const FETCH_ISSUES_STARRED_SUCCESS = 'FETCH_ISSUES_STARRED_SUCCESS';
+const FETCH_ISSUES_STARRED_FAILURE = 'FETCH_ISSUES_STARRED_FAILURE';
+
+const FETCH_COMMENTS_START = 'FETCH_COMMENTS_START';
+export const FETCH_COMMENTS_SUCCESS = 'FETCH_COMMENTS_SUCCESS';
+const FETCH_COMMENTS_FAILURE = 'FETCH_COMMENTS_FAILURE';
+
+const FETCH_COMMENT_REFERENCES_START = 'FETCH_COMMENT_REFERENCES_START';
+const FETCH_COMMENT_REFERENCES_SUCCESS = 'FETCH_COMMENT_REFERENCES_SUCCESS';
+const FETCH_COMMENT_REFERENCES_FAILURE = 'FETCH_COMMENT_REFERENCES_FAILURE';
+
+const FETCH_REFERENCED_USERS_START = 'FETCH_REFERENCED_USERS_START';
+const FETCH_REFERENCED_USERS_SUCCESS = 'FETCH_REFERENCED_USERS_SUCCESS';
+const FETCH_REFERENCED_USERS_FAILURE = 'FETCH_REFERENCED_USERS_FAILURE';
+
+const FETCH_RELATED_ISSUES_START = 'FETCH_RELATED_ISSUES_START';
+const FETCH_RELATED_ISSUES_SUCCESS = 'FETCH_RELATED_ISSUES_SUCCESS';
+const FETCH_RELATED_ISSUES_FAILURE = 'FETCH_RELATED_ISSUES_FAILURE';
+
+const FETCH_FEDERATED_REFERENCES_START = 'FETCH_FEDERATED_REFERENCES_START';
+const FETCH_FEDERATED_REFERENCES_SUCCESS = 'FETCH_FEDERATED_REFERENCES_SUCCESS';
+const FETCH_FEDERATED_REFERENCES_FAILURE = 'FETCH_FEDERATED_REFERENCES_FAILURE';
+
+const CONVERT_START = 'CONVERT_START';
+const CONVERT_SUCCESS = 'CONVERT_SUCCESS';
+const CONVERT_FAILURE = 'CONVERT_FAILURE';
+
+const UPDATE_START = 'UPDATE_START';
+const UPDATE_SUCCESS = 'UPDATE_SUCCESS';
+const UPDATE_FAILURE = 'UPDATE_FAILURE';
+
+const UPDATE_APPROVAL_START = 'UPDATE_APPROVAL_START';
+const UPDATE_APPROVAL_SUCCESS = 'UPDATE_APPROVAL_SUCCESS';
+const UPDATE_APPROVAL_FAILURE = 'UPDATE_APPROVAL_FAILURE';
+
+/* State Shape
+{
+  issuesByRefString: Object<IssueRefString, Issue>,
+
+  viewedIssueRef: IssueRefString,
+
+  hotlists: Array<HotlistV0>,
+  issueList: {
+    issueRefs: Array<IssueRefString>,
+    progress: number,
+    totalResults: number,
+  }
+  comments: Array<IssueComment>,
+  commentReferences: Object,
+  relatedIssues: Object,
+  referencedUsers: Array<UserV0>,
+  starredIssues: Object<IssueRefString, Boolean>,
+  permissions: Array<string>,
+  presubmitResponse: Object,
+
+  requests: {
+    fetch: ReduxRequestState,
+    fetchHotlists: ReduxRequestState,
+    fetchIssueList: ReduxRequestState,
+    fetchPermissions: ReduxRequestState,
+    starringIssues: Object<string, ReduxRequestState>,
+    presubmit: ReduxRequestState,
+    fetchComments: ReduxRequestState,
+    fetchCommentReferences: ReduxRequestState,
+    fetchFederatedReferences: ReduxRequestState,
+    fetchRelatedIssues: ReduxRequestState,
+    fetchStarredIssues: ReduxRequestState,
+    fetchStarredIssues: ReduxRequestState,
+    convert: ReduxRequestState,
+    update: ReduxRequestState,
+    updateApproval: ReduxRequestState,
+  },
+}
+*/
+
+// Helpers for the reducers.
+
+/**
+ * Overrides local data for single approval on an Issue object with fresh data.
+ * Note that while an Issue can have multiple approvals, this function only
+ * refreshes data for a single approval.
+ * @param {Issue} issue Issue Object being updated.
+ * @param {ApprovalDef} approval A single approval to override in the issue.
+ * @return {Issue} Issue with updated approval data.
+ */
+const updateApprovalValues = (issue, approval) => {
+  if (!issue.approvalValues) return issue;
+  const newApprovals = issue.approvalValues.map((item) => {
+    if (item.fieldRef.fieldName === approval.fieldRef.fieldName) {
+      // PhaseRef isn't populated on the response so we want to make sure
+      // it doesn't overwrite the original phaseRef with {}.
+      return {...approval, phaseRef: item.phaseRef};
+    }
+    return item;
+  });
+  return {...issue, approvalValues: newApprovals};
+};
+
+// Reducers
+
+/**
+ * Creates a new issuesByRefString Object with a single issue's data
+ * edited.
+ * @param {Object<IssueRefString, Issue>} issuesByRefString
+ * @param {Issue} issue The new issue data to add to the state.
+ * @return {Object<IssueRefString, Issue>}
+ */
+const updateSingleIssueInState = (issuesByRefString, issue) => {
+  return {
+    ...issuesByRefString,
+    [issueToIssueRefString(issue)]: issue,
+  };
+};
+
+// TODO(crbug.com/monorail/6882): Finish converting all other issue
+//   actions to use this format.
+/**
+ * Adds issues fetched by a ListIssues request to the Redux store in a
+ * normalized format.
+ * @param {Object<IssueRefString, Issue>} state Redux state.
+ * @param {AnyAction} action
+ * @param {Array<Issue>} action.issues The list of issues that was fetched.
+ * @param {Issue=} action.issue The issue being updated.
+ * @param {number=} action.starCount Number of stars the issue has. This changes
+ *   when a user stars an issue and needs to be updated.
+ * @param {ApprovalDef=} action.approval A new approval to update the issue
+ *   with.
+ * @param {IssueRef=} action.issueRef A specific IssueRef to update.
+ */
+export const issuesByRefStringReducer = createReducer({}, {
+  [FETCH_ISSUE_LIST_UPDATE]: (state, {issues}) => {
+    const newState = {...state};
+
+    issues.forEach((issue) => {
+      const refString = issueToIssueRefString(issue);
+      newState[refString] = {...newState[refString], ...issue};
+    });
+
+    return newState;
+  },
+  [FETCH_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+  [FETCH_ISSUES_SUCCESS]: (state, {issues}) => {
+    const newState = {...state};
+
+    issues.forEach((issue) => {
+      const refString = issueToIssueRefString(issue);
+      newState[refString] = {...newState[refString], ...issue};
+    });
+
+    return newState;
+  },
+  [CONVERT_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+  [UPDATE_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+  [UPDATE_APPROVAL_SUCCESS]: (state, {issueRef, approval}) => {
+    const issueRefString = issueToIssueRefString(issueRef);
+    const originalIssue = state[issueRefString] || {};
+    const newIssue = updateApprovalValues(originalIssue, approval);
+    return {
+      ...state,
+      [issueRefString]: {
+        ...newIssue,
+      },
+    };
+  },
+  [STAR_SUCCESS]: (state, {issueRef, starCount}) => {
+    const issueRefString = issueToIssueRefString(issueRef);
+    const originalIssue = state[issueRefString] || {};
+    return {
+      ...state,
+      [issueRefString]: {
+        ...originalIssue,
+        starCount,
+      },
+    };
+  },
+});
+
+/**
+ * Sets a reference for the issue that the user is currently viewing.
+ * @param {IssueRefString} state Currently viewed issue.
+ * @param {AnyAction} action
+ * @param {IssueRef} action.issueRef The updated localId to view.
+ * @return {IssueRefString}
+ */
+const viewedIssueRefReducer = createReducer('', {
+  [VIEW_ISSUE]: (state, {issueRef}) => issueRefToString(issueRef) || state,
+});
+
+/**
+ * Reducer to manage updating the list of hotlists attached to an Issue.
+ * @param {Array<HotlistV0>} state List of issue hotlists.
+ * @param {AnyAction} action
+ * @param {Array<HotlistV0>} action.hotlists New list of hotlists.
+ * @return {Array<HotlistV0>}
+ */
+const hotlistsReducer = createReducer([], {
+  [FETCH_HOTLISTS_SUCCESS]: (_, {hotlists}) => hotlists,
+});
+
+/**
+ * @typedef {Object} IssueListState
+ * @property {Array<IssueRefString>} issues The list of issues being viewed,
+ *   in a normalized form.
+ * @property {number} progress The percentage of issues loaded. Used for
+ *   incremental loading of issues in the grid view.
+ * @property {number} totalResults The total number of issues matching the
+ *   query.
+ */
+
+/**
+ * Handles the state of the currently viewed issue list. This reducer
+ * stores this data in normalized form.
+ * @param {IssueListState} state
+ * @param {AnyAction} action
+ * @param {Array<Issue>} action.issues Issues that were fetched.
+ * @param {number} state.progress New percentage of issues have been loaded.
+ * @param {number} state.totalResults The total number of issues matching the
+ *   query.
+ * @return {IssueListState}
+ */
+export const issueListReducer = createReducer({}, {
+  [FETCH_ISSUE_LIST_UPDATE]: (_state, {issues, progress, totalResults}) => ({
+    issueRefs: issues.map(issueToIssueRefString), progress, totalResults,
+  }),
+});
+
+/**
+ * Updates the comments attached to the currently viewed issue.
+ * @param {Array<IssueComment>} state The list of comments in an issue.
+ * @param {AnyAction} action
+ * @param {Array<IssueComment>} action.comments Fetched comments.
+ * @return {Array<IssueComment>}
+ */
+const commentsReducer = createReducer([], {
+  [FETCH_COMMENTS_SUCCESS]: (_state, {comments}) => comments,
+});
+
+// TODO(crbug.com/monorail/5953): Come up with some way to refactor
+// autolink.js's reference code to allow avoiding duplicate lookups
+// with data already in Redux state.
+/**
+ * For autolinking, this reducer stores the dereferenced data for bits
+ * of data that were referenced in comments. For example, comments might
+ * include user emails or IDs for other issues, and this state slice would
+ * store the full Objects for that data.
+ * @param {Array<CommentReference>} state
+ * @param {AnyAction} action
+ * @param {Array<CommentReference>} action.commentReferences New references
+ *   to store.
+ * @return {Array<CommentReference>}
+ */
+const commentReferencesReducer = createReducer({}, {
+  [FETCH_COMMENTS_START]: (_state, _action) => ({}),
+  [FETCH_COMMENT_REFERENCES_SUCCESS]: (_state, {commentReferences}) => {
+    return commentReferences;
+  },
+});
+
+/**
+ * Handles state for related issues such as blocking and blocked on issues,
+ * including federated references that could reference external issues outside
+ * Monorail.
+ * @param {Object<IssueRefString, Issue>} state
+ * @param {AnyAction} action
+ * @param {Object<IssueRefString, Issue>=} action.relatedIssues New related
+ *   issues.
+ * @param {Array<IssueRef>=} action.fedRefIssueRefs List of fetched federated
+ *   issue references.
+ * @return {Object<IssueRefString, Issue>}
+ */
+export const relatedIssuesReducer = createReducer({}, {
+  [FETCH_RELATED_ISSUES_SUCCESS]: (_state, {relatedIssues}) => relatedIssues,
+  [FETCH_FEDERATED_REFERENCES_SUCCESS]: (state, {fedRefIssueRefs}) => {
+    if (!fedRefIssueRefs) {
+      return state;
+    }
+
+    const fedRefStates = {};
+    fedRefIssueRefs.forEach((ref) => {
+      fedRefStates[ref.extIdentifier] = ref;
+    });
+
+    // Return a new object, in Redux fashion.
+    return Object.assign(fedRefStates, state);
+  },
+});
+
+/**
+ * Stores data for users referenced by issue. ie: Owner, CC, etc.
+ * @param {Object<string, UserV0>} state
+ * @param {AnyAction} action
+ * @param {Object<string, UserV0>} action.referencedUsers
+ * @return {Object<string, UserV0>}
+ */
+const referencedUsersReducer = createReducer({}, {
+  [FETCH_REFERENCED_USERS_SUCCESS]: (_state, {referencedUsers}) =>
+    referencedUsers,
+});
+
+/**
+ * Handles updating state of all starred issues.
+ * @param {Object<IssueRefString, boolean>} state Set of starred issues,
+ *   stored in a serializeable Object form.
+ * @param {AnyAction} action
+ * @param {IssueRef=} action.issueRef An issue with a star state being updated.
+ * @param {boolean=} action.starred Whether the issue is starred or unstarred.
+ * @param {Array<IssueRef>=} action.starredIssueRefs A list of starred issues.
+ * @return {Object<IssueRefString, boolean>}
+ */
+export const starredIssuesReducer = createReducer({}, {
+  [STAR_SUCCESS]: (state, {issueRef, starred}) => {
+    return {...state, [issueRefToString(issueRef)]: starred};
+  },
+  [FETCH_ISSUES_STARRED_SUCCESS]: (_state, {starredIssueRefs}) => {
+    const normalizedStars = {};
+    starredIssueRefs.forEach((issueRef) => {
+      normalizedStars[issueRefToString(issueRef)] = true;
+    });
+    return normalizedStars;
+  },
+  [FETCH_IS_STARRED_SUCCESS]: (state, {issueRef, starred}) => {
+    const refString = issueRefToString(issueRef);
+    return {...state, [refString]: starred};
+  },
+});
+
+/**
+ * Adds the result of an IssuePresubmit response to the Redux store.
+ * @param {Object} state Initial Redux state.
+ * @param {AnyAction} action
+ * @param {Object} action.presubmitResponse The issue
+ *   presubmit response Object.
+ * @return {Object}
+ */
+const presubmitResponseReducer = createReducer({}, {
+  [PRESUBMIT_SUCCESS]: (_state, {presubmitResponse}) => presubmitResponse,
+});
+
+/**
+ * Stores the user's permissions for a given issue.
+ * @param {Array<string>} state Permission list. Each permission is a string
+ *   with the name of the permission.
+ * @param {AnyAction} action
+ * @param {Array<string>} action.permissions The fetched permission data.
+ * @return {Array<string>}
+ */
+const permissionsReducer = createReducer([], {
+  [FETCH_PERMISSIONS_SUCCESS]: (_state, {permissions}) => permissions,
+});
+
+const requestsReducer = combineReducers({
+  fetch: createRequestReducer(
+      FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+  fetchIssues: createRequestReducer(
+      FETCH_ISSUES_START, FETCH_ISSUES_SUCCESS, FETCH_ISSUES_FAILURE),
+  fetchHotlists: createRequestReducer(
+      FETCH_HOTLISTS_START, FETCH_HOTLISTS_SUCCESS, FETCH_HOTLISTS_FAILURE),
+  fetchIssueList: createRequestReducer(
+      FETCH_ISSUE_LIST_START,
+      FETCH_ISSUE_LIST_SUCCESS,
+      FETCH_ISSUE_LIST_FAILURE),
+  fetchPermissions: createRequestReducer(
+      FETCH_PERMISSIONS_START,
+      FETCH_PERMISSIONS_SUCCESS,
+      FETCH_PERMISSIONS_FAILURE),
+  starringIssues: createKeyedRequestReducer(
+      STAR_START, STAR_SUCCESS, STAR_FAILURE),
+  presubmit: createRequestReducer(
+      PRESUBMIT_START, PRESUBMIT_SUCCESS, PRESUBMIT_FAILURE),
+  fetchComments: createRequestReducer(
+      FETCH_COMMENTS_START, FETCH_COMMENTS_SUCCESS, FETCH_COMMENTS_FAILURE),
+  fetchCommentReferences: createRequestReducer(
+      FETCH_COMMENT_REFERENCES_START,
+      FETCH_COMMENT_REFERENCES_SUCCESS,
+      FETCH_COMMENT_REFERENCES_FAILURE),
+  fetchFederatedReferences: createRequestReducer(
+      FETCH_FEDERATED_REFERENCES_START,
+      FETCH_FEDERATED_REFERENCES_SUCCESS,
+      FETCH_FEDERATED_REFERENCES_FAILURE),
+  fetchRelatedIssues: createRequestReducer(
+      FETCH_RELATED_ISSUES_START,
+      FETCH_RELATED_ISSUES_SUCCESS,
+      FETCH_RELATED_ISSUES_FAILURE),
+  fetchReferencedUsers: createRequestReducer(
+      FETCH_REFERENCED_USERS_START,
+      FETCH_REFERENCED_USERS_SUCCESS,
+      FETCH_REFERENCED_USERS_FAILURE),
+  fetchIsStarred: createRequestReducer(
+      FETCH_IS_STARRED_START, FETCH_IS_STARRED_SUCCESS,
+      FETCH_IS_STARRED_FAILURE),
+  fetchStarredIssues: createRequestReducer(
+      FETCH_ISSUES_STARRED_START, FETCH_ISSUES_STARRED_SUCCESS,
+      FETCH_ISSUES_STARRED_FAILURE,
+  ),
+  convert: createRequestReducer(
+      CONVERT_START, CONVERT_SUCCESS, CONVERT_FAILURE),
+  update: createRequestReducer(
+      UPDATE_START, UPDATE_SUCCESS, UPDATE_FAILURE),
+  // TODO(zhangtiff): Update this to use createKeyedRequestReducer() instead, so
+  // users can update multiple approvals at once.
+  updateApproval: createRequestReducer(
+      UPDATE_APPROVAL_START, UPDATE_APPROVAL_SUCCESS, UPDATE_APPROVAL_FAILURE),
+});
+
+export const reducer = combineReducers({
+  viewedIssueRef: viewedIssueRefReducer,
+
+  issuesByRefString: issuesByRefStringReducer,
+
+  hotlists: hotlistsReducer,
+  issueList: issueListReducer,
+  comments: commentsReducer,
+  commentReferences: commentReferencesReducer,
+  relatedIssues: relatedIssuesReducer,
+  referencedUsers: referencedUsersReducer,
+  starredIssues: starredIssuesReducer,
+  permissions: permissionsReducer,
+  presubmitResponse: presubmitResponseReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+const RESTRICT_VIEW_PREFIX = 'restrict-view-';
+const RESTRICT_EDIT_PREFIX = 'restrict-editissue-';
+const RESTRICT_COMMENT_PREFIX = 'restrict-addissuecomment-';
+
+/**
+ * Selector to retrieve all normalized Issue data in the Redux store,
+ * keyed by IssueRefString.
+ * @param {any} state
+ * @return {Object<IssueRefString, Issue>}
+ */
+const issuesByRefString = (state) => state.issue.issuesByRefString;
+
+/**
+ * Selector to return a function to retrieve an Issue from the Redux store.
+ * @param {any} state
+ * @return {function(string): ?Issue}
+ */
+export const issue = createSelector(issuesByRefString, (issuesByRefString) =>
+  (name) => issuesByRefString[issueNameToRefString(name)]);
+
+/**
+ * Selector to return a function to retrieve a given Issue Object from
+ * the Redux store.
+ * @param {any} state
+ * @return {function(IssueRefString, string): Issue}
+ */
+export const issueForRefString = createSelector(issuesByRefString,
+    (issuesByRefString) => (issueRefString, projectName = undefined) => {
+      // In some contexts, an issue ref string will omit a project name,
+      // assuming the default project to be the project name. We never
+      // omit the project name in strings used as keys, so we have to
+      // make sure issue ref strings contain the project name.
+      const ref = issueStringToRef(issueRefString, projectName);
+      const refString = issueRefToString(ref);
+      if (issuesByRefString.hasOwnProperty(refString)) {
+        return issuesByRefString[refString];
+      }
+      return issueStringToRef(refString, projectName);
+    });
+
+/**
+ * Selector to get a reference to the currently viewed issue, in string form.
+ * @param {any} state
+ * @return {IssueRefString}
+ */
+const viewedIssueRefString = (state) => state.issue.viewedIssueRef;
+
+/**
+ * Selector to get a reference to the currently viewed issue.
+ * @param {any} state
+ * @return {IssueRef}
+ */
+export const viewedIssueRef = createSelector(viewedIssueRefString,
+    (viewedIssueRefString) => issueStringToRef(viewedIssueRefString));
+
+/**
+ * Selector to get the full Issue data for the currently viewed issue.
+ * @param {any} state
+ * @return {Issue}
+ */
+export const viewedIssue = createSelector(issuesByRefString,
+    viewedIssueRefString,
+    (issuesByRefString, viewedIssueRefString) =>
+      issuesByRefString[viewedIssueRefString] || {});
+
+export const comments = (state) => state.issue.comments;
+export const commentsLoaded = (state) => state.issue.commentsLoaded;
+
+const _commentReferences = (state) => state.issue.commentReferences;
+export const commentReferences = createSelector(_commentReferences,
+    (commentReferences) => objectToMap(commentReferences));
+
+export const hotlists = (state) => state.issue.hotlists;
+
+const stateIssueList = (state) => state.issue.issueList;
+export const issueList = createSelector(
+    issuesByRefString,
+    stateIssueList,
+    (issuesByRefString, stateIssueList) => {
+      return (stateIssueList.issueRefs || []).map((issueRef) => {
+        return issuesByRefString[issueRef];
+      });
+    },
+);
+export const totalIssues = (state) => state.issue.issueList.totalResults;
+export const issueListProgress = (state) => state.issue.issueList.progress;
+export const issueListPhaseNames = createSelector(issueList, (issueList) => {
+  const phaseNamesSet = new Set();
+  if (issueList) {
+    issueList.forEach(({phases}) => {
+      if (phases) {
+        phases.forEach(({phaseRef: {phaseName}}) => {
+          phaseNamesSet.add(phaseName.toLowerCase());
+        });
+      }
+    });
+  }
+  return Array.from(phaseNamesSet);
+});
+
+/**
+ * @param {any} state
+ * @return {boolean} Whether the currently viewed issue list
+ *   has loaded.
+ */
+export const issueListLoaded = createSelector(
+    stateIssueList,
+    (stateIssueList) => stateIssueList.issueRefs !== undefined);
+
+export const permissions = (state) => state.issue.permissions;
+export const presubmitResponse = (state) => state.issue.presubmitResponse;
+
+const _relatedIssues = (state) => state.issue.relatedIssues || {};
+export const relatedIssues = createSelector(_relatedIssues,
+    (relatedIssues) => objectToMap(relatedIssues));
+
+const _referencedUsers = (state) => state.issue.referencedUsers || {};
+export const referencedUsers = createSelector(_referencedUsers,
+    (referencedUsers) => objectToMap(referencedUsers));
+
+export const isStarred = (state) => state.issue.isStarred;
+export const _starredIssues = (state) => state.issue.starredIssues;
+
+export const requests = (state) => state.issue.requests;
+
+// Returns a Map of in flight StarIssues requests, keyed by issueRef.
+export const starringIssues = createSelector(requests, (requests) =>
+  objectToMap(requests.starringIssues));
+
+export const starredIssues = createSelector(
+    _starredIssues,
+    (starredIssues) => {
+      const stars = new Set();
+      for (const [ref, starred] of Object.entries(starredIssues)) {
+        if (starred) stars.add(ref);
+      }
+      return stars;
+    },
+);
+
+// TODO(zhangtiff): Split up either comments or approvals into their own "duck".
+export const commentsByApprovalName = createSelector(
+    comments,
+    (comments) => {
+      const map = new Map();
+      comments.forEach((comment) => {
+        const key = (comment.approvalRef && comment.approvalRef.fieldName) ||
+          '';
+        if (map.has(key)) {
+          map.get(key).push(comment);
+        } else {
+          map.set(key, [comment]);
+        }
+      });
+      return map;
+    },
+);
+
+export const fieldValues = createSelector(
+    viewedIssue,
+    (issue) => issue && issue.fieldValues,
+);
+
+export const labelRefs = createSelector(
+    viewedIssue,
+    (issue) => issue && issue.labelRefs,
+);
+
+export const type = createSelector(
+    fieldValues,
+    labelRefs,
+    (fieldValues, labelRefs) => extractTypeForIssue(fieldValues, labelRefs),
+);
+
+export const restrictions = createSelector(
+    labelRefs,
+    (labelRefs) => {
+      if (!labelRefs) return {};
+
+      const restrictions = {};
+
+      labelRefs.forEach((labelRef) => {
+        const label = labelRef.label;
+        const lowerCaseLabel = label.toLowerCase();
+
+        if (lowerCaseLabel.startsWith(RESTRICT_VIEW_PREFIX)) {
+          const permissionType = removePrefix(label, RESTRICT_VIEW_PREFIX);
+          if (!('view' in restrictions)) {
+            restrictions['view'] = [permissionType];
+          } else {
+            restrictions['view'].push(permissionType);
+          }
+        } else if (lowerCaseLabel.startsWith(RESTRICT_EDIT_PREFIX)) {
+          const permissionType = removePrefix(label, RESTRICT_EDIT_PREFIX);
+          if (!('edit' in restrictions)) {
+            restrictions['edit'] = [permissionType];
+          } else {
+            restrictions['edit'].push(permissionType);
+          }
+        } else if (lowerCaseLabel.startsWith(RESTRICT_COMMENT_PREFIX)) {
+          const permissionType = removePrefix(label, RESTRICT_COMMENT_PREFIX);
+          if (!('comment' in restrictions)) {
+            restrictions['comment'] = [permissionType];
+          } else {
+            restrictions['comment'].push(permissionType);
+          }
+        }
+      });
+
+      return restrictions;
+    },
+);
+
+export const isOpen = createSelector(
+    viewedIssue,
+    (issue) => issue && issue.statusRef && issue.statusRef.meansOpen || false);
+
+// Returns a function that, given an issue and its related issues,
+// returns a combined list of issue ref strings including related issues,
+// blocking or blocked on issues, and federated references.
+const mapRefsWithRelated = (blocking) => {
+  return (issue, relatedIssues) => {
+    let refs = [];
+    if (blocking) {
+      if (issue.blockingIssueRefs) {
+        refs = refs.concat(issue.blockingIssueRefs);
+      }
+      if (issue.danglingBlockingRefs) {
+        refs = refs.concat(issue.danglingBlockingRefs);
+      }
+    } else {
+      if (issue.blockedOnIssueRefs) {
+        refs = refs.concat(issue.blockedOnIssueRefs);
+      }
+      if (issue.danglingBlockedOnRefs) {
+        refs = refs.concat(issue.danglingBlockedOnRefs);
+      }
+    }
+
+    // Note: relatedIssues is a Redux generated key for issues, not part of the
+    // pRPC Issue object.
+    if (issue.relatedIssues) {
+      refs = refs.concat(issue.relatedIssues);
+    }
+    return refs.map((ref) => {
+      const key = issueRefToString(ref);
+      if (relatedIssues.has(key)) {
+        return relatedIssues.get(key);
+      }
+      return ref;
+    });
+  };
+};
+
+export const blockingIssues = createSelector(
+    viewedIssue, relatedIssues,
+    mapRefsWithRelated(true),
+);
+
+export const blockedOnIssues = createSelector(
+    viewedIssue, relatedIssues,
+    mapRefsWithRelated(false),
+);
+
+export const mergedInto = createSelector(
+    viewedIssue, relatedIssues,
+    (issue, relatedIssues) => {
+      if (!issue || !issue.mergedIntoIssueRef) return {};
+      const key = issueRefToString(issue.mergedIntoIssueRef);
+      if (relatedIssues && relatedIssues.has(key)) {
+        return relatedIssues.get(key);
+      }
+      return issue.mergedIntoIssueRef;
+    },
+);
+
+export const sortedBlockedOn = createSelector(
+    blockedOnIssues,
+    (blockedOn) => blockedOn.sort((a, b) => {
+      const aIsOpen = a.statusRef && a.statusRef.meansOpen ? 1 : 0;
+      const bIsOpen = b.statusRef && b.statusRef.meansOpen ? 1 : 0;
+      return bIsOpen - aIsOpen;
+    }),
+);
+
+// values (from issue.fieldValues) is an array with one entry per value.
+// We want to turn this into a map of fieldNames -> values.
+export const fieldValueMap = createSelector(
+    fieldValues,
+    (fieldValues) => fieldValuesToMap(fieldValues),
+);
+
+// Get the list of full componentDefs for the viewed issue.
+export const components = createSelector(
+    viewedIssue,
+    projectV0.componentsMap,
+    (issue, components) => {
+      if (!issue || !issue.componentRefs) return [];
+      return issue.componentRefs.map(
+          (comp) => components.get(comp.path) || comp);
+    },
+);
+
+// Get custom fields that apply to a specific issue.
+export const fieldDefs = createSelector(
+    projectV0.fieldDefs,
+    type,
+    fieldValueMap,
+    (fieldDefs, type, fieldValues) => {
+      if (!fieldDefs) return [];
+      type = type || '';
+      return fieldDefs.filter((f) => {
+        const fieldValueKey = fieldValueMapKey(f.fieldRef.fieldName,
+            f.phaseRef && f.phaseRef.phaseName);
+        if (fieldValues && fieldValues.has(fieldValueKey)) {
+        // Regardless of other checks, include a particular field def if the
+        // issue has a value defined.
+          return true;
+        }
+        // Skip approval type and phase fields here.
+        if (f.fieldRef.approvalName ||
+            f.fieldRef.type === fieldTypes.APPROVAL_TYPE ||
+            f.isPhaseField) {
+          return false;
+        }
+
+        // If this fieldDef belongs to only one type, filter out the field if
+        // that type isn't the specified type.
+        if (f.applicableType && type.toLowerCase() !==
+            f.applicableType.toLowerCase()) {
+          return false;
+        }
+
+        return true;
+      });
+    },
+);
+
+// Action Creators
+/**
+ * Tells Redux that the user has navigated to an issue page and is now
+ * viewing a new issue.
+ * @param {IssueRef} issueRef The issue that the user is viewing.
+ * @return {AnyAction}
+ */
+export const viewIssue = (issueRef) => ({type: VIEW_ISSUE, issueRef});
+
+export const fetchCommentReferences = (comments, projectName) => {
+  return async (dispatch) => {
+    dispatch({type: FETCH_COMMENT_REFERENCES_START});
+
+    try {
+      const refs = await autolink.getReferencedArtifacts(comments, projectName);
+      const commentRefs = {};
+      refs.forEach(({componentName, existingRefs}) => {
+        commentRefs[componentName] = existingRefs;
+      });
+      dispatch({
+        type: FETCH_COMMENT_REFERENCES_SUCCESS,
+        commentReferences: commentRefs,
+      });
+    } catch (error) {
+      dispatch({type: FETCH_COMMENT_REFERENCES_FAILURE, error});
+    }
+  };
+};
+
+export const fetchReferencedUsers = (issue) => async (dispatch) => {
+  if (!issue) return;
+  dispatch({type: FETCH_REFERENCED_USERS_START});
+
+  // TODO(zhangtiff): Make this function account for custom fields
+  // of type user.
+  const userRefs = [...(issue.ccRefs || [])];
+  if (issue.ownerRef) {
+    userRefs.push(issue.ownerRef);
+  }
+  (issue.approvalValues || []).forEach((approval) => {
+    userRefs.push(...(approval.approverRefs || []));
+    if (approval.setterRef) {
+      userRefs.push(approval.setterRef);
+    }
+  });
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Users', 'ListReferencedUsers', {userRefs});
+
+    const referencedUsers = {};
+    (resp.users || []).forEach((user) => {
+      referencedUsers[user.displayName] = user;
+    });
+    dispatch({type: FETCH_REFERENCED_USERS_SUCCESS, referencedUsers});
+  } catch (error) {
+    dispatch({type: FETCH_REFERENCED_USERS_FAILURE, error});
+  }
+};
+
+export const fetchFederatedReferences = (issue) => async (dispatch) => {
+  dispatch({type: FETCH_FEDERATED_REFERENCES_START});
+
+  // Concat all potential fedrefs together, convert from shortlink to classes,
+  // then fire off a request to fetch the status of each.
+  const fedRefs = []
+      .concat(issue.danglingBlockingRefs || [])
+      .concat(issue.danglingBlockedOnRefs || [])
+      .concat(issue.mergedIntoIssueRef ? [issue.mergedIntoIssueRef] : [])
+      .filter((ref) => ref && ref.extIdentifier)
+      .map((ref) => fromShortlink(ref.extIdentifier))
+      .filter((fedRef) => fedRef);
+
+  // If no FedRefs, return empty Map.
+  if (fedRefs.length === 0) {
+    return;
+  }
+
+  try {
+    // Load email separately since it might have changed.
+    await loadGapi();
+    const email = await fetchGapiEmail();
+
+    // If already logged in, dispatch login success event.
+    dispatch({
+      type: userV0.GAPI_LOGIN_SUCCESS,
+      email: email,
+    });
+
+    await Promise.all(fedRefs.map((fedRef) => fedRef.getFederatedDetails()));
+    const fedRefIssueRefs = fedRefs.map((fedRef) => fedRef.toIssueRef());
+
+    dispatch({
+      type: FETCH_FEDERATED_REFERENCES_SUCCESS,
+      fedRefIssueRefs: fedRefIssueRefs,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_FEDERATED_REFERENCES_FAILURE, error});
+  }
+};
+
+// TODO(zhangtiff): Figure out if we can reduce request/response sizes by
+// diffing issues to fetch against issues we already know about to avoid
+// fetching duplicate info.
+export const fetchRelatedIssues = (issue) => async (dispatch) => {
+  if (!issue) return;
+  dispatch({type: FETCH_RELATED_ISSUES_START});
+
+  const refsToFetch = (issue.blockedOnIssueRefs || []).concat(
+      issue.blockingIssueRefs || []);
+  // Add mergedinto ref, exclude FedRefs which are fetched separately.
+  if (issue.mergedIntoIssueRef && !issue.mergedIntoIssueRef.extIdentifier) {
+    refsToFetch.push(issue.mergedIntoIssueRef);
+  }
+
+  const message = {
+    issueRefs: refsToFetch,
+  };
+  try {
+    // Fire off call to fetch FedRefs. Since it might take longer it is
+    // handled by a separate reducer.
+    dispatch(fetchFederatedReferences(issue));
+
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListReferencedIssues', message);
+
+    const relatedIssues = {};
+
+    const openIssues = resp.openRefs || [];
+    const closedIssues = resp.closedRefs || [];
+    openIssues.forEach((issue) => {
+      issue.statusRef.meansOpen = true;
+      relatedIssues[issueRefToString(issue)] = issue;
+    });
+    closedIssues.forEach((issue) => {
+      issue.statusRef.meansOpen = false;
+      relatedIssues[issueRefToString(issue)] = issue;
+    });
+    dispatch({
+      type: FETCH_RELATED_ISSUES_SUCCESS,
+      relatedIssues: relatedIssues,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_RELATED_ISSUES_FAILURE, error});
+  };
+};
+
+/**
+ * Fetches issue data needed to display a detailed view of a single
+ * issue. This function dispatches many actions to handle the fetching
+ * of issue comments, permissions, star state, and more.
+ * @param {IssueRef} issueRef The issue that the user is viewing.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssuePageData = (issueRef) => async (dispatch) => {
+  dispatch(fetchComments(issueRef));
+  dispatch(fetch(issueRef));
+  dispatch(fetchPermissions(issueRef));
+  dispatch(fetchIsStarred(issueRef));
+};
+
+/**
+ * @param {IssueRef} issueRef Which issue to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'GetIssue', {issueRef},
+    );
+
+    const movedToRef = resp.movedToRef;
+
+    // The API can return deleted issue objects that don't have issueRef data
+    // specified. For this case, we want to make sure a projectName and localId
+    // are still provided to the frontend to ensure that keying issues still
+    // works.
+    const issue = {...issueRef, ...resp.issue};
+    if (movedToRef) {
+      issue.movedToRef = movedToRef;
+    }
+
+    dispatch({type: FETCH_SUCCESS, issue});
+
+    if (!issue.isDeleted && !movedToRef) {
+      dispatch(fetchRelatedIssues(issue));
+      dispatch(fetchHotlists(issueRef));
+      dispatch(fetchReferencedUsers(issue));
+      dispatch(userV0.fetchProjects([issue.reporterRef]));
+    }
+  } catch (error) {
+    dispatch({type: FETCH_FAILURE, error});
+  }
+};
+
+/**
+ * Action creator to fetch multiple Issues.
+ * @param {Array<IssueRef>} issueRefs An Array of Issue references to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssues = (issueRefs) => async (dispatch) => {
+  dispatch({type: FETCH_ISSUES_START});
+
+  try {
+    const {openRefs, closedRefs} = await prpcClient.call(
+        'monorail.Issues', 'ListReferencedIssues', {issueRefs});
+    const issues = [...openRefs || [], ...closedRefs || []];
+
+    dispatch({type: FETCH_ISSUES_SUCCESS, issues});
+  } catch (error) {
+    dispatch({type: FETCH_ISSUES_FAILURE, error});
+  }
+};
+
+/**
+ * Gets the hotlists that a given issue is in.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchHotlists = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_HOTLISTS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Features', 'ListHotlistsByIssue', {issue: issueRef});
+
+    const hotlists = (resp.hotlists || []);
+    hotlists.sort((hotlistA, hotlistB) => {
+      return hotlistA.name.localeCompare(hotlistB.name);
+    });
+    dispatch({type: FETCH_HOTLISTS_SUCCESS, hotlists});
+  } catch (error) {
+    dispatch({type: FETCH_HOTLISTS_FAILURE, error});
+  };
+};
+
+/**
+ * Async action creator to fetch issues in the issue list and grid pages. This
+ * action creator supports batching multiple async requests to support the grid
+ * view's ability to load up to 6,000 issues in one page load.
+ *
+ * @param {string} projectName The project to fetch issues from.
+ * @param {Object} params Options for which issues to fetch.
+ * @param {string=} params.q The query string for the search.
+ * @param {string=} params.can The ID of the canned query for the search.
+ * @param {string=} params.groupby The spec of which fields to group by.
+ * @param {string=} params.sort The spec of which fields to sort by.
+ * @param {number=} params.start What cursor index to start at.
+ * @param {number=} params.maxItems How many items to fetch per page.
+ * @param {number=} params.maxCalls The maximum number of API calls to make.
+ *   Combined with pagination.maxItems, this defines the maximum number of
+ *   issues this method can fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssueList =
+  (projectName, {q = undefined, can = undefined, groupby = undefined,
+    sort = undefined, start = undefined, maxItems = undefined,
+    maxCalls = 1,
+  }) => async (dispatch) => {
+    let updateData = {};
+    const promises = [];
+    const issuesByRequest = [];
+    let issueLimit;
+    let totalIssues;
+    let totalCalls;
+    const itemsPerCall = maxItems || 1000;
+
+    const cannedQuery = Number.parseInt(can) || undefined;
+
+    const pagination = {
+      ...(start && {start}),
+      ...(maxItems && {maxItems}),
+    };
+
+    const message = {
+      projectNames: [projectName],
+      query: q,
+      cannedQuery,
+      groupBySpec: groupby,
+      sortSpec: sort,
+      pagination,
+    };
+
+    dispatch({type: FETCH_ISSUE_LIST_START});
+
+    // initial api call made to determine total number of issues matching
+    // the query.
+    try {
+      // TODO(zhangtiff): Refactor this action creator when adding issue
+      // list pagination.
+      const resp = await prpcClient.call(
+          'monorail.Issues', 'ListIssues', message);
+
+      // prpcClient is not actually a protobuf client and therefore not
+      // hydrating default values. See crbug.com/monorail/6641
+      // Until that is fixed, we have to explicitly define it.
+      const defaultFetchListResponse = {totalResults: 0, issues: []};
+
+      updateData =
+        Object.entries(resp).length === 0 ?
+          defaultFetchListResponse :
+          resp;
+      issuesByRequest[0] = updateData.issues;
+      issueLimit = updateData.totalResults;
+
+      // determine correct issues to load and number of calls to be made.
+      if (issueLimit > (itemsPerCall * maxCalls)) {
+        totalIssues = itemsPerCall * maxCalls;
+        totalCalls = maxCalls - 1;
+      } else {
+        totalIssues = issueLimit;
+        totalCalls = Math.ceil(issueLimit / itemsPerCall) - 1;
+      }
+
+      if (totalIssues) {
+        updateData.progress = updateData.issues.length / totalIssues;
+      } else {
+        updateData.progress = 1;
+      }
+
+      dispatch({type: FETCH_ISSUE_LIST_UPDATE, ...updateData});
+
+      // remaining api calls are made.
+      for (let i = 1; i <= totalCalls; i++) {
+        promises[i - 1] = (async () => {
+          const resp = await prpcClient.call(
+              'monorail.Issues', 'ListIssues', {
+                ...message,
+                pagination: {start: i * itemsPerCall, maxItems: itemsPerCall},
+              });
+          issuesByRequest[i] = (resp.issues || []);
+          // sort the issues in the correct order.
+          updateData.issues = [];
+          issuesByRequest.forEach((issue) => {
+            updateData.issues = updateData.issues.concat(issue);
+          });
+          updateData.progress = updateData.issues.length / totalIssues;
+          dispatch({type: FETCH_ISSUE_LIST_UPDATE, ...updateData});
+        })();
+      }
+
+      await Promise.all(promises);
+
+      // TODO(zhangtiff): Try to delete FETCH_ISSUE_LIST_SUCCESS in favor of
+      // just FETCH_ISSUE_LIST_UPDATE.
+      dispatch({type: FETCH_ISSUE_LIST_SUCCESS});
+    } catch (error) {
+      dispatch({type: FETCH_ISSUE_LIST_FAILURE, error});
+    };
+  };
+
+/**
+ * Fetches the currently logged in user's permissions for a given issue.
+ * @param {Issue} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchPermissions = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_PERMISSIONS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListIssuePermissions', {issueRef},
+    );
+
+    dispatch({type: FETCH_PERMISSIONS_SUCCESS, permissions: resp.permissions});
+  } catch (error) {
+    dispatch({type: FETCH_PERMISSIONS_FAILURE, error});
+  };
+};
+
+/**
+ * Fetches comments for an issue. Note that issue descriptions are also
+ * comments.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchComments = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_COMMENTS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListComments', {issueRef});
+
+    dispatch({type: FETCH_COMMENTS_SUCCESS, comments: resp.comments});
+    dispatch(fetchCommentReferences(
+        resp.comments, issueRef.projectName));
+
+    const commenterRefs = (resp.comments || []).map(
+        (comment) => comment.commenter);
+    dispatch(userV0.fetchProjects(commenterRefs));
+  } catch (error) {
+    dispatch({type: FETCH_COMMENTS_FAILURE, error});
+  };
+};
+
+/**
+ * Gets whether the logged in user has starred a given issue.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIsStarred = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_IS_STARRED_START});
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'IsIssueStarred', {issueRef},
+    );
+
+    dispatch({
+      type: FETCH_IS_STARRED_SUCCESS,
+      starred: resp.isStarred,
+      issueRef: issueRef,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_IS_STARRED_FAILURE, error});
+  };
+};
+
+/**
+ * Fetch all of a logged in user's starred issues.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchStarredIssues = () => async (dispatch) => {
+  dispatch({type: FETCH_ISSUES_STARRED_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListStarredIssues', {},
+    );
+    dispatch({type: FETCH_ISSUES_STARRED_SUCCESS,
+      starredIssueRefs: resp.starredIssueRefs});
+  } catch (error) {
+    dispatch({type: FETCH_ISSUES_STARRED_FAILURE, error});
+  };
+};
+
+/**
+ * Stars or unstars an issue.
+ * @param {IssueRef} issueRef The issue to star.
+ * @param {boolean} starred Whether to star or unstar.
+ * @return {function(function): Promise<void>}
+ */
+export const star = (issueRef, starred) => async (dispatch) => {
+  const requestKey = issueRefToString(issueRef);
+
+  dispatch({type: STAR_START, requestKey});
+  const message = {issueRef, starred};
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'StarIssue', message,
+    );
+
+    dispatch({
+      type: STAR_SUCCESS,
+      starCount: resp.starCount,
+      issueRef,
+      starred,
+      requestKey,
+    });
+  } catch (error) {
+    dispatch({type: STAR_FAILURE, error, requestKey});
+  }
+};
+
+/**
+ * Runs a presubmit request to find warnings to show the user before an issue
+ * edit is saved.
+ * @param {IssueRef} issueRef The issue being edited.
+ * @param {IssueDelta} issueDelta The user's in flight changes to the issue.
+ * @return {function(function): Promise<void>}
+ */
+export const presubmit = (issueRef, issueDelta) => async (dispatch) => {
+  dispatch({type: PRESUBMIT_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'PresubmitIssue', {issueRef, issueDelta});
+
+    dispatch({type: PRESUBMIT_SUCCESS, presubmitResponse: resp});
+  } catch (error) {
+    dispatch({type: PRESUBMIT_FAILURE, error: error});
+  }
+};
+
+/**
+ * Async action creator to update an issue's approval.
+ *
+ * @param {Object} params Options for the approval update.
+ * @param {IssueRef} params.issueRef
+ * @param {FieldRef} params.fieldRef
+ * @param {ApprovalDelta=} params.approvalDelta
+ * @param {string=} params.commentContent
+ * @param {boolean=} params.sendEmail
+ * @param {boolean=} params.isDescription
+ * @param {AttachmentUpload=} params.uploads
+ * @return {function(function): Promise<void>}
+ */
+export const updateApproval = ({issueRef, fieldRef, approvalDelta,
+  commentContent, sendEmail, isDescription, uploads}) => async (dispatch) => {
+  dispatch({type: UPDATE_APPROVAL_START});
+  try {
+    const {approval} = await prpcClient.call(
+        'monorail.Issues', 'UpdateApproval', {
+          ...(issueRef && {issueRef}),
+          ...(fieldRef && {fieldRef}),
+          ...(approvalDelta && {approvalDelta}),
+          ...(commentContent && {commentContent}),
+          ...(sendEmail && {sendEmail}),
+          ...(isDescription && {isDescription}),
+          ...(uploads && {uploads}),
+        });
+
+    dispatch({type: UPDATE_APPROVAL_SUCCESS, approval, issueRef});
+    dispatch(fetch(issueRef));
+    dispatch(fetchComments(issueRef));
+  } catch (error) {
+    dispatch({type: UPDATE_APPROVAL_FAILURE, error: error});
+  };
+};
+
+/**
+ * Async action creator to update an issue.
+ *
+ * @param {Object} params Options for the issue update.
+ * @param {IssueRef} params.issueRef
+ * @param {IssueDelta=} params.delta
+ * @param {string=} params.commentContent
+ * @param {boolean=} params.sendEmail
+ * @param {boolean=} params.isDescription
+ * @param {AttachmentUpload=} params.uploads
+ * @param {Array<number>=} params.keptAttachments
+ * @return {function(function): Promise<void>}
+ */
+export const update = ({issueRef, delta, commentContent, sendEmail,
+  isDescription, uploads, keptAttachments}) => async (dispatch) => {
+  dispatch({type: UPDATE_START});
+
+  try {
+    const {issue} = await prpcClient.call(
+        'monorail.Issues', 'UpdateIssue', {issueRef, delta,
+          commentContent, sendEmail, isDescription, uploads,
+          keptAttachments});
+
+    dispatch({type: UPDATE_SUCCESS, issue});
+    dispatch(fetchComments(issueRef));
+    dispatch(fetchRelatedIssues(issue));
+    dispatch(fetchReferencedUsers(issue));
+  } catch (error) {
+    dispatch({type: UPDATE_FAILURE, error: error});
+  };
+};
+
+/**
+ * Converts an issue from one template to another. This is used for changing
+ * launch issues.
+ * @param {IssueRef} issueRef
+ * @param {Object} options
+ * @param {string=} options.templateName
+ * @param {string=} options.commentContent
+ * @param {boolean=} options.sendEmail
+ * @return {function(function): Promise<void>}
+ */
+export const convert = (issueRef, {templateName = '',
+  commentContent = '', sendEmail = true},
+) => async (dispatch) => {
+  dispatch({type: CONVERT_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ConvertIssueApprovalsTemplate',
+        {issueRef, templateName, commentContent, sendEmail});
+
+    dispatch({type: CONVERT_SUCCESS, issue: resp.issue});
+    const fetchCommentsMessage = {issueRef};
+    dispatch(fetchComments(fetchCommentsMessage));
+  } catch (error) {
+    dispatch({type: CONVERT_FAILURE, error: error});
+  };
+};
diff --git a/static_src/reducers/issueV0.test.js b/static_src/reducers/issueV0.test.js
new file mode 100644
index 0000000..0c7a0f5
--- /dev/null
+++ b/static_src/reducers/issueV0.test.js
@@ -0,0 +1,1409 @@
+// 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 {createSelector} from 'reselect';
+import {store, resetState} from './base.js';
+import * as issueV0 from './issueV0.js';
+import * as example from 'shared/test/constants-issueV0.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import {issueToIssueRef, issueRefToString} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {getSigninInstance} from 'shared/gapi-loader.js';
+
+let prpcCall;
+let dispatch;
+
+describe('issue', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+  });
+
+  describe('reducers', () => {
+    describe('issueByRefReducer', () => {
+      it('no-op on unmatching action', () => {
+        const action = {
+          type: 'FAKE_ACTION',
+          issues: [example.ISSUE_OTHER_PROJECT],
+        };
+        assert.deepEqual(issueV0.issuesByRefStringReducer({}, action), {});
+
+        const state = {[example.ISSUE_REF_STRING]: example.ISSUE};
+        assert.deepEqual(issueV0.issuesByRefStringReducer(state, action),
+            state);
+      });
+
+      it('handles FETCH_ISSUE_LIST_UPDATE', () => {
+        const newState = issueV0.issuesByRefStringReducer({}, {
+          type: issueV0.FETCH_ISSUE_LIST_UPDATE,
+          issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+          totalResults: 2,
+          progress: 1,
+        });
+        assert.deepEqual(newState, {
+          [example.ISSUE_REF_STRING]: example.ISSUE,
+          [example.ISSUE_OTHER_PROJECT_REF_STRING]: example.ISSUE_OTHER_PROJECT,
+        });
+      });
+
+      it('handles FETCH_ISSUES_SUCCESS', () => {
+        const newState = issueV0.issuesByRefStringReducer({}, {
+          type: issueV0.FETCH_ISSUES_SUCCESS,
+          issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+        });
+        assert.deepEqual(newState, {
+          [example.ISSUE_REF_STRING]: example.ISSUE,
+          [example.ISSUE_OTHER_PROJECT_REF_STRING]: example.ISSUE_OTHER_PROJECT,
+        });
+      });
+    });
+
+    describe('issueListReducer', () => {
+      it('no-op on unmatching action', () => {
+        const action = {
+          type: 'FETCH_ISSUE_LIST_FAKE_ACTION',
+          issues: [
+            {localId: 1, projectName: 'chromium', summary: 'hello-world'},
+          ],
+        };
+        assert.deepEqual(issueV0.issueListReducer({}, action), {});
+
+        assert.deepEqual(issueV0.issueListReducer({
+          issueRefs: ['chromium:1'],
+          totalResults: 1,
+          progress: 1,
+        }, action), {
+          issueRefs: ['chromium:1'],
+          totalResults: 1,
+          progress: 1,
+        });
+      });
+
+      it('handles FETCH_ISSUE_LIST_UPDATE', () => {
+        const newState = issueV0.issueListReducer({}, {
+          type: 'FETCH_ISSUE_LIST_UPDATE',
+          issues: [
+            {localId: 1, projectName: 'chromium', summary: 'hello-world'},
+            {localId: 2, projectName: 'monorail', summary: 'Test'},
+          ],
+          totalResults: 2,
+          progress: 1,
+        });
+        assert.deepEqual(newState, {
+          issueRefs: ['chromium:1', 'monorail:2'],
+          totalResults: 2,
+          progress: 1,
+        });
+      });
+    });
+
+    describe('relatedIssuesReducer', () => {
+      it('handles FETCH_RELATED_ISSUES_SUCCESS', () => {
+        const newState = issueV0.relatedIssuesReducer({}, {
+          type: 'FETCH_RELATED_ISSUES_SUCCESS',
+          relatedIssues: {'rutabaga:1234': {}},
+        });
+        assert.deepEqual(newState, {'rutabaga:1234': {}});
+      });
+
+      describe('FETCH_FEDERATED_REFERENCES_SUCCESS', () => {
+        it('returns early if data is missing', () => {
+          const newState = issueV0.relatedIssuesReducer({'b/123': {}}, {
+            type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+          });
+          assert.deepEqual(newState, {'b/123': {}});
+        });
+
+        it('returns early if data is empty', () => {
+          const newState = issueV0.relatedIssuesReducer({'b/123': {}}, {
+            type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+            fedRefIssueRefs: [],
+          });
+          assert.deepEqual(newState, {'b/123': {}});
+        });
+
+        it('assigns each FedRef to the state', () => {
+          const state = {
+            'rutabaga:123': {},
+            'rutabaga:345': {},
+          };
+          const newState = issueV0.relatedIssuesReducer(state, {
+            type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+            fedRefIssueRefs: [
+              {
+                extIdentifier: 'b/987',
+                summary: 'What is up',
+                statusRef: {meansOpen: true},
+              },
+              {
+                extIdentifier: 'b/765',
+                summary: 'Rutabaga',
+                statusRef: {meansOpen: false},
+              },
+            ],
+          });
+          assert.deepEqual(newState, {
+            'rutabaga:123': {},
+            'rutabaga:345': {},
+            'b/987': {
+              extIdentifier: 'b/987',
+              summary: 'What is up',
+              statusRef: {meansOpen: true},
+            },
+            'b/765': {
+              extIdentifier: 'b/765',
+              summary: 'Rutabaga',
+              statusRef: {meansOpen: false},
+            },
+          });
+        });
+      });
+    });
+  });
+
+  it('viewedIssue', () => {
+    assert.deepEqual(issueV0.viewedIssue(wrapIssue()), {});
+    assert.deepEqual(
+        issueV0.viewedIssue(wrapIssue({projectName: 'proj', localId: 100})),
+        {projectName: 'proj', localId: 100},
+    );
+  });
+
+  describe('issueList', () => {
+    it('issueList', () => {
+      const stateWithEmptyIssueList = {issue: {
+        issueList: {},
+      }};
+      assert.deepEqual(issueV0.issueList(stateWithEmptyIssueList), []);
+
+      const stateWithIssueList = {issue: {
+        issuesByRefString: {
+          'chromium:1': {localId: 1, projectName: 'chromium', summary: 'test'},
+          'monorail:2': {localId: 2, projectName: 'monorail',
+            summary: 'hello world'},
+        },
+        issueList: {
+          issueRefs: ['chromium:1', 'monorail:2'],
+        }}};
+      assert.deepEqual(issueV0.issueList(stateWithIssueList),
+          [
+            {localId: 1, projectName: 'chromium', summary: 'test'},
+            {localId: 2, projectName: 'monorail', summary: 'hello world'},
+          ]);
+    });
+
+    it('is a selector', () => {
+      issueV0.issueList.constructor === createSelector;
+    });
+
+    it('memoizes results: returns same reference', () => {
+      const stateWithIssueList = {issue: {
+        issuesByRefString: {
+          'chromium:1': {localId: 1, projectName: 'chromium', summary: 'test'},
+          'monorail:2': {localId: 2, projectName: 'monorail',
+            summary: 'hello world'},
+        },
+        issueList: {
+          issueRefs: ['chromium:1', 'monorail:2'],
+        }}};
+      const reference1 = issueV0.issueList(stateWithIssueList);
+      const reference2 = issueV0.issueList(stateWithIssueList);
+
+      assert.equal(typeof reference1, 'object');
+      assert.equal(typeof reference2, 'object');
+      assert.equal(reference1, reference2);
+    });
+  });
+
+  describe('issueListLoaded', () => {
+    const stateWithEmptyIssueList = {issue: {
+      issueList: {},
+    }};
+
+    it('false when no issue list', () => {
+      assert.isFalse(issueV0.issueListLoaded(stateWithEmptyIssueList));
+    });
+
+    it('true after issues loaded, even when empty', () => {
+      const issueList = issueV0.issueListReducer({}, {
+        type: issueV0.FETCH_ISSUE_LIST_UPDATE,
+        issues: [],
+        progress: 1,
+        totalResults: 0,
+      });
+      assert.isTrue(issueV0.issueListLoaded({issue: {issueList}}));
+    });
+  });
+
+  it('fieldValues', () => {
+    assert.isUndefined(issueV0.fieldValues(wrapIssue()));
+    assert.deepEqual(issueV0.fieldValues(wrapIssue({
+      fieldValues: [{value: 'v'}],
+    })), [{value: 'v'}]);
+  });
+
+  it('type computes type from custom field', () => {
+    assert.isUndefined(issueV0.type(wrapIssue()));
+    assert.isUndefined(issueV0.type(wrapIssue({
+      fieldValues: [{value: 'v'}],
+    })));
+    assert.deepEqual(issueV0.type(wrapIssue({
+      fieldValues: [
+        {fieldRef: {fieldName: 'IgnoreMe'}, value: 'v'},
+        {fieldRef: {fieldName: 'Type'}, value: 'Defect'},
+      ],
+    })), 'Defect');
+  });
+
+  it('type computes type from label', () => {
+    assert.deepEqual(issueV0.type(wrapIssue({
+      labelRefs: [
+        {label: 'Test'},
+        {label: 'tYpE-FeatureRequest'},
+      ],
+    })), 'FeatureRequest');
+
+    assert.deepEqual(issueV0.type(wrapIssue({
+      fieldValues: [
+        {fieldRef: {fieldName: 'IgnoreMe'}, value: 'v'},
+      ],
+      labelRefs: [
+        {label: 'Test'},
+        {label: 'Type-Defect'},
+      ],
+    })), 'Defect');
+  });
+
+  it('restrictions', () => {
+    assert.deepEqual(issueV0.restrictions(wrapIssue()), {});
+    assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: []})), {});
+
+    assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: [
+      {label: 'IgnoreThis'},
+      {label: 'IgnoreThis2'},
+    ]})), {});
+
+    assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: [
+      {label: 'IgnoreThis'},
+      {label: 'IgnoreThis2'},
+      {label: 'Restrict-View-Google'},
+      {label: 'Restrict-EditIssue-hello'},
+      {label: 'Restrict-EditIssue-test'},
+      {label: 'Restrict-AddIssueComment-HELLO'},
+    ]})), {
+      'view': ['Google'],
+      'edit': ['hello', 'test'],
+      'comment': ['HELLO'],
+    });
+  });
+
+  it('isOpen', () => {
+    assert.isFalse(issueV0.isOpen(wrapIssue()));
+    assert.isTrue(issueV0.isOpen(wrapIssue({statusRef: {meansOpen: true}})));
+    assert.isFalse(issueV0.isOpen(wrapIssue({statusRef: {meansOpen: false}})));
+  });
+
+  it('issueListPhaseNames', () => {
+    const stateWithEmptyIssueList = {issue: {
+      issueList: [],
+    }};
+    assert.deepEqual(issueV0.issueListPhaseNames(stateWithEmptyIssueList), []);
+    const stateWithIssueList = {issue: {
+      issuesByRefString: {
+        '1': {localId: 1, phases: [{phaseRef: {phaseName: 'chicken-phase'}}]},
+        '2': {localId: 2, phases: [
+          {phaseRef: {phaseName: 'chicken-Phase'}},
+          {phaseRef: {phaseName: 'cow-phase'}}],
+        },
+        '3': {localId: 3, phases: [
+          {phaseRef: {phaseName: 'cow-Phase'}},
+          {phaseRef: {phaseName: 'DOG-phase'}}],
+        },
+        '4': {localId: 4, phases: [
+          {phaseRef: {phaseName: 'dog-phase'}},
+        ]},
+      },
+      issueList: {
+        issueRefs: ['1', '2', '3', '4'],
+      }}};
+    assert.deepEqual(issueV0.issueListPhaseNames(stateWithIssueList),
+        ['chicken-phase', 'cow-phase', 'dog-phase']);
+  });
+
+  describe('blockingIssues', () => {
+    const relatedIssues = {
+      ['proj:1']: {
+        localId: 1,
+        projectName: 'proj',
+        labelRefs: [{label: 'label'}],
+      },
+      ['proj:3']: {
+        localId: 3,
+        projectName: 'proj',
+        labelRefs: [],
+      },
+      ['chromium:332']: {
+        localId: 332,
+        projectName: 'chromium',
+        labelRefs: [],
+      },
+    };
+
+    it('returns references when no issue data', () => {
+      const stateNoReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockingIssueRefs: [{localId: 1, projectName: 'proj'}],
+          },
+          {relatedIssues: {}},
+      );
+      assert.deepEqual(issueV0.blockingIssues(stateNoReferences),
+          [{localId: 1, projectName: 'proj'}],
+      );
+    });
+
+    it('returns empty when no blocking issues', () => {
+      const stateNoIssues = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockingIssueRefs: [],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.blockingIssues(stateNoIssues), []);
+    });
+
+    it('returns full issues when deferenced data present', () => {
+      const stateIssuesWithReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockingIssueRefs: [
+              {localId: 1, projectName: 'proj'},
+              {localId: 332, projectName: 'chromium'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.blockingIssues(stateIssuesWithReferences),
+          [
+            {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+            {localId: 332, projectName: 'chromium', labelRefs: []},
+          ]);
+    });
+
+    it('returns federated references', () => {
+      const stateIssuesWithFederatedReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockingIssueRefs: [
+              {localId: 1, projectName: 'proj'},
+              {extIdentifier: 'b/1234'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(
+          issueV0.blockingIssues(stateIssuesWithFederatedReferences), [
+            {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+            {extIdentifier: 'b/1234'},
+          ]);
+    });
+  });
+
+  describe('blockedOnIssues', () => {
+    const relatedIssues = {
+      ['proj:1']: {
+        localId: 1,
+        projectName: 'proj',
+        labelRefs: [{label: 'label'}],
+      },
+      ['proj:3']: {
+        localId: 3,
+        projectName: 'proj',
+        labelRefs: [],
+      },
+      ['chromium:332']: {
+        localId: 332,
+        projectName: 'chromium',
+        labelRefs: [],
+      },
+    };
+
+    it('returns references when no issue data', () => {
+      const stateNoReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [{localId: 1, projectName: 'proj'}],
+          },
+          {relatedIssues: {}},
+      );
+      assert.deepEqual(issueV0.blockedOnIssues(stateNoReferences),
+          [{localId: 1, projectName: 'proj'}],
+      );
+    });
+
+    it('returns empty when no blocking issues', () => {
+      const stateNoIssues = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.blockedOnIssues(stateNoIssues), []);
+    });
+
+    it('returns full issues when deferenced data present', () => {
+      const stateIssuesWithReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 1, projectName: 'proj'},
+              {localId: 332, projectName: 'chromium'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.blockedOnIssues(stateIssuesWithReferences),
+          [
+            {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+            {localId: 332, projectName: 'chromium', labelRefs: []},
+          ]);
+    });
+
+    it('returns federated references', () => {
+      const stateIssuesWithFederatedReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 1, projectName: 'proj'},
+              {extIdentifier: 'b/1234'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(
+          issueV0.blockedOnIssues(stateIssuesWithFederatedReferences),
+          [
+            {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+            {extIdentifier: 'b/1234'},
+          ]);
+    });
+  });
+
+  describe('sortedBlockedOn', () => {
+    const relatedIssues = {
+      ['proj:1']: {
+        localId: 1,
+        projectName: 'proj',
+        statusRef: {meansOpen: true},
+      },
+      ['proj:3']: {
+        localId: 3,
+        projectName: 'proj',
+        statusRef: {meansOpen: false},
+      },
+      ['proj:4']: {
+        localId: 4,
+        projectName: 'proj',
+        statusRef: {meansOpen: false},
+      },
+      ['proj:5']: {
+        localId: 5,
+        projectName: 'proj',
+        statusRef: {meansOpen: false},
+      },
+      ['chromium:332']: {
+        localId: 332,
+        projectName: 'chromium',
+        statusRef: {meansOpen: true},
+      },
+    };
+
+    it('does not sort references when no issue data', () => {
+      const stateNoReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 3, projectName: 'proj'},
+              {localId: 1, projectName: 'proj'},
+            ],
+          },
+          {relatedIssues: {}},
+      );
+      assert.deepEqual(issueV0.sortedBlockedOn(stateNoReferences), [
+        {localId: 3, projectName: 'proj'},
+        {localId: 1, projectName: 'proj'},
+      ]);
+    });
+
+    it('sorts open issues first when issue data available', () => {
+      const stateReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 3, projectName: 'proj'},
+              {localId: 1, projectName: 'proj'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.sortedBlockedOn(stateReferences), [
+        {localId: 1, projectName: 'proj', statusRef: {meansOpen: true}},
+        {localId: 3, projectName: 'proj', statusRef: {meansOpen: false}},
+      ]);
+    });
+
+    it('preserves original order on ties', () => {
+      const statePreservesArrayOrder = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 5, projectName: 'proj'}, // Closed
+              {localId: 1, projectName: 'proj'}, // Open
+              {localId: 4, projectName: 'proj'}, // Closed
+              {localId: 3, projectName: 'proj'}, // Closed
+              {localId: 332, projectName: 'chromium'}, // Open
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.sortedBlockedOn(statePreservesArrayOrder),
+          [
+            {localId: 1, projectName: 'proj', statusRef: {meansOpen: true}},
+            {localId: 332, projectName: 'chromium',
+              statusRef: {meansOpen: true}},
+            {localId: 5, projectName: 'proj', statusRef: {meansOpen: false}},
+            {localId: 4, projectName: 'proj', statusRef: {meansOpen: false}},
+            {localId: 3, projectName: 'proj', statusRef: {meansOpen: false}},
+          ],
+      );
+    });
+  });
+
+  describe('mergedInto', () => {
+    it('empty', () => {
+      assert.deepEqual(issueV0.mergedInto(wrapIssue()), {});
+    });
+
+    it('gets mergedInto ref for viewed issue', () => {
+      const state = issueV0.mergedInto(wrapIssue({
+        projectName: 'project',
+        localId: 123,
+        mergedIntoIssueRef: {localId: 22, projectName: 'proj'},
+      }));
+      assert.deepEqual(state, {
+        localId: 22,
+        projectName: 'proj',
+      });
+    });
+
+    it('gets full mergedInto issue data when it exists in the store', () => {
+      const state = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            mergedIntoIssueRef: {localId: 22, projectName: 'proj'},
+          }, {
+            relatedIssues: {
+              ['proj:22']: {localId: 22, projectName: 'proj', summary: 'test'},
+            },
+          });
+      assert.deepEqual(issueV0.mergedInto(state), {
+        localId: 22,
+        projectName: 'proj',
+        summary: 'test',
+      });
+    });
+  });
+
+  it('fieldValueMap', () => {
+    assert.deepEqual(issueV0.fieldValueMap(wrapIssue()), new Map());
+    assert.deepEqual(issueV0.fieldValueMap(wrapIssue({
+      fieldValues: [],
+    })), new Map());
+    assert.deepEqual(issueV0.fieldValueMap(wrapIssue({
+      fieldValues: [
+        {fieldRef: {fieldName: 'hello'}, value: 'v3'},
+        {fieldRef: {fieldName: 'hello'}, value: 'v2'},
+        {fieldRef: {fieldName: 'world'}, value: 'v3'},
+      ],
+    })), new Map([
+      ['hello', ['v3', 'v2']],
+      ['world', ['v3']],
+    ]));
+  });
+
+  it('fieldDefs filters fields by applicable type', () => {
+    assert.deepEqual(issueV0.fieldDefs({
+      projectV0: {},
+      ...wrapIssue(),
+    }), []);
+
+    assert.deepEqual(issueV0.fieldDefs({
+      projectV0: {
+        name: 'chromium',
+        configs: {
+          chromium: {
+            fieldDefs: [
+              {fieldRef: {fieldName: 'intyInt', type: fieldTypes.INT_TYPE}},
+              {fieldRef: {fieldName: 'enum', type: fieldTypes.ENUM_TYPE}},
+              {
+                fieldRef:
+                  {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+                applicableType: 'None',
+              },
+              {fieldRef: {fieldName: 'defectsOnly', type: fieldTypes.STR_TYPE},
+                applicableType: 'Defect'},
+            ],
+          },
+        },
+      },
+      ...wrapIssue({
+        fieldValues: [
+          {fieldRef: {fieldName: 'Type'}, value: 'Defect'},
+        ],
+      }),
+    }), [
+      {fieldRef: {fieldName: 'intyInt', type: fieldTypes.INT_TYPE}},
+      {fieldRef: {fieldName: 'enum', type: fieldTypes.ENUM_TYPE}},
+      {fieldRef: {fieldName: 'defectsOnly', type: fieldTypes.STR_TYPE},
+        applicableType: 'Defect'},
+    ]);
+  });
+
+  it('fieldDefs skips approval fields for all issues', () => {
+    assert.deepEqual(issueV0.fieldDefs({
+      projectV0: {
+        name: 'chromium',
+        configs: {
+          chromium: {
+            fieldDefs: [
+              {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+              {fieldRef:
+                {fieldName: 'ignoreMe', type: fieldTypes.APPROVAL_TYPE}},
+              {fieldRef:
+                {fieldName: 'LookAway', approvalName: 'ThisIsAnApproval'}},
+              {fieldRef: {fieldName: 'phaseField'}, isPhaseField: true},
+            ],
+          },
+        },
+      },
+      ...wrapIssue(),
+    }), [
+      {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+    ]);
+  });
+
+  it('fieldDefs includes non applicable fields when values defined', () => {
+    assert.deepEqual(issueV0.fieldDefs({
+      projectV0: {
+        name: 'chromium',
+        configs: {
+          chromium: {
+            fieldDefs: [
+              {
+                fieldRef:
+                  {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+                applicableType: 'None',
+              },
+            ],
+          },
+        },
+      },
+      ...wrapIssue({
+        fieldValues: [
+          {fieldRef: {fieldName: 'nonApplicable'}, value: 'v3'},
+        ],
+      }),
+    }), [
+      {fieldRef: {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+        applicableType: 'None'},
+    ]);
+  });
+
+  describe('action creators', () => {
+    beforeEach(() => {
+      prpcCall = sinon.stub(prpcClient, 'call');
+    });
+
+    afterEach(() => {
+      prpcCall.restore();
+    });
+
+    it('viewIssue creates action with issueRef', () => {
+      assert.deepEqual(
+          issueV0.viewIssue({projectName: 'proj', localId: 123}),
+          {
+            type: issueV0.VIEW_ISSUE,
+            issueRef: {projectName: 'proj', localId: 123},
+          },
+      );
+    });
+
+
+    describe('updateApproval', async () => {
+      const APPROVAL = {
+        fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+        approverRefs: [{userId: 1234, displayName: 'test@example.com'}],
+        status: 'APPROVED',
+      };
+
+      it('approval update success', async () => {
+        const dispatch = sinon.stub();
+
+        prpcCall.returns({approval: APPROVAL});
+
+        const action = issueV0.updateApproval({
+          issueRef: {projectName: 'chromium', localId: 1234},
+          fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+          approvalDelta: {status: 'APPROVED'},
+          sendEmail: true,
+        });
+
+        await action(dispatch);
+
+        sinon.assert.calledOnce(prpcCall);
+
+        sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+            'UpdateApproval', {
+              issueRef: {projectName: 'chromium', localId: 1234},
+              fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+              approvalDelta: {status: 'APPROVED'},
+              sendEmail: true,
+            });
+
+        sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+        sinon.assert.calledWith(dispatch, {
+          type: 'UPDATE_APPROVAL_SUCCESS',
+          approval: APPROVAL,
+          issueRef: {projectName: 'chromium', localId: 1234},
+        });
+      });
+
+      it('approval survey update success', async () => {
+        const dispatch = sinon.stub();
+
+        prpcCall.returns({approval: APPROVAL});
+
+        const action = issueV0.updateApproval({
+          issueRef: {projectName: 'chromium', localId: 1234},
+          fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+          commentContent: 'new survey',
+          sendEmail: false,
+          isDescription: true,
+        });
+
+        await action(dispatch);
+
+        sinon.assert.calledOnce(prpcCall);
+
+        sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+            'UpdateApproval', {
+              issueRef: {projectName: 'chromium', localId: 1234},
+              fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+              commentContent: 'new survey',
+              isDescription: true,
+            });
+
+        sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+        sinon.assert.calledWith(dispatch, {
+          type: 'UPDATE_APPROVAL_SUCCESS',
+          approval: APPROVAL,
+          issueRef: {projectName: 'chromium', localId: 1234},
+        });
+      });
+
+      it('attachment upload success', async () => {
+        const dispatch = sinon.stub();
+
+        prpcCall.returns({approval: APPROVAL});
+
+        const action = issueV0.updateApproval({
+          issueRef: {projectName: 'chromium', localId: 1234},
+          fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+          uploads: '78f17a020cbf39e90e344a842cd19911',
+        });
+
+        await action(dispatch);
+
+        sinon.assert.calledOnce(prpcCall);
+
+        sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+            'UpdateApproval', {
+              issueRef: {projectName: 'chromium', localId: 1234},
+              fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+              uploads: '78f17a020cbf39e90e344a842cd19911',
+            });
+
+        sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+        sinon.assert.calledWith(dispatch, {
+          type: 'UPDATE_APPROVAL_SUCCESS',
+          approval: APPROVAL,
+          issueRef: {projectName: 'chromium', localId: 1234},
+        });
+      });
+    });
+
+    describe('fetchIssues', () => {
+      it('success', async () => {
+        const response = {
+          openRefs: [example.ISSUE],
+          closedRefs: [example.ISSUE_OTHER_PROJECT],
+        };
+        prpcClient.call.returns(Promise.resolve(response));
+        const dispatch = sinon.stub();
+
+        await issueV0.fetchIssues([example.ISSUE_REF])(dispatch);
+
+        sinon.assert.calledWith(dispatch, {type: issueV0.FETCH_ISSUES_START});
+
+        const args = {issueRefs: [example.ISSUE_REF]};
+        sinon.assert.calledWith(
+            prpcClient.call, 'monorail.Issues', 'ListReferencedIssues', args);
+
+        const action = {
+          type: issueV0.FETCH_ISSUES_SUCCESS,
+          issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+        };
+        sinon.assert.calledWith(dispatch, action);
+      });
+
+      it('failure', async () => {
+        prpcClient.call.throws();
+        const dispatch = sinon.stub();
+
+        await issueV0.fetchIssues([example.ISSUE_REF])(dispatch);
+
+        const action = {
+          type: issueV0.FETCH_ISSUES_FAILURE,
+          error: sinon.match.any,
+        };
+        sinon.assert.calledWith(dispatch, action);
+      });
+    });
+
+    it('fetchIssueList calls ListIssues', async () => {
+      prpcCall.callsFake(() => {
+        return {
+          issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+          totalResults: 6,
+        };
+      });
+
+      store.dispatch(issueV0.fetchIssueList('chromium',
+          {q: 'owner:me', can: '4'}));
+
+      sinon.assert.calledWith(prpcCall, 'monorail.Issues', 'ListIssues', {
+        query: 'owner:me',
+        cannedQuery: 4,
+        projectNames: ['chromium'],
+        pagination: {},
+        groupBySpec: undefined,
+        sortSpec: undefined,
+      });
+    });
+
+    it('fetchIssueList does not set can when can is NaN', async () => {
+      prpcCall.callsFake(() => ({}));
+
+      store.dispatch(issueV0.fetchIssueList('chromium', {q: 'owner:me',
+        can: 'four-leaf-clover'}));
+
+      sinon.assert.calledWith(prpcCall, 'monorail.Issues', 'ListIssues', {
+        query: 'owner:me',
+        cannedQuery: undefined,
+        projectNames: ['chromium'],
+        pagination: {},
+        groupBySpec: undefined,
+        sortSpec: undefined,
+      });
+    });
+
+    it('fetchIssueList makes several calls to ListIssues', async () => {
+      prpcCall.callsFake(() => {
+        return {
+          issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+          totalResults: 6,
+        };
+      });
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 3, maxCalls: 2});
+      await action(dispatch);
+
+      sinon.assert.calledTwice(prpcCall);
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues:
+          [{localId: 1}, {localId: 2}, {localId: 3},
+            {localId: 1}, {localId: 2}, {localId: 3}],
+        progress: 1,
+        totalResults: 6,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    it('fetchIssueList orders issues correctly', async () => {
+      prpcCall.onFirstCall().returns({issues: [{localId: 1}], totalResults: 6});
+      prpcCall.onSecondCall().returns({
+        issues: [{localId: 2}],
+        totalResults: 6});
+      prpcCall.onThirdCall().returns({issues: [{localId: 3}], totalResults: 6});
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 1, maxCalls: 3});
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+        progress: 1,
+        totalResults: 6,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    it('returns progress of 1 when no totalIssues', async () => {
+      prpcCall.onFirstCall().returns({issues: [], totalResults: 0});
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 1, maxCalls: 1});
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues: [],
+        progress: 1,
+        totalResults: 0,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    it('returns progress of 1 when totalIssues undefined', async () => {
+      prpcCall.onFirstCall().returns({issues: []});
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 1, maxCalls: 1});
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues: [],
+        progress: 1,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    // TODO(kweng@) remove once crbug.com/monorail/6641 is fixed
+    it('has expected default for empty response', async () => {
+      prpcCall.onFirstCall().returns({});
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 1, maxCalls: 1});
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues: [],
+        progress: 1,
+        totalResults: 0,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    describe('federated references', () => {
+      beforeEach(() => {
+        // Preload signinImpl with a fake for testing.
+        getSigninInstance({
+          init: sinon.stub(),
+          getUserProfileAsync: () => (
+            Promise.resolve({
+              getEmail: sinon.stub().returns('rutabaga@google.com'),
+            })
+          ),
+        });
+        window.CS_env = {gapi_client_id: 'rutabaga'};
+        const getStub = sinon.stub().returns({
+          execute: (cb) => cb(response),
+        });
+        const response = {
+          result: {
+            resolvedTime: 12345,
+            issueState: {
+              title: 'Rutabaga title',
+            },
+          },
+        };
+        window.gapi = {
+          client: {
+            load: (_url, _version, cb) => cb(),
+            corp_issuetracker: {issues: {get: getStub}},
+          },
+        };
+      });
+
+      afterEach(() => {
+        delete window.CS_env;
+        delete window.gapi;
+      });
+
+      describe('fetchFederatedReferences', () => {
+        it('returns an empty map if no fedrefs found', async () => {
+          const dispatch = sinon.stub();
+          const testIssue = {};
+          const action = issueV0.fetchFederatedReferences(testIssue);
+          const result = await action(dispatch);
+
+          assert.equal(dispatch.getCalls().length, 1);
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_FEDERATED_REFERENCES_START',
+          });
+          assert.isUndefined(result);
+        });
+
+        it('fetches from Buganizer API', async () => {
+          const dispatch = sinon.stub();
+          const testIssue = {
+            danglingBlockingRefs: [
+              {extIdentifier: 'b/123456'},
+            ],
+            danglingBlockedOnRefs: [
+              {extIdentifier: 'b/654321'},
+            ],
+            mergedIntoIssueRef: {
+              extIdentifier: 'b/987654',
+            },
+          };
+          const action = issueV0.fetchFederatedReferences(testIssue);
+          await action(dispatch);
+
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_FEDERATED_REFERENCES_START',
+          });
+          sinon.assert.calledWith(dispatch, {
+            type: 'GAPI_LOGIN_SUCCESS',
+            email: 'rutabaga@google.com',
+          });
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+            fedRefIssueRefs: [
+              {
+                extIdentifier: 'b/123456',
+                statusRef: {meansOpen: false},
+                summary: 'Rutabaga title',
+              },
+              {
+                extIdentifier: 'b/654321',
+                statusRef: {meansOpen: false},
+                summary: 'Rutabaga title',
+              },
+              {
+                extIdentifier: 'b/987654',
+                statusRef: {meansOpen: false},
+                summary: 'Rutabaga title',
+              },
+            ],
+          });
+        });
+      });
+
+      describe('fetchRelatedIssues', () => {
+        it('calls fetchFederatedReferences for mergedinto', async () => {
+          const dispatch = sinon.stub();
+          prpcCall.returns(Promise.resolve({openRefs: [], closedRefs: []}));
+          const testIssue = {
+            mergedIntoIssueRef: {
+              extIdentifier: 'b/987654',
+            },
+          };
+          const action = issueV0.fetchRelatedIssues(testIssue);
+          await action(dispatch);
+
+          // Important: mergedinto fedref is not passed to ListReferencedIssues.
+          const expectedMessage = {issueRefs: []};
+          sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+              'ListReferencedIssues', expectedMessage);
+
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_RELATED_ISSUES_START',
+          });
+          // No mergedInto refs returned, they're handled by
+          // fetchFederatedReferences.
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_RELATED_ISSUES_SUCCESS',
+            relatedIssues: {},
+          });
+        });
+      });
+    });
+  });
+
+  describe('starring issues', () => {
+    describe('reducers', () => {
+      it('FETCH_IS_STARRED_SUCCESS updates the starredIssues object', () => {
+        const state = {};
+        const newState = issueV0.starredIssuesReducer(state,
+            {
+              type: issueV0.FETCH_IS_STARRED_SUCCESS,
+              starred: false,
+              issueRef: {
+                projectName: 'proj',
+                localId: 1,
+              },
+            },
+        );
+        assert.deepEqual(newState, {'proj:1': false});
+      });
+
+      it('FETCH_ISSUES_STARRED_SUCCESS updates the starredIssues object',
+          () => {
+            const state = {};
+            const starredIssueRefs = [{projectName: 'proj', localId: 1},
+              {projectName: 'proj', localId: 2}];
+            const newState = issueV0.starredIssuesReducer(state,
+                {type: issueV0.FETCH_ISSUES_STARRED_SUCCESS, starredIssueRefs},
+            );
+            assert.deepEqual(newState, {'proj:1': true, 'proj:2': true});
+          });
+
+      it('FETCH_ISSUES_STARRED_SUCCESS does not time out with 10,000 stars',
+          () => {
+            const state = {};
+            const starredIssueRefs = [];
+            const expected = {};
+            for (let i = 1; i <= 10000; i++) {
+              starredIssueRefs.push({projectName: 'proj', localId: i});
+              expected[`proj:${i}`] = true;
+            }
+            const newState = issueV0.starredIssuesReducer(state,
+                {type: issueV0.FETCH_ISSUES_STARRED_SUCCESS, starredIssueRefs},
+            );
+            assert.deepEqual(newState, expected);
+          });
+
+      it('STAR_SUCCESS updates the starredIssues object', () => {
+        const state = {'proj:1': true, 'proj:2': false};
+        const newState = issueV0.starredIssuesReducer(state,
+            {
+              type: issueV0.STAR_SUCCESS,
+              starred: true,
+              issueRef: {projectName: 'proj', localId: 2},
+            });
+        assert.deepEqual(newState, {'proj:1': true, 'proj:2': true});
+      });
+    });
+
+    describe('selectors', () => {
+      describe('issue', () => {
+        const selector = issueV0.issue(wrapIssue(example.ISSUE));
+        assert.deepEqual(selector(example.NAME), example.ISSUE);
+      });
+
+      describe('issueForRefString', () => {
+        const noIssues = issueV0.issueForRefString(wrapIssue({}));
+        const withIssue = issueV0.issueForRefString(wrapIssue({
+          projectName: 'test',
+          localId: 1,
+          summary: 'hello world',
+        }));
+
+        it('returns issue ref when no issue data', () => {
+          assert.deepEqual(noIssues('1', 'chromium'), {
+            localId: 1,
+            projectName: 'chromium',
+          });
+
+          assert.deepEqual(noIssues('chromium:2', 'ignore'), {
+            localId: 2,
+            projectName: 'chromium',
+          });
+
+          assert.deepEqual(noIssues('other:3'), {
+            localId: 3,
+            projectName: 'other',
+          });
+
+          assert.deepEqual(withIssue('other:3'), {
+            localId: 3,
+            projectName: 'other',
+          });
+        });
+
+        it('returns full issue data when available', () => {
+          assert.deepEqual(withIssue('1', 'test'), {
+            projectName: 'test',
+            localId: 1,
+            summary: 'hello world',
+          });
+
+          assert.deepEqual(withIssue('test:1', 'other'), {
+            projectName: 'test',
+            localId: 1,
+            summary: 'hello world',
+          });
+
+          assert.deepEqual(withIssue('test:1'), {
+            projectName: 'test',
+            localId: 1,
+            summary: 'hello world',
+          });
+        });
+      });
+
+      it('starredIssues', () => {
+        const state = {issue:
+          {starredIssues: {'proj:1': true, 'proj:2': false}}};
+        assert.deepEqual(issueV0.starredIssues(state), new Set(['proj:1']));
+      });
+
+      it('starringIssues', () => {
+        const state = {issue: {
+          requests: {
+            starringIssues: {
+              'proj:1': {requesting: true},
+              'proj:2': {requestin: false, error: 'unknown error'},
+            },
+          },
+        }};
+        assert.deepEqual(issueV0.starringIssues(state), new Map([
+          ['proj:1', {requesting: true}],
+          ['proj:2', {requestin: false, error: 'unknown error'}],
+        ]));
+      });
+    });
+
+    describe('action creators', () => {
+      beforeEach(() => {
+        prpcCall = sinon.stub(prpcClient, 'call');
+
+        dispatch = sinon.stub();
+      });
+
+      afterEach(() => {
+        prpcCall.restore();
+      });
+
+      it('fetching if an issue is starred', async () => {
+        const issueRef = {projectName: 'proj', localId: 1};
+        const action = issueV0.fetchIsStarred(issueRef);
+
+        prpcCall.returns(Promise.resolve({isStarred: true}));
+
+        await action(dispatch);
+
+        sinon.assert.calledWith(dispatch,
+            {type: issueV0.FETCH_IS_STARRED_START});
+
+        sinon.assert.calledWith(
+            prpcClient.call, 'monorail.Issues',
+            'IsIssueStarred', {issueRef},
+        );
+
+        sinon.assert.calledWith(dispatch, {
+          type: issueV0.FETCH_IS_STARRED_SUCCESS,
+          starred: true,
+          issueRef,
+        });
+      });
+
+      it('fetching starred issues', async () => {
+        const returnedIssueRef = {projectName: 'proj', localId: 1};
+        const starredIssueRefs = [returnedIssueRef];
+        const action = issueV0.fetchStarredIssues();
+
+        prpcCall.returns(Promise.resolve({starredIssueRefs}));
+
+        await action(dispatch);
+
+        sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUES_STARRED_START'});
+
+        sinon.assert.calledWith(
+            prpcClient.call, 'monorail.Issues',
+            'ListStarredIssues', {},
+        );
+
+        sinon.assert.calledWith(dispatch, {
+          type: issueV0.FETCH_ISSUES_STARRED_SUCCESS,
+          starredIssueRefs,
+        });
+      });
+
+      it('star', async () => {
+        const testIssue = {projectName: 'proj', localId: 1, starCount: 1};
+        const issueRef = issueToIssueRef(testIssue);
+        const action = issueV0.star(issueRef, false);
+
+        prpcCall.returns(Promise.resolve(testIssue));
+
+        await action(dispatch);
+
+        sinon.assert.calledWith(dispatch, {
+          type: issueV0.STAR_START,
+          requestKey: 'proj:1',
+        });
+
+        sinon.assert.calledWith(
+            prpcClient.call,
+            'monorail.Issues', 'StarIssue',
+            {issueRef, starred: false},
+        );
+
+        sinon.assert.calledWith(dispatch, {
+          type: issueV0.STAR_SUCCESS,
+          starCount: 1,
+          issueRef,
+          starred: false,
+          requestKey: 'proj:1',
+        });
+      });
+    });
+  });
+});
+
+/**
+ * Return an initial Redux state with a given viewed
+ * @param {Issue=} viewedIssue The viewed issue.
+ * @param {Object=} otherValues Any other state values that need
+ *   to be initialized.
+ * @return {Object}
+ */
+function wrapIssue(viewedIssue, otherValues = {}) {
+  if (!viewedIssue) {
+    return {
+      issue: {
+        issuesByRefString: {},
+        ...otherValues,
+      },
+    };
+  }
+
+  const ref = issueRefToString(viewedIssue);
+  return {
+    issue: {
+      viewedIssueRef: ref,
+      issuesByRefString: {
+        [ref]: {...viewedIssue},
+      },
+      ...otherValues,
+    },
+  };
+}
diff --git a/static_src/reducers/permissions.js b/static_src/reducers/permissions.js
new file mode 100644
index 0000000..2f0101b
--- /dev/null
+++ b/static_src/reducers/permissions.js
@@ -0,0 +1,118 @@
+// Copyright 2020 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.
+
+/**
+ * @fileoverview Permissions actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving permissions state
+ * on the frontend.
+ *
+ * The Permissions data is stored in a normalized format.
+ * `permissions` stores all PermissionSets[] indexed by resource name.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Permissions
+
+// Field Permissions
+export const FIELD_DEF_EDIT = 'FIELD_DEF_EDIT';
+export const FIELD_DEF_VALUE_EDIT = 'FIELD_DEF_VALUE_EDIT';
+
+// Actions
+export const BATCH_GET_START = 'permissions/BATCH_GET_START';
+export const BATCH_GET_SUCCESS = 'permissions/BATCH_GET_SUCCESS';
+export const BATCH_GET_FAILURE = 'permissions/BATCH_GET_FAILURE';
+
+/* State Shape
+{
+  byName: Object<string, PermissionSet>,
+
+  requests: {
+    batchGet: ReduxRequestState,
+  },
+}
+*/
+
+// Reducers
+
+/**
+ * All PermissionSets indexed by resource name.
+ * @param {Object<string, PermissionSet>} state The existing items.
+ * @param {AnyAction} action
+ * @param {Array<PermissionSet>} action.permissionSets
+ * @return {Object<string, PermissionSet>}
+ */
+export const byNameReducer = createReducer({}, {
+  [BATCH_GET_SUCCESS]: (state, {permissionSets}) => {
+    const newState = {...state};
+    for (const permissionSet of permissionSets) {
+      newState[permissionSet.resource] = permissionSet;
+    }
+    return newState;
+  },
+});
+
+const requestsReducer = combineReducers({
+  batchGet: createRequestReducer(
+      BATCH_GET_START, BATCH_GET_SUCCESS, BATCH_GET_FAILURE),
+});
+
+export const reducer = combineReducers({
+  byName: byNameReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns all the PermissionSets in the store as a mapping.
+ * @param {any} state
+ * @return {Object<string, PermissionSet>}
+ */
+export const byName = (state) => state.permissions.byName;
+
+/**
+ * Returns the Permissions requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.permissions.requests;
+
+// Action Creators
+
+/**
+ * Action creator to fetch PermissionSets.
+ * @param {Array<string>} names The resource names to get.
+ * @return {function(function): Promise<Array<PermissionSet>>}
+ */
+export const batchGet = (names) => async (dispatch) => {
+  dispatch({type: BATCH_GET_START});
+
+  try {
+    /** @type {{permissionSets: Array<PermissionSet>}} */
+    const {permissionSets} = await prpcClient.call(
+        'monorail.v3.Permissions', 'BatchGetPermissionSets', {names});
+
+    for (const permissionSet of permissionSets) {
+      if (!permissionSet.permissions) {
+        permissionSet.permissions = [];
+      }
+    }
+    dispatch({type: BATCH_GET_SUCCESS, permissionSets});
+
+    return permissionSets;
+  } catch (error) {
+    dispatch({type: BATCH_GET_FAILURE, error});
+  };
+};
diff --git a/static_src/reducers/permissions.test.js b/static_src/reducers/permissions.test.js
new file mode 100644
index 0000000..3c29076
--- /dev/null
+++ b/static_src/reducers/permissions.test.js
@@ -0,0 +1,105 @@
+// Copyright 2020 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 * as permissions from './permissions.js';
+import * as example from 'shared/test/constants-permissions.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('permissions reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = permissions.reducer(undefined, {type: null});
+    const expected = {
+      byName: {},
+      requests: {
+        batchGet: {error: null, requesting: false},
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('byName updates on BATCH_GET_SUCCESS', () => {
+    const action = {
+      type: permissions.BATCH_GET_SUCCESS,
+      permissionSets: [example.PERMISSION_SET_ISSUE],
+    };
+    const actual = permissions.byNameReducer({}, action);
+    const expected = {
+      [example.PERMISSION_SET_ISSUE.resource]: example.PERMISSION_SET_ISSUE,
+    };
+    assert.deepEqual(actual, expected);
+  });
+});
+
+describe('permissions selectors', () => {
+  it('byName', () => {
+    const state = {permissions: {byName: example.BY_NAME}};
+    const actual = permissions.byName(state);
+    assert.deepEqual(actual, example.BY_NAME);
+  });
+});
+
+describe('permissions action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('batchGet', () => {
+    it('success', async () => {
+      const response = {permissionSets: [example.PERMISSION_SET_ISSUE]};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: permissions.BATCH_GET_START});
+
+      const args = {names: [exampleIssues.NAME]};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Permissions',
+          'BatchGetPermissionSets', args);
+
+      const action = {
+        type: permissions.BATCH_GET_SUCCESS,
+        permissionSets: [example.PERMISSION_SET_ISSUE],
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+      const action = {
+        type: permissions.BATCH_GET_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('fills in permissions field', async () => {
+      const response = {permissionSets: [{resource: exampleIssues.NAME}]};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+      const action = {
+        type: permissions.BATCH_GET_SUCCESS,
+        permissionSets: [{resource: exampleIssues.NAME, permissions: []}],
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
diff --git a/static_src/reducers/projectV0.js b/static_src/reducers/projectV0.js
new file mode 100644
index 0000000..5101ff8
--- /dev/null
+++ b/static_src/reducers/projectV0.js
@@ -0,0 +1,586 @@
+// 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 {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import * as permissions from 'reducers/permissions.js';
+import {fieldTypes, SITEWIDE_DEFAULT_COLUMNS, defaultIssueFieldMap,
+  parseColSpec, stringValuesForIssueField} from 'shared/issue-fields.js';
+import {hasPrefix, removePrefix} from 'shared/helpers.js';
+import {fieldNameToLabelPrefix,
+  labelNameToLabelPrefixes, labelNameToLabelValue, fieldDefToName,
+  restrictionLabelsForPermissions} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const SELECT = 'projectV0/SELECT';
+
+const FETCH_CONFIG_START = 'projectV0/FETCH_CONFIG_START';
+export const FETCH_CONFIG_SUCCESS = 'projectV0/FETCH_CONFIG_SUCCESS';
+const FETCH_CONFIG_FAILURE = 'projectV0/FETCH_CONFIG_FAILURE';
+
+export const FETCH_PRESENTATION_CONFIG_START =
+  'projectV0/FETCH_PRESENTATION_CONFIG_START';
+export const FETCH_PRESENTATION_CONFIG_SUCCESS =
+  'projectV0/FETCH_PRESENTATION_CONFIG_SUCCESS';
+export const FETCH_PRESENTATION_CONFIG_FAILURE =
+  'projectV0/FETCH_PRESENTATION_CONFIG_FAILURE';
+
+export const FETCH_CUSTOM_PERMISSIONS_START =
+  'projectV0/FETCH_CUSTOM_PERMISSIONS_START';
+export const FETCH_CUSTOM_PERMISSIONS_SUCCESS =
+  'projectV0/FETCH_CUSTOM_PERMISSIONS_SUCCESS';
+export const FETCH_CUSTOM_PERMISSIONS_FAILURE =
+  'projectV0/FETCH_CUSTOM_PERMISSIONS_FAILURE';
+
+
+export const FETCH_VISIBLE_MEMBERS_START =
+  'projectV0/FETCH_VISIBLE_MEMBERS_START';
+export const FETCH_VISIBLE_MEMBERS_SUCCESS =
+  'projectV0/FETCH_VISIBLE_MEMBERS_SUCCESS';
+export const FETCH_VISIBLE_MEMBERS_FAILURE =
+  'projectV0/FETCH_VISIBLE_MEMBERS_FAILURE';
+
+const FETCH_TEMPLATES_START = 'projectV0/FETCH_TEMPLATES_START';
+export const FETCH_TEMPLATES_SUCCESS = 'projectV0/FETCH_TEMPLATES_SUCCESS';
+const FETCH_TEMPLATES_FAILURE = 'projectV0/FETCH_TEMPLATES_FAILURE';
+
+/* State Shape
+{
+  name: string,
+
+  configs: Object<string, Config>,
+  presentationConfigs: Object<string, PresentationConfig>,
+  customPermissions: Object<string, Array<string>>,
+  visibleMembers:
+      Object<string, {userRefs: Array<UserRef>, groupRefs: Array<UserRef>}>,
+  templates: Object<string, Array<TemplateDef>>,
+  presentationConfigsLoaded: Object<string, boolean>,
+
+  requests: {
+    fetchConfig: ReduxRequestState,
+    fetchMembers: ReduxRequestState
+    fetchCustomPermissions: ReduxRequestState,
+    fetchPresentationConfig: ReduxRequestState,
+    fetchTemplates: ReduxRequestState,
+  },
+}
+*/
+
+// Reducers
+export const nameReducer = createReducer(null, {
+  [SELECT]: (_state, {projectName}) => projectName,
+});
+
+export const configsReducer = createReducer({}, {
+  [FETCH_CONFIG_SUCCESS]: (state, {projectName, config}) => ({
+    ...state,
+    [projectName]: config,
+  }),
+});
+
+export const presentationConfigsReducer = createReducer({}, {
+  [FETCH_PRESENTATION_CONFIG_SUCCESS]:
+    (state, {projectName, presentationConfig}) => ({
+      ...state,
+      [projectName]: presentationConfig,
+    }),
+});
+
+/**
+ * Adds custom permissions to Redux in a normalized state.
+ * @param {Object<string, Array<String>>} state Redux state.
+ * @param {AnyAction} Action
+ * @return {Object<string, Array<String>>}
+ */
+export const customPermissionsReducer = createReducer({}, {
+  [FETCH_CUSTOM_PERMISSIONS_SUCCESS]:
+    (state, {projectName, permissions}) => ({
+      ...state,
+      [projectName]: permissions,
+    }),
+});
+
+export const visibleMembersReducer = createReducer({}, {
+  [FETCH_VISIBLE_MEMBERS_SUCCESS]: (state, {projectName, visibleMembers}) => ({
+    ...state,
+    [projectName]: visibleMembers,
+  }),
+});
+
+export const templatesReducer = createReducer({}, {
+  [FETCH_TEMPLATES_SUCCESS]: (state, {projectName, templates}) => ({
+    ...state,
+    [projectName]: templates,
+  }),
+});
+
+const requestsReducer = combineReducers({
+  fetchConfig: createRequestReducer(
+      FETCH_CONFIG_START, FETCH_CONFIG_SUCCESS, FETCH_CONFIG_FAILURE),
+  fetchMembers: createRequestReducer(
+      FETCH_VISIBLE_MEMBERS_START,
+      FETCH_VISIBLE_MEMBERS_SUCCESS,
+      FETCH_VISIBLE_MEMBERS_FAILURE),
+  fetchCustomPermissions: createRequestReducer(
+      FETCH_CUSTOM_PERMISSIONS_START,
+      FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      FETCH_CUSTOM_PERMISSIONS_FAILURE),
+  fetchPresentationConfig: createRequestReducer(
+      FETCH_PRESENTATION_CONFIG_START,
+      FETCH_PRESENTATION_CONFIG_SUCCESS,
+      FETCH_PRESENTATION_CONFIG_FAILURE),
+  fetchTemplates: createRequestReducer(
+      FETCH_TEMPLATES_START, FETCH_TEMPLATES_SUCCESS, FETCH_TEMPLATES_FAILURE),
+});
+
+export const reducer = combineReducers({
+  name: nameReducer,
+  configs: configsReducer,
+  customPermissions: customPermissionsReducer,
+  presentationConfigs: presentationConfigsReducer,
+  visibleMembers: visibleMembersReducer,
+  templates: templatesReducer,
+  requests: requestsReducer,
+});
+
+// Selectors
+export const project = (state) => state.projectV0 || {};
+
+export const viewedProjectName =
+  createSelector(project, (project) => project.name || null);
+
+export const configs =
+  createSelector(project, (project) => project.configs || {});
+export const presentationConfigs =
+  createSelector(project, (project) => project.presentationConfigs || {});
+export const customPermissions =
+  createSelector(project, (project) => project.customPermissions || {});
+export const visibleMembers =
+  createSelector(project, (project) => project.visibleMembers || {});
+export const templates =
+  createSelector(project, (project) => project.templates || {});
+
+export const viewedConfig = createSelector(
+    [viewedProjectName, configs],
+    (projectName, configs) => configs[projectName] || {});
+export const viewedPresentationConfig = createSelector(
+    [viewedProjectName, presentationConfigs],
+    (projectName, configs) => configs[projectName] || {});
+
+// TODO(crbug.com/monorail/7080): Come up with a more clear and
+// consistent pattern for determining when data is loaded.
+export const viewedPresentationConfigLoaded = createSelector(
+    [viewedProjectName, presentationConfigs],
+    (projectName, configs) => !!configs[projectName]);
+export const viewedCustomPermissions = createSelector(
+    [viewedProjectName, customPermissions],
+    (projectName, permissions) => permissions[projectName] || []);
+export const viewedVisibleMembers = createSelector(
+    [viewedProjectName, visibleMembers],
+    (projectName, visibleMembers) => visibleMembers[projectName] || {});
+export const viewedTemplates = createSelector(
+    [viewedProjectName, templates],
+    (projectName, templates) => templates[projectName] || []);
+
+/**
+ * Get the default columns for the currently viewed project.
+ */
+export const defaultColumns = createSelector(viewedPresentationConfig,
+    ({defaultColSpec}) =>{
+      if (defaultColSpec) {
+        return parseColSpec(defaultColSpec);
+      }
+      return SITEWIDE_DEFAULT_COLUMNS;
+    });
+
+
+/**
+ * Get the default query for the currently viewed project.
+ */
+export const defaultQuery = createSelector(viewedPresentationConfig,
+    (config) => config.defaultQuery || '');
+
+// Look up components by path.
+export const componentsMap = createSelector(
+    viewedConfig,
+    (config) => {
+      if (!config || !config.componentDefs) return new Map();
+      const acc = new Map();
+      for (const v of config.componentDefs) {
+        acc.set(v.path, v);
+      }
+      return acc;
+    },
+);
+
+export const fieldDefs = createSelector(
+    viewedConfig, (config) => ((config && config.fieldDefs) || []),
+);
+
+export const fieldDefMap = createSelector(
+    fieldDefs, (fieldDefs) => {
+      const map = new Map();
+      fieldDefs.forEach((fd) => {
+        map.set(fd.fieldRef.fieldName.toLowerCase(), fd);
+      });
+      return map;
+    },
+);
+
+export const labelDefs = createSelector(
+    [viewedConfig, viewedCustomPermissions],
+    (config, permissions) => [
+      ...((config && config.labelDefs) || []),
+      ...restrictionLabelsForPermissions(permissions),
+    ],
+);
+
+// labelDefs stored in an easily findable format with label names as keys.
+export const labelDefMap = createSelector(
+    labelDefs, (labelDefs) => {
+      const map = new Map();
+      labelDefs.forEach((ld) => {
+        map.set(ld.label.toLowerCase(), ld);
+      });
+      return map;
+    },
+);
+
+/**
+ * A selector that builds a map where keys are label prefixes
+ * and values equal to sets of possible values corresponding to the prefix
+ * @param {Object} state Current Redux state.
+ * @return {Map}
+ */
+export const labelPrefixValueMap = createSelector(labelDefs, (labelDefs) => {
+  const prefixMap = new Map();
+  labelDefs.forEach((ld) => {
+    const prefixes = labelNameToLabelPrefixes(ld.label);
+
+    prefixes.forEach((prefix) => {
+      if (prefixMap.has(prefix)) {
+        prefixMap.get(prefix).add(labelNameToLabelValue(ld.label, prefix));
+      } else {
+        prefixMap.set(prefix, new Set(
+            [labelNameToLabelValue(ld.label, prefix)]));
+      }
+    });
+  });
+
+  return prefixMap;
+});
+
+/**
+ * A selector that builds an array of label prefixes, keeping casing intact
+ * Some labels are implicitly used as custom fields in the grid and list view.
+ * Only labels with more than one option are included, to reduce noise.
+ * @param {Object} state Current Redux state.
+ * @return {Array}
+ */
+export const labelPrefixFields = createSelector(
+    labelPrefixValueMap, (map) => {
+      const prefixes = [];
+
+      map.forEach((options, prefix) => {
+      // Ignore label prefixes with only one value.
+        if (options.size > 1) {
+          prefixes.push(prefix);
+        }
+      });
+
+      return prefixes;
+    },
+);
+
+/**
+ * A selector that wraps labelPrefixFields arrays as set for fast lookup.
+ * @param {Object} state Current Redux state.
+ * @return {Set}
+ */
+export const labelPrefixSet = createSelector(
+    labelPrefixFields, (fields) => new Set(fields.map(
+        (field) => field.toLowerCase())),
+);
+
+export const enumFieldDefs = createSelector(
+    fieldDefs,
+    (fieldDefs) => {
+      return fieldDefs.filter(
+          (fd) => fd.fieldRef.type === fieldTypes.ENUM_TYPE);
+    },
+);
+
+/**
+ * A selector that builds a function that's used to compute the value of
+ * a given field name on a given issue. This function abstracts the difference
+ * between custom fields, built-in fields, and implicit fields created
+ * from labels and considers these values in the context of the current
+ * project configuration.
+ * @param {Object} state Current Redux state.
+ * @return {function(Issue, string): Array<string>} A function that processes a
+ *   given issue and field name to find the string value for that field, in
+ *   the issue.
+ */
+export const extractFieldValuesFromIssue = createSelector(
+    viewedProjectName,
+    (projectName) => (issue, fieldName) =>
+      stringValuesForIssueField(issue, fieldName, projectName),
+);
+
+/**
+ * A selector that builds a function that's used to compute the type of a given
+ * field name.
+ * @param {Object} state Current Redux state.
+ * @return {function(string): string}
+ */
+export const extractTypeForFieldName = createSelector(fieldDefMap,
+    (fieldDefMap) => {
+      return (fieldName) => {
+        const key = fieldName.toLowerCase();
+
+        // If the field is a built in field. Default fields have precedence
+        // over custom fields.
+        if (defaultIssueFieldMap.hasOwnProperty(key)) {
+          return defaultIssueFieldMap[key].type;
+        }
+
+        // If the field is a custom field. Custom fields have precedence
+        // over label prefixes.
+        if (fieldDefMap.has(key)) {
+          return fieldDefMap.get(key).fieldRef.type;
+        }
+
+        // Default to STR_TYPE, including for label fields.
+        return fieldTypes.STR_TYPE;
+      };
+    },
+);
+
+export const optionsPerEnumField = createSelector(
+    enumFieldDefs,
+    labelDefs,
+    (fieldDefs, labelDefs) => {
+      const map = new Map(fieldDefs.map(
+          (fd) => [fd.fieldRef.fieldName.toLowerCase(), []]));
+      labelDefs.forEach((ld) => {
+        const labelName = ld.label;
+
+        const fd = fieldDefs.find((fd) => hasPrefix(
+            labelName, fieldNameToLabelPrefix(fd.fieldRef.fieldName)));
+        if (fd) {
+          const key = fd.fieldRef.fieldName.toLowerCase();
+          map.get(key).push({
+            ...ld,
+            optionName: removePrefix(labelName,
+                fieldNameToLabelPrefix(fd.fieldRef.fieldName)),
+          });
+        }
+      });
+      return map;
+    },
+);
+
+export const fieldDefsForPhases = createSelector(
+    fieldDefs,
+    (fieldDefs) => {
+      if (!fieldDefs) return [];
+      return fieldDefs.filter((f) => f.isPhaseField);
+    },
+);
+
+export const fieldDefsByApprovalName = createSelector(
+    fieldDefs,
+    (fieldDefs) => {
+      if (!fieldDefs) return new Map();
+      const acc = new Map();
+      for (const fd of fieldDefs) {
+        if (fd.fieldRef && fd.fieldRef.approvalName) {
+          if (acc.has(fd.fieldRef.approvalName)) {
+            acc.get(fd.fieldRef.approvalName).push(fd);
+          } else {
+            acc.set(fd.fieldRef.approvalName, [fd]);
+          }
+        }
+      }
+      return acc;
+    },
+);
+
+export const fetchingConfig = (state) => {
+  return state.projectV0.requests.fetchConfig.requesting;
+};
+
+/**
+ * Shorthand method for detecting whether we are currently
+ * fetching presentationConcifg
+ * @param {Object} state Current Redux state.
+ * @return {boolean}
+ */
+export const fetchingPresentationConfig = (state) => {
+  return state.projectV0.requests.fetchPresentationConfig.requesting;
+};
+
+// Action Creators
+/**
+ * Action creator to set the currently viewed Project.
+ * @param {string} projectName The name of the Project to select.
+ * @return {function(function): Promise<void>}
+ */
+export const select = (projectName) => {
+  return (dispatch) => dispatch({type: SELECT, projectName});
+};
+
+/**
+ * Fetches data required to view project.
+ * @param {string} projectName
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (projectName) => async (dispatch) => {
+  const configPromise = dispatch(fetchConfig(projectName));
+  const visibleMembersPromise = dispatch(fetchVisibleMembers(projectName));
+
+  dispatch(fetchPresentationConfig(projectName));
+  dispatch(fetchTemplates(projectName));
+
+  const customPermissionsPromise = dispatch(
+      fetchCustomPermissions(projectName));
+
+  // TODO(crbug.com/monorail/5828): Remove window.TKR_populateAutocomplete once
+  // the old autocomplete code is deprecated.
+  const [config, visibleMembers, customPermissions] = await Promise.all([
+    configPromise,
+    visibleMembersPromise,
+    customPermissionsPromise]);
+  config.labelDefs = [...config.labelDefs,
+    ...restrictionLabelsForPermissions(customPermissions)];
+  dispatch(fetchFieldPerms(config.projectName, config.fieldDefs));
+  // eslint-disable-next-line new-cap
+  window.TKR_populateAutocomplete(config, visibleMembers, customPermissions);
+};
+
+/**
+ * Fetches project configuration including things like the custom fields in a
+ * project, the statuses, etc.
+ * @param {string} projectName
+ * @return {function(function): Promise<Config>}
+ */
+const fetchConfig = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_CONFIG_START});
+
+  const getConfig = prpcClient.call(
+      'monorail.Projects', 'GetConfig', {projectName});
+
+  try {
+    const config = await getConfig;
+    dispatch({type: FETCH_CONFIG_SUCCESS, projectName, config});
+    return config;
+  } catch (error) {
+    dispatch({type: FETCH_CONFIG_FAILURE, error});
+  }
+};
+
+export const fetchPresentationConfig = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_PRESENTATION_CONFIG_START});
+
+  try {
+    const presentationConfig = await prpcClient.call(
+        'monorail.Projects', 'GetPresentationConfig', {projectName});
+    dispatch({
+      type: FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName,
+      presentationConfig,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_PRESENTATION_CONFIG_FAILURE, error});
+  }
+};
+
+/**
+ * Fetches custom permissions defined for a project.
+ * @param {string} projectName
+ * @return {function(function): Promise<Array<string>>}
+ */
+export const fetchCustomPermissions = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_CUSTOM_PERMISSIONS_START});
+
+  try {
+    const {permissions} = await prpcClient.call(
+        'monorail.Projects', 'GetCustomPermissions', {projectName});
+    dispatch({
+      type: FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      projectName,
+      permissions,
+    });
+    return permissions;
+  } catch (error) {
+    dispatch({type: FETCH_CUSTOM_PERMISSIONS_FAILURE, error});
+  }
+};
+
+/**
+ * Fetches the project members that the user is able to view.
+ * @param {string} projectName
+ * @return {function(function): Promise<GetVisibleMembersResponse>}
+ */
+export const fetchVisibleMembers = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_VISIBLE_MEMBERS_START});
+
+  try {
+    const visibleMembers = await prpcClient.call(
+        'monorail.Projects', 'GetVisibleMembers', {projectName});
+    dispatch({
+      type: FETCH_VISIBLE_MEMBERS_SUCCESS,
+      projectName,
+      visibleMembers,
+    });
+    return visibleMembers;
+  } catch (error) {
+    dispatch({type: FETCH_VISIBLE_MEMBERS_FAILURE, error});
+  }
+};
+
+const fetchTemplates = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_TEMPLATES_START});
+
+  const listTemplates = prpcClient.call(
+      'monorail.Projects', 'ListProjectTemplates', {projectName});
+
+  // TODO(zhangtiff): Remove (see above TODO).
+  if (!listTemplates) return;
+
+  try {
+    const resp = await listTemplates;
+    dispatch({
+      type: FETCH_TEMPLATES_SUCCESS,
+      projectName,
+      templates: resp.templates,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_TEMPLATES_FAILURE, error});
+  }
+};
+
+// Helpers
+
+/**
+ * Helper to fetch field permissions.
+ * @param {string} projectName The name of the project where the fields are.
+ * @param {Array<FieldDef>} fieldDefs
+ * @return {function(function): Promise<void>}
+ */
+export const fetchFieldPerms = (projectName, fieldDefs) => async (dispatch) => {
+  const fieldDefsNames = [];
+  if (fieldDefs) {
+    fieldDefs.forEach((fd) => {
+      const fieldDefName = fieldDefToName(projectName, fd);
+      fieldDefsNames.push(fieldDefName);
+    });
+  }
+  await dispatch(permissions.batchGet(fieldDefsNames));
+};
diff --git a/static_src/reducers/projectV0.test.js b/static_src/reducers/projectV0.test.js
new file mode 100644
index 0000000..fb1f051
--- /dev/null
+++ b/static_src/reducers/projectV0.test.js
@@ -0,0 +1,944 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+import * as projectV0 from './projectV0.js';
+import {store} from './base.js';
+import * as example from 'shared/test/constants-projectV0.js';
+import {restrictionLabelsForPermissions} from 'shared/convertersV0.js';
+import {fieldTypes, SITEWIDE_DEFAULT_COLUMNS} from 'shared/issue-fields.js';
+
+describe('project reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = projectV0.reducer(undefined, {type: null});
+    const expected = {
+      name: null,
+      configs: {},
+      presentationConfigs: {},
+      customPermissions: {},
+      visibleMembers: {},
+      templates: {},
+      requests: {
+        fetchConfig: {
+          error: null,
+          requesting: false,
+        },
+        fetchCustomPermissions: {
+          error: null,
+          requesting: false,
+        },
+        fetchMembers: {
+          error: null,
+          requesting: false,
+        },
+        fetchPresentationConfig: {
+          error: null,
+          requesting: false,
+        },
+        fetchTemplates: {
+          error: null,
+          requesting: false,
+        },
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('name', () => {
+    const action = {type: projectV0.SELECT, projectName: example.PROJECT_NAME};
+    assert.deepEqual(projectV0.nameReducer(null, action), example.PROJECT_NAME);
+  });
+
+  it('configs updates when fetching Config', () => {
+    const action = {
+      type: projectV0.FETCH_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      config: example.CONFIG,
+    };
+    const expected = {[example.PROJECT_NAME]: example.CONFIG};
+    assert.deepEqual(projectV0.configsReducer({}, action), expected);
+  });
+
+  it('customPermissions', () => {
+    const action = {
+      type: projectV0.FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      permissions: example.CUSTOM_PERMISSIONS,
+    };
+    const expected = {[example.PROJECT_NAME]: example.CUSTOM_PERMISSIONS};
+    assert.deepEqual(projectV0.customPermissionsReducer({}, action), expected);
+  });
+
+  it('presentationConfigs', () => {
+    const action = {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      presentationConfig: example.PRESENTATION_CONFIG,
+    };
+    const expected = {[example.PROJECT_NAME]: example.PRESENTATION_CONFIG};
+    assert.deepEqual(projectV0.presentationConfigsReducer({}, action),
+      expected);
+  });
+
+  it('visibleMembers', () => {
+    const action = {
+      type: projectV0.FETCH_VISIBLE_MEMBERS_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      visibleMembers: example.VISIBLE_MEMBERS,
+    };
+    const expected = {[example.PROJECT_NAME]: example.VISIBLE_MEMBERS};
+    assert.deepEqual(projectV0.visibleMembersReducer({}, action), expected);
+  });
+
+  it('templates', () => {
+    const action = {
+      type: projectV0.FETCH_TEMPLATES_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      templates: [example.TEMPLATE_DEF],
+    };
+    const expected = {[example.PROJECT_NAME]: [example.TEMPLATE_DEF]};
+    assert.deepEqual(projectV0.templatesReducer({}, action), expected);
+  });
+});
+
+describe('project selectors', () => {
+  it('viewedProjectName', () => {
+    const actual = projectV0.viewedProjectName(example.STATE);
+    assert.deepEqual(actual, example.PROJECT_NAME);
+  });
+
+  it('viewedVisibleMembers', () => {
+    assert.deepEqual(projectV0.viewedVisibleMembers({}), {});
+    assert.deepEqual(projectV0.viewedVisibleMembers({projectV0: {}}), {});
+    assert.deepEqual(projectV0.viewedVisibleMembers(
+        {projectV0: {visibleMembers: {}}}), {});
+    const actual = projectV0.viewedVisibleMembers(example.STATE);
+    assert.deepEqual(actual, example.VISIBLE_MEMBERS);
+  });
+
+  it('viewedCustomPermissions', () => {
+    assert.deepEqual(projectV0.viewedCustomPermissions({}), []);
+    assert.deepEqual(projectV0.viewedCustomPermissions({projectV0: {}}), []);
+    assert.deepEqual(projectV0.viewedCustomPermissions(
+        {projectV0: {customPermissions: {}}}), []);
+    const actual = projectV0.viewedCustomPermissions(example.STATE);
+    assert.deepEqual(actual, example.CUSTOM_PERMISSIONS);
+  });
+
+  it('viewedPresentationConfig', () => {
+    assert.deepEqual(projectV0.viewedPresentationConfig({}), {});
+    assert.deepEqual(projectV0.viewedPresentationConfig({projectV0: {}}), {});
+    const actual = projectV0.viewedPresentationConfig(example.STATE);
+    assert.deepEqual(actual, example.PRESENTATION_CONFIG);
+  });
+
+  it('defaultColumns', () => {
+    assert.deepEqual(projectV0.defaultColumns({}), SITEWIDE_DEFAULT_COLUMNS);
+    assert.deepEqual(
+        projectV0.defaultColumns({projectV0: {}}), SITEWIDE_DEFAULT_COLUMNS);
+    assert.deepEqual(
+        projectV0.defaultColumns({projectV0: {presentationConfig: {}}}),
+        SITEWIDE_DEFAULT_COLUMNS);
+    const expected = ['ID', 'Summary', 'AllLabels'];
+    assert.deepEqual(projectV0.defaultColumns(example.STATE), expected);
+  });
+
+  it('defaultQuery', () => {
+    assert.deepEqual(projectV0.defaultQuery({}), '');
+    assert.deepEqual(projectV0.defaultQuery({projectV0: {}}), '');
+    const actual = projectV0.defaultQuery(example.STATE);
+    assert.deepEqual(actual, example.DEFAULT_QUERY);
+  });
+
+  it('fieldDefs', () => {
+    assert.deepEqual(projectV0.fieldDefs({projectV0: {}}), []);
+    assert.deepEqual(projectV0.fieldDefs({projectV0: {config: {}}}), []);
+    const actual = projectV0.fieldDefs(example.STATE);
+    assert.deepEqual(actual, example.FIELD_DEFS);
+  });
+
+  it('labelDefMap', () => {
+    const labelDefs = (permissions) =>
+        restrictionLabelsForPermissions(permissions).map((labelDef) =>
+            [labelDef.label.toLowerCase(), labelDef]);
+
+    assert.deepEqual(
+      projectV0.labelDefMap({projectV0: {}}), new Map(labelDefs([])));
+    assert.deepEqual(
+      projectV0.labelDefMap({projectV0: {config: {}}}), new Map(labelDefs([])));
+    const expected = new Map([
+      ['one', {label: 'One'}],
+      ['enum', {label: 'EnUm'}],
+      ['enum-options', {label: 'eNuM-Options'}],
+      ['hello-world', {label: 'hello-world', docstring: 'hmmm'}],
+      ['hello-me', {label: 'hello-me', docstring: 'hmmm'}],
+      ...labelDefs(example.CUSTOM_PERMISSIONS),
+    ]);
+    assert.deepEqual(projectV0.labelDefMap(example.STATE), expected);
+  });
+
+  it('labelPrefixValueMap', () => {
+    const builtInLabelPrefixes = [
+      ['Restrict', new Set(['View-EditIssue', 'AddIssueComment-EditIssue'])],
+      ['Restrict-View', new Set(['EditIssue'])],
+      ['Restrict-AddIssueComment', new Set(['EditIssue'])],
+    ];
+    assert.deepEqual(projectV0.labelPrefixValueMap({projectV0: {}}),
+        new Map(builtInLabelPrefixes));
+
+    assert.deepEqual(projectV0.labelPrefixValueMap(
+        {projectV0: {config: {}}}), new Map(builtInLabelPrefixes));
+
+    const expected = new Map([
+      ['Restrict', new Set(['View-Google', 'View-Security', 'EditIssue-Google',
+          'EditIssue-Security', 'AddIssueComment-Google',
+          'AddIssueComment-Security', 'DeleteIssue-Google',
+          'DeleteIssue-Security', 'FlagSpam-Google', 'FlagSpam-Security',
+          'View-EditIssue', 'AddIssueComment-EditIssue'])],
+      ['Restrict-View', new Set(['Google', 'Security', 'EditIssue'])],
+      ['Restrict-EditIssue', new Set(['Google', 'Security'])],
+      ['Restrict-AddIssueComment', new Set(['Google', 'Security', 'EditIssue'])],
+      ['Restrict-DeleteIssue', new Set(['Google', 'Security'])],
+      ['Restrict-FlagSpam', new Set(['Google', 'Security'])],
+      ['eNuM', new Set(['Options'])],
+      ['hello', new Set(['world', 'me'])],
+    ]);
+    assert.deepEqual(projectV0.labelPrefixValueMap(example.STATE), expected);
+  });
+
+  it('labelPrefixFields', () => {
+    const fields1 = projectV0.labelPrefixFields({projectV0: {}});
+    assert.deepEqual(fields1, ['Restrict']);
+    const fields2 = projectV0.labelPrefixFields({projectV0: {config: {}}});
+    assert.deepEqual(fields2, ['Restrict']);
+    const expected = [
+      'hello', 'Restrict', 'Restrict-View', 'Restrict-EditIssue',
+      'Restrict-AddIssueComment', 'Restrict-DeleteIssue', 'Restrict-FlagSpam'
+    ];
+    assert.deepEqual(projectV0.labelPrefixFields(example.STATE), expected);
+  });
+
+  it('enumFieldDefs', () => {
+    assert.deepEqual(projectV0.enumFieldDefs({projectV0: {}}), []);
+    assert.deepEqual(projectV0.enumFieldDefs({projectV0: {config: {}}}), []);
+    const expected = [example.FIELD_DEF_ENUM];
+    assert.deepEqual(projectV0.enumFieldDefs(example.STATE), expected);
+  });
+
+  it('optionsPerEnumField', () => {
+    assert.deepEqual(projectV0.optionsPerEnumField({projectV0: {}}), new Map());
+    const expected = new Map([
+      ['enum', [
+        {label: 'eNuM-Options', optionName: 'Options'},
+      ]],
+    ]);
+    assert.deepEqual(projectV0.optionsPerEnumField(example.STATE), expected);
+  });
+
+  it('viewedPresentationConfigLoaded', () => {
+    const loadConfigAction = {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      presentationConfig: example.PRESENTATION_CONFIG,
+    };
+    const selectProjectAction = {
+      type: projectV0.SELECT,
+      projectName: example.PROJECT_NAME,
+    };
+    let projectState = {};
+
+    assert.equal(false, projectV0.viewedPresentationConfigLoaded(
+        {projectV0: projectState}));
+
+    projectState = projectV0.reducer(projectState, selectProjectAction);
+    projectState = projectV0.reducer(projectState, loadConfigAction);
+
+    assert.equal(true, projectV0.viewedPresentationConfigLoaded(
+        {projectV0: projectState}));
+  });
+
+  it('fetchingPresentationConfig', () => {
+    const projectState = projectV0.reducer(undefined, {type: null});
+    assert.equal(false,
+        projectState.requests.fetchPresentationConfig.requesting);
+  });
+
+  describe('extractTypeForFieldName', () => {
+    let typeExtractor;
+
+    describe('built-in fields', () => {
+      beforeEach(() => {
+        typeExtractor = projectV0.extractTypeForFieldName({});
+      });
+
+      it('not case sensitive', () => {
+        assert.deepEqual(typeExtractor('id'), fieldTypes.ISSUE_TYPE);
+        assert.deepEqual(typeExtractor('iD'), fieldTypes.ISSUE_TYPE);
+        assert.deepEqual(typeExtractor('Id'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for ID', () => {
+        assert.deepEqual(typeExtractor('ID'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Project', () => {
+        assert.deepEqual(typeExtractor('Project'), fieldTypes.PROJECT_TYPE);
+      });
+
+      it('gets type for Attachments', () => {
+        assert.deepEqual(typeExtractor('Attachments'), fieldTypes.INT_TYPE);
+      });
+
+      it('gets type for AllLabels', () => {
+        assert.deepEqual(typeExtractor('AllLabels'), fieldTypes.LABEL_TYPE);
+      });
+
+      it('gets type for AllLabels', () => {
+        assert.deepEqual(typeExtractor('AllLabels'), fieldTypes.LABEL_TYPE);
+      });
+
+      it('gets type for Blocked', () => {
+        assert.deepEqual(typeExtractor('Blocked'), fieldTypes.STR_TYPE);
+      });
+
+      it('gets type for BlockedOn', () => {
+        assert.deepEqual(typeExtractor('BlockedOn'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Blocking', () => {
+        assert.deepEqual(typeExtractor('Blocking'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for CC', () => {
+        assert.deepEqual(typeExtractor('CC'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for Closed', () => {
+        assert.deepEqual(typeExtractor('Closed'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Component', () => {
+        assert.deepEqual(typeExtractor('Component'), fieldTypes.COMPONENT_TYPE);
+      });
+
+      it('gets type for ComponentModified', () => {
+        assert.deepEqual(typeExtractor('ComponentModified'),
+            fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for MergedInto', () => {
+        assert.deepEqual(typeExtractor('MergedInto'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Modified', () => {
+        assert.deepEqual(typeExtractor('Modified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Reporter', () => {
+        assert.deepEqual(typeExtractor('Reporter'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for Stars', () => {
+        assert.deepEqual(typeExtractor('Stars'), fieldTypes.INT_TYPE);
+      });
+
+      it('gets type for Status', () => {
+        assert.deepEqual(typeExtractor('Status'), fieldTypes.STATUS_TYPE);
+      });
+
+      it('gets type for StatusModified', () => {
+        assert.deepEqual(typeExtractor('StatusModified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Summary', () => {
+        assert.deepEqual(typeExtractor('Summary'), fieldTypes.STR_TYPE);
+      });
+
+      it('gets type for Type', () => {
+        assert.deepEqual(typeExtractor('Type'), fieldTypes.ENUM_TYPE);
+      });
+
+      it('gets type for Owner', () => {
+        assert.deepEqual(typeExtractor('Owner'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for OwnerModified', () => {
+        assert.deepEqual(typeExtractor('OwnerModified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Opened', () => {
+        assert.deepEqual(typeExtractor('Opened'), fieldTypes.TIME_TYPE);
+      });
+    });
+
+    it('gets types for custom fields', () => {
+      typeExtractor = projectV0.extractTypeForFieldName({projectV0: {
+        name: example.PROJECT_NAME,
+        configs: {[example.PROJECT_NAME]: {fieldDefs: [
+          {fieldRef: {fieldName: 'CustomIntField', type: 'INT_TYPE'}},
+          {fieldRef: {fieldName: 'CustomStrField', type: 'STR_TYPE'}},
+          {fieldRef: {fieldName: 'CustomUserField', type: 'USER_TYPE'}},
+          {fieldRef: {fieldName: 'CustomEnumField', type: 'ENUM_TYPE'}},
+          {fieldRef: {fieldName: 'CustomApprovalField',
+            type: 'APPROVAL_TYPE'}},
+        ]}},
+      }});
+
+      assert.deepEqual(typeExtractor('CustomIntField'), fieldTypes.INT_TYPE);
+      assert.deepEqual(typeExtractor('CustomStrField'), fieldTypes.STR_TYPE);
+      assert.deepEqual(typeExtractor('CustomUserField'), fieldTypes.USER_TYPE);
+      assert.deepEqual(typeExtractor('CustomEnumField'), fieldTypes.ENUM_TYPE);
+      assert.deepEqual(typeExtractor('CustomApprovalField'),
+          fieldTypes.APPROVAL_TYPE);
+    });
+
+    it('defaults to string type for other fields', () => {
+      typeExtractor = projectV0.extractTypeForFieldName({projectV0: {
+        name: example.PROJECT_NAME,
+        configs: {[example.PROJECT_NAME]: {fieldDefs: [
+          {fieldRef: {fieldName: 'CustomIntField', type: 'INT_TYPE'}},
+          {fieldRef: {fieldName: 'CustomUserField', type: 'USER_TYPE'}},
+        ]}},
+      }});
+
+      assert.deepEqual(typeExtractor('FakeUserField'), fieldTypes.STR_TYPE);
+      assert.deepEqual(typeExtractor('NotOwner'), fieldTypes.STR_TYPE);
+    });
+  });
+
+  describe('extractFieldValuesFromIssue', () => {
+    let clock;
+    let issue;
+    let fieldExtractor;
+
+    describe('built-in fields', () => {
+      beforeEach(() => {
+        // Built-in fields will always act the same, regardless of
+        // project config.
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({});
+
+        // Set clock to some specified date for relative time.
+        const initialTime = 365 * 24 * 60 * 60;
+
+        issue = {
+          localId: 33,
+          projectName: 'chromium',
+          summary: 'Test summary',
+          attachmentCount: 22,
+          starCount: 2,
+          componentRefs: [{path: 'Infra'}, {path: 'Monorail>UI'}],
+          blockedOnIssueRefs: [{localId: 30, projectName: 'chromium'}],
+          blockingIssueRefs: [{localId: 60, projectName: 'chromium'}],
+          labelRefs: [{label: 'Restrict-View-Google'}, {label: 'Type-Defect'}],
+          reporterRef: {displayName: 'test@example.com'},
+          ccRefs: [{displayName: 'test@example.com'}],
+          ownerRef: {displayName: 'owner@example.com'},
+          closedTimestamp: initialTime - 120, // 2 minutes ago
+          modifiedTimestamp: initialTime - 60, // a minute ago
+          openedTimestamp: initialTime - 24 * 60 * 60, // a day ago
+          componentModifiedTimestamp: initialTime - 60, // a minute ago
+          statusModifiedTimestamp: initialTime - 60, // a minute ago
+          ownerModifiedTimestamp: initialTime - 60, // a minute ago
+          statusRef: {status: 'Duplicate'},
+          mergedIntoIssueRef: {localId: 31, projectName: 'chromium'},
+        };
+
+        clock = sinon.useFakeTimers({
+          now: new Date(initialTime * 1000),
+          shouldAdvanceTime: false,
+        });
+      });
+
+      afterEach(() => {
+        clock.restore();
+      });
+
+      it('computes strings for ID', () => {
+        const fieldName = 'ID';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:33']);
+      });
+
+      it('computes strings for Project', () => {
+        const fieldName = 'Project';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium']);
+      });
+
+      it('computes strings for Attachments', () => {
+        const fieldName = 'Attachments';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['22']);
+      });
+
+      it('computes strings for AllLabels', () => {
+        const fieldName = 'AllLabels';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Restrict-View-Google', 'Type-Defect']);
+      });
+
+      it('computes strings for Blocked when issue is blocked', () => {
+        const fieldName = 'Blocked';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Yes']);
+      });
+
+      it('computes strings for Blocked when issue is not blocked', () => {
+        const fieldName = 'Blocked';
+        issue.blockedOnIssueRefs = [];
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['No']);
+      });
+
+      it('computes strings for BlockedOn', () => {
+        const fieldName = 'BlockedOn';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:30']);
+      });
+
+      it('computes strings for Blocking', () => {
+        const fieldName = 'Blocking';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:60']);
+      });
+
+      it('computes strings for CC', () => {
+        const fieldName = 'CC';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['test@example.com']);
+      });
+
+      it('computes strings for Closed', () => {
+        const fieldName = 'Closed';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['2 minutes ago']);
+      });
+
+      it('computes strings for Component', () => {
+        const fieldName = 'Component';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Infra', 'Monorail>UI']);
+      });
+
+      it('computes strings for ComponentModified', () => {
+        const fieldName = 'ComponentModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for MergedInto', () => {
+        const fieldName = 'MergedInto';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:31']);
+      });
+
+      it('computes strings for Modified', () => {
+        const fieldName = 'Modified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Reporter', () => {
+        const fieldName = 'Reporter';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['test@example.com']);
+      });
+
+      it('computes strings for Stars', () => {
+        const fieldName = 'Stars';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['2']);
+      });
+
+      it('computes strings for Status', () => {
+        const fieldName = 'Status';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Duplicate']);
+      });
+
+      it('computes strings for StatusModified', () => {
+        const fieldName = 'StatusModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Summary', () => {
+        const fieldName = 'Summary';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Test summary']);
+      });
+
+      it('computes strings for Type', () => {
+        const fieldName = 'Type';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Defect']);
+      });
+
+      it('computes strings for Owner', () => {
+        const fieldName = 'Owner';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['owner@example.com']);
+      });
+
+      it('computes strings for OwnerModified', () => {
+        const fieldName = 'OwnerModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Opened', () => {
+        const fieldName = 'Opened';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a day ago']);
+      });
+    });
+
+    describe('custom approval fields', () => {
+      beforeEach(() => {
+        const fieldDefs = [
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Goose-Approval'}},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Chicken-Approval'}},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Dodo-Approval'}},
+        ];
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                fieldDefs,
+              },
+            },
+          },
+        });
+
+        issue = {
+          localId: 33,
+          projectName: 'bird',
+          approvalValues: [
+            {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Goose-Approval'},
+              approverRefs: []},
+            {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Chicken-Approval'},
+              status: 'APPROVED'},
+            {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Dodo-Approval'},
+              status: 'NEED_INFO', approverRefs: [
+                {displayName: 'kiwi@bird.test'},
+                {displayName: 'mini-dino@bird.test'},
+              ],
+            },
+          ],
+        };
+      });
+
+      it('handles approval approver columns', () => {
+        assert.deepEqual(fieldExtractor(issue, 'goose-approval-approver'), []);
+        assert.deepEqual(fieldExtractor(issue, 'chicken-approval-approver'),
+            []);
+        assert.deepEqual(fieldExtractor(issue, 'dodo-approval-approver'),
+            ['kiwi@bird.test', 'mini-dino@bird.test']);
+      });
+
+      it('handles approval value columns', () => {
+        assert.deepEqual(fieldExtractor(issue, 'goose-approval'), ['NotSet']);
+        assert.deepEqual(fieldExtractor(issue, 'chicken-approval'),
+            ['Approved']);
+        assert.deepEqual(fieldExtractor(issue, 'dodo-approval'),
+            ['NeedInfo']);
+      });
+    });
+
+    describe('custom fields', () => {
+      beforeEach(() => {
+        const fieldDefs = [
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}},
+          {fieldRef: {type: 'ENUM_TYPE', fieldName: 'ENUM'}},
+          {fieldRef: {type: 'INT_TYPE', fieldName: 'Cow-Number'},
+            bool_is_phase_field: true, is_multivalued: true},
+        ];
+        // As a label prefix, aString conflicts with the custom field named
+        // "aString". In this case, Monorail gives precedence to the
+        // custom field.
+        const labelDefs = [
+          {label: 'aString-ignore'},
+          {label: 'aString-two'},
+        ];
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                fieldDefs,
+                labelDefs,
+              },
+            },
+          },
+        });
+
+        const fieldValues = [
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'},
+            value: 'test'},
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'},
+            value: 'test2'},
+          {fieldRef: {type: 'ENUM_TYPE', fieldName: 'ENUM'},
+            value: 'a-value'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'Cow-Phase'}, value: '55'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'Cow-Phase'}, value: '54'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'MilkCow-Phase'}, value: '56'},
+        ];
+
+        issue = {
+          localId: 33,
+          projectName: 'chromium',
+          fieldValues,
+        };
+      });
+
+      it('gets values for custom fields', () => {
+        assert.deepEqual(fieldExtractor(issue, 'aString'), ['test', 'test2']);
+        assert.deepEqual(fieldExtractor(issue, 'enum'), ['a-value']);
+        assert.deepEqual(fieldExtractor(issue, 'cow-phase.cow-number'),
+            ['55', '54']);
+        assert.deepEqual(fieldExtractor(issue, 'milkcow-phase.cow-number'),
+            ['56']);
+      });
+
+      it('custom fields get precedence over label fields', () => {
+        issue.labelRefs = [{label: 'aString-ignore'}];
+        assert.deepEqual(fieldExtractor(issue, 'aString'),
+            ['test', 'test2']);
+      });
+    });
+
+    describe('label prefix fields', () => {
+      beforeEach(() => {
+        issue = {
+          localId: 33,
+          projectName: 'chromium',
+          labelRefs: [
+            {label: 'test-label'},
+            {label: 'test-label-2'},
+            {label: 'ignore-me'},
+            {label: 'Milestone-UI'},
+            {label: 'Milestone-Goodies'},
+          ],
+        };
+
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                labelDefs: [
+                  {label: 'test-1'},
+                  {label: 'test-2'},
+                  {label: 'milestone-1'},
+                  {label: 'milestone-2'},
+                ],
+              },
+            },
+          },
+        });
+      });
+
+      it('gets values for label prefixes', () => {
+        assert.deepEqual(fieldExtractor(issue, 'test'), ['label', 'label-2']);
+        assert.deepEqual(fieldExtractor(issue, 'Milestone'), ['UI', 'Goodies']);
+      });
+    });
+  });
+
+  it('fieldDefsByApprovalName', () => {
+    assert.deepEqual(projectV0.fieldDefsByApprovalName({projectV0: {}}),
+        new Map());
+
+    assert.deepEqual(projectV0.fieldDefsByApprovalName({projectV0: {
+      name: example.PROJECT_NAME,
+      configs: {[example.PROJECT_NAME]: {
+        fieldDefs: [
+          {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+          {fieldRef: {fieldName: 'ignoreMe', type: fieldTypes.APPROVAL_TYPE}},
+          {fieldRef: {fieldName: 'yay', approvalName: 'ThisIsAnApproval'}},
+          {fieldRef: {fieldName: 'ImAField', approvalName: 'ThisIsAnApproval'}},
+          {fieldRef: {fieldName: 'TalkToALawyer', approvalName: 'Legal'}},
+        ],
+      }},
+    }}), new Map([
+      ['ThisIsAnApproval', [
+        {fieldRef: {fieldName: 'yay', approvalName: 'ThisIsAnApproval'}},
+        {fieldRef: {fieldName: 'ImAField', approvalName: 'ThisIsAnApproval'}},
+      ]],
+      ['Legal', [
+        {fieldRef: {fieldName: 'TalkToALawyer', approvalName: 'Legal'}},
+      ]],
+    ]));
+  });
+});
+
+let dispatch;
+
+describe('project action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  it('select', () => {
+    projectV0.select('project-name')(dispatch);
+    const action = {type: projectV0.SELECT, projectName: 'project-name'};
+    sinon.assert.calledWith(dispatch, action);
+  });
+
+  it('fetchCustomPermissions', async () => {
+    const action = projectV0.fetchCustomPermissions('chromium');
+
+    prpcClient.call.returns(Promise.resolve({permissions: ['google']}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_CUSTOM_PERMISSIONS_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetCustomPermissions',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      projectName: 'chromium',
+      permissions: ['google'],
+    });
+  });
+
+  it('fetchPresentationConfig', async () => {
+    const action = projectV0.fetchPresentationConfig('chromium');
+
+    prpcClient.call.returns(Promise.resolve({projectThumbnailUrl: 'test'}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_PRESENTATION_CONFIG_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetPresentationConfig',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: 'chromium',
+      presentationConfig: {projectThumbnailUrl: 'test'},
+    });
+  });
+
+  it('fetchVisibleMembers', async () => {
+    const action = projectV0.fetchVisibleMembers('chromium');
+
+    prpcClient.call.returns(Promise.resolve({userRefs: [{userId: '123'}]}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_VISIBLE_MEMBERS_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetVisibleMembers',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_VISIBLE_MEMBERS_SUCCESS,
+      projectName: 'chromium',
+      visibleMembers: {userRefs: [{userId: '123'}]},
+    });
+  });
+});
+
+describe('helpers', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('fetchFieldPerms', () => {
+    it('fetch field permissions', async () => {
+      const projectName = 'proj';
+      const fieldDefs = [
+        {
+          fieldRef: {
+            fieldName: 'testField',
+            fieldId: 1,
+            type: 'ENUM_TYPE',
+          },
+        },
+      ];
+      const response = {};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      await store.dispatch(projectV0.fetchFieldPerms(projectName, fieldDefs));
+
+      const args = {names: ['projects/proj/fieldDefs/1']};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Permissions',
+          'BatchGetPermissionSets', args);
+    });
+
+    it('fetch with no fieldDefs', async () => {
+      const config = {projectName: 'proj'};
+      const response = {};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      // fieldDefs will be undefined.
+      await store.dispatch(projectV0.fetchFieldPerms(
+          config.projectName, config.fieldDefs));
+
+      const args = {names: []};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Permissions',
+          'BatchGetPermissionSets', args);
+    });
+  });
+});
diff --git a/static_src/reducers/projects.js b/static_src/reducers/projects.js
new file mode 100644
index 0000000..955dfea
--- /dev/null
+++ b/static_src/reducers/projects.js
@@ -0,0 +1,129 @@
+// 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.
+
+/**
+ * @fileoverview Project actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving project state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+export const LIST_START = 'projects/LIST_START';
+export const LIST_SUCCESS = 'projects/LIST_SUCCESS';
+export const LIST_FAILURE = 'projects/LIST_FAILURE';
+
+/* State Shape
+{
+  name: string,
+
+  byName: Object<ProjectName, Project>,
+  allNames: Array<ProjectName>,
+
+  requests: {
+    list: ReduxRequestState,
+  },
+}
+*/
+
+/**
+ * All Project data indexed by Project name.
+ * @param {Object<ProjectName, Project>} state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Project>} action.projects The Projects that were fetched.
+ * @return {Object<ProjectName, Project>}
+ */
+export const byNameReducer = createReducer({}, {
+  [LIST_SUCCESS]: (state, {projects}) => {
+    const newProjects = {};
+    projects.forEach((proj) => {
+      newProjects[proj.name] = proj;
+    });
+    return {...state, ...newProjects};
+  },
+});
+
+/**
+ * Resource names for all Projects in Monorail.
+ * @param {Array<ProjectName>} _state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Project>} action.projects The Projects that were fetched.
+ * @return {Array<ProjectName>}
+ */
+export const allNamesReducer = createReducer([], {
+  [LIST_SUCCESS]: (_state, {projects}) => {
+    return projects.map((proj) => proj.name);
+  },
+});
+
+const requestsReducer = combineReducers({
+  list: createRequestReducer(
+      LIST_START, LIST_SUCCESS, LIST_FAILURE),
+});
+
+export const reducer = combineReducers({
+  byName: byNameReducer,
+  allNames: allNamesReducer,
+
+  requests: requestsReducer,
+});
+
+
+/**
+ * Returns normalized Project data by name.
+ * @param {any} state
+ * @return {Object<ProjectName, Project>}
+ * @private
+ */
+export const byName = (state) => state.projects.byName;
+
+/**
+ * Base selector for wrapping the allNames state key.
+ * @param {any} state
+ * @return {Array<ProjectName>}
+ * @private
+ */
+export const _allNames = (state) => state.projects.allNames;
+
+/**
+ * Returns all Projects on Monorail, in denormalized form, in
+ * the sort order returned by the API.
+ * @param {any} state
+ * @return {Array<Project>}
+ */
+export const all = createSelector([byName, _allNames],
+    (byName, allNames) => allNames.map((name) => byName[name]));
+
+
+/**
+ * Returns the Project requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.projects.requests;
+
+/**
+ * Gets all projects hosted on Monorail.
+ * @return {function(function): Promise<void>}
+ */
+export const list = () => async (dispatch) => {
+  dispatch({type: LIST_START});
+  try {
+    /** @type {{projects: Array<Project>}} */
+    const {projects} = await prpcClient.call(
+        'monorail.v3.Projects', 'ListProjects', {});
+
+    dispatch({type: LIST_SUCCESS, projects});
+  } catch (error) {
+    dispatch({type: LIST_FAILURE, error});
+  }
+};
diff --git a/static_src/reducers/projects.test.js b/static_src/reducers/projects.test.js
new file mode 100644
index 0000000..0a9dee4
--- /dev/null
+++ b/static_src/reducers/projects.test.js
@@ -0,0 +1,174 @@
+// 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 * as projects from './projects.js';
+import * as example from 'shared/test/constants-projects.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+
+describe('project reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = projects.reducer(undefined, {type: null});
+    const expected = {
+      byName: {},
+      allNames: [],
+      requests: {
+        list: {
+          error: null,
+          requesting: false,
+        },
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  describe('byNameReducer', () => {
+    it('populated on LIST_SUCCESS', () => {
+      const action = {type: projects.LIST_SUCCESS, projects:
+          [example.PROJECT, example.PROJECT_2]};
+      const actual = projects.byNameReducer({}, action);
+
+      assert.deepEqual(actual, {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: example.PROJECT_2,
+      });
+    });
+
+    it('keeps original state on empty LIST_SUCCESS', () => {
+      const originalState = {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: example.PROJECT_2,
+      };
+      const action = {type: projects.LIST_SUCCESS, projects: []};
+      const actual = projects.byNameReducer(originalState, action);
+
+      assert.deepEqual(actual, originalState);
+    });
+
+    it('appends new issues to state on LIST_SUCCESS', () => {
+      const originalState = {
+        [example.NAME]: example.PROJECT,
+      };
+      const action = {type: projects.LIST_SUCCESS,
+        projects: [example.PROJECT_2]};
+      const actual = projects.byNameReducer(originalState, action);
+
+      const expected = {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: example.PROJECT_2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+
+    it('overrides outdated data on LIST_SUCCESS', () => {
+      const originalState = {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: example.PROJECT_2,
+      };
+
+      const newProject2 = {
+        name: example.NAME_2,
+        summary: 'I hacked your project!',
+      };
+      const action = {type: projects.LIST_SUCCESS,
+        projects: [newProject2]};
+      const actual = projects.byNameReducer(originalState, action);
+      const expected = {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: newProject2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+  });
+
+  it('allNames populated on LIST_SUCCESS', () => {
+    const action = {type: projects.LIST_SUCCESS, projects:
+        [example.PROJECT, example.PROJECT_2]};
+    const actual = projects.allNamesReducer([], action);
+
+    assert.deepEqual(actual, [example.NAME, example.NAME_2]);
+  });
+});
+
+describe('project selectors', () => {
+  it('byName', () => {
+    const normalizedProjects = {
+      [example.NAME]: example.PROJECT,
+    };
+    const state = {projects: {
+      byName: normalizedProjects,
+    }};
+    assert.deepEqual(projects.byName(state), normalizedProjects);
+  });
+
+  it('all', () => {
+    const state = {projects: {
+      byName: {
+        [example.NAME]: example.PROJECT,
+      },
+      allNames: [example.NAME],
+    }};
+    assert.deepEqual(projects.all(state), [example.PROJECT]);
+  });
+
+  it('requests', () => {
+    const state = {projects: {
+      requests: {
+        list: {error: null, requesting: false},
+      },
+    }};
+    assert.deepEqual(projects.requests(state), {
+      list: {error: null, requesting: false},
+    });
+  });
+});
+
+describe('project action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('list', () => {
+    it('success', async () => {
+      const projectsResponse = {projects: [example.PROJECT, example.PROJECT_2]};
+      prpcClient.call.returns(Promise.resolve(projectsResponse));
+
+      await projects.list()(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: projects.LIST_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Projects', 'ListProjects', {});
+
+      const successAction = {
+        type: projects.LIST_SUCCESS,
+        projects: projectsResponse.projects,
+      };
+      sinon.assert.calledWith(dispatch, successAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await projects.list()(dispatch);
+
+      const action = {
+        type: projects.LIST_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
diff --git a/static_src/reducers/redux-helpers.js b/static_src/reducers/redux-helpers.js
new file mode 100644
index 0000000..ce80d60
--- /dev/null
+++ b/static_src/reducers/redux-helpers.js
@@ -0,0 +1,64 @@
+// 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.
+
+export const createReducer = (initialState, handlers) => {
+  return function reducer(state = initialState, action) {
+    if (handlers.hasOwnProperty(action.type)) {
+      return handlers[action.type](state, action);
+    } else {
+      return state;
+    }
+  };
+};
+
+const DEFAULT_REQUEST_KEY = '*';
+
+export const createKeyedRequestReducer = (start, success, failure) => {
+  return createReducer({}, {
+    [start]: (state, {requestKey = DEFAULT_REQUEST_KEY}) => {
+      return {
+        ...state,
+        [requestKey]: {
+          requesting: true,
+          error: null,
+        },
+      };
+    },
+    [success]: (state, {requestKey = DEFAULT_REQUEST_KEY}) =>{
+      return {
+        ...state,
+        [requestKey]: {
+          requesting: false,
+          error: null,
+        },
+      };
+    },
+    [failure]: (state, {requestKey = DEFAULT_REQUEST_KEY, error}) => {
+      return {
+        ...state,
+        [requestKey]: {
+          requesting: false,
+          error,
+        },
+      };
+    },
+  });
+};
+
+export const createRequestReducer = (start, success, failure) => {
+  return createReducer({requesting: false, error: null}, {
+    [start]: (_state, _action) => ({
+      requesting: true,
+      error: null,
+    }),
+    [success]: (_state, _action) =>({
+      requesting: false,
+      error: null,
+    }),
+    [failure]: (_state, {error}) => ({
+      requesting: false,
+      error,
+    }),
+  });
+};
diff --git a/static_src/reducers/redux-helpers.test.js b/static_src/reducers/redux-helpers.test.js
new file mode 100644
index 0000000..93f0e0a
--- /dev/null
+++ b/static_src/reducers/redux-helpers.test.js
@@ -0,0 +1,102 @@
+// 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 {createRequestReducer,
+  createKeyedRequestReducer} from './redux-helpers.js';
+
+let keyedRequestReducer;
+let requestReducer;
+
+describe('redux-helpers', () => {
+  describe('createKeyedRequestReducer', () => {
+    beforeEach(() => {
+      keyedRequestReducer = createKeyedRequestReducer(
+          'REQUEST_START', 'REQUEST_SUCCESS', 'REQUEST_FAILURE');
+    });
+
+    it('sets requesting to true on start', () => {
+      assert.deepEqual(keyedRequestReducer({}, {type: 'REQUEST_START'}),
+          {['*']: {requesting: true, error: null}});
+    });
+
+    it('sets requesting to false on success', () => {
+      assert.deepEqual(keyedRequestReducer({}, {type: 'REQUEST_SUCCESS'}),
+          {['*']: {requesting: false, error: null}});
+    });
+
+    it('sets error message on failure', () => {
+      assert.deepEqual(keyedRequestReducer({}, {
+        type: 'REQUEST_FAILURE',
+        error: 'hello',
+      }), {['*']: {requesting: false, error: 'hello'}});
+    });
+
+    it('preserves previous request state on start', () => {
+      const initialState = {
+        ['*']: {requesting: false, error: 'hello'},
+      };
+      assert.deepEqual(keyedRequestReducer(initialState, {
+        type: 'REQUEST_START',
+        requestKey: 'chromium:11',
+      }), {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: true, error: null},
+      });
+    });
+
+    it('preserves previous request state on success', () => {
+      const initialState = {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: true, error: null},
+      };
+      assert.deepEqual(keyedRequestReducer(initialState, {
+        type: 'REQUEST_SUCCESS',
+        requestKey: 'chromium:11',
+      }), {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: false, error: null},
+      });
+    });
+
+    it('preserves previous request state on failure', () => {
+      const initialState = {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: false, error: null},
+      };
+      assert.deepEqual(keyedRequestReducer(initialState, {
+        type: 'REQUEST_FAILURE',
+        requestKey: 'chromium:11',
+        error: 'something went wrong',
+      }), {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: false, error: 'something went wrong'},
+      });
+    });
+  });
+
+  describe('createRequestReducer', () => {
+    beforeEach(() => {
+      requestReducer = createRequestReducer(
+          'REQUEST_START', 'REQUEST_SUCCESS', 'REQUEST_FAILURE');
+    });
+
+    it('sets requesting to true on start', () => {
+      assert.deepEqual(requestReducer({}, {type: 'REQUEST_START'}),
+          {requesting: true, error: null});
+    });
+
+    it('sets requesting to false on success', () => {
+      assert.deepEqual(requestReducer({}, {type: 'REQUEST_SUCCESS'}),
+          {requesting: false, error: null});
+    });
+
+    it('sets error message on failure', () => {
+      assert.deepEqual(requestReducer({}, {
+        type: 'REQUEST_FAILURE',
+        error: 'hello',
+      }), {requesting: false, error: 'hello'});
+    });
+  });
+});
diff --git a/static_src/reducers/sitewide.js b/static_src/reducers/sitewide.js
new file mode 100644
index 0000000..f7e20d7
--- /dev/null
+++ b/static_src/reducers/sitewide.js
@@ -0,0 +1,167 @@
+// 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 * as projectV0 from 'reducers/projectV0.js';
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {createSelector} from 'reselect';
+import {prpcClient} from 'prpc-client-instance.js';
+import {SITEWIDE_DEFAULT_CAN, parseColSpec} from 'shared/issue-fields.js';
+
+// Actions
+const SET_PAGE_TITLE = 'SET_PAGE_TITLE';
+const SET_HEADER_TITLE = 'SET_HEADER_TITLE';
+export const SET_QUERY_PARAMS = 'SET_QUERY_PARAMS';
+
+// Async actions
+const GET_SERVER_STATUS_FAILURE = 'GET_SERVER_STATUS_FAILURE';
+const GET_SERVER_STATUS_START = 'GET_SERVER_STATUS_START';
+const GET_SERVER_STATUS_SUCCESS = 'GET_SERVER_STATUS_SUCCESS';
+
+/* State Shape
+{
+  bannerMessage: String,
+  bannerTime: Number,
+  pageTitle: String,
+  headerTitle: String,
+  queryParams: Object,
+  readOnly: Boolean,
+  requests: {
+    serverStatus: Object,
+  },
+}
+*/
+
+// Reducers
+const bannerMessageReducer = createReducer('', {
+  [GET_SERVER_STATUS_SUCCESS]:
+    (_state, action) => action.serverStatus.bannerMessage || '',
+});
+
+const bannerTimeReducer = createReducer(0, {
+  [GET_SERVER_STATUS_SUCCESS]:
+    (_state, action) => action.serverStatus.bannerTime || 0,
+});
+
+/**
+ * Handle state for the current document title.
+ */
+const pageTitleReducer = createReducer('', {
+  [SET_PAGE_TITLE]: (_state, {title}) => title,
+});
+
+const headerTitleReducer = createReducer('', {
+  [SET_HEADER_TITLE]: (_state, {title}) => title,
+});
+
+const queryParamsReducer = createReducer({}, {
+  [SET_QUERY_PARAMS]: (_state, {queryParams}) => queryParams || {},
+});
+
+const readOnlyReducer = createReducer(false, {
+  [GET_SERVER_STATUS_SUCCESS]:
+    (_state, action) => action.serverStatus.readOnly || false,
+});
+
+const requestsReducer = combineReducers({
+  serverStatus: createRequestReducer(
+      GET_SERVER_STATUS_START,
+      GET_SERVER_STATUS_SUCCESS,
+      GET_SERVER_STATUS_FAILURE),
+});
+
+export const reducer = combineReducers({
+  bannerMessage: bannerMessageReducer,
+  bannerTime: bannerTimeReducer,
+  readOnly: readOnlyReducer,
+  queryParams: queryParamsReducer,
+  pageTitle: pageTitleReducer,
+  headerTitle: headerTitleReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+export const sitewide = (state) => state.sitewide || {};
+export const bannerMessage =
+    createSelector(sitewide, (sitewide) => sitewide.bannerMessage);
+export const bannerTime =
+    createSelector(sitewide, (sitewide) => sitewide.bannerTime);
+export const queryParams =
+    createSelector(sitewide, (sitewide) => sitewide.queryParams || {});
+export const pageTitle = createSelector(
+    sitewide, projectV0.viewedConfig,
+    (sitewide, projectConfig) => {
+      const titlePieces = [];
+
+      // If a specific page specifies its own page title, add that
+      // to the beginning of the title.
+      if (sitewide.pageTitle) {
+        titlePieces.push(sitewide.pageTitle);
+      }
+
+      // If the user is viewing a project, add the project data.
+      if (projectConfig && projectConfig.projectName) {
+        titlePieces.push(projectConfig.projectName);
+      }
+
+      return titlePieces.join(' - ') || 'Monorail';
+    });
+export const headerTitle =
+    createSelector(sitewide, (sitewide) => sitewide.headerTitle);
+export const readOnly =
+    createSelector(sitewide, (sitewide) => sitewide.readOnly);
+
+/**
+ * Computes the issue list columns from the URL parameters.
+ */
+export const currentColumns = createSelector(
+    queryParams,
+    (params = {}) => params.colspec ? parseColSpec(params.colspec) : null);
+
+/**
+* Get the default canned query for the currently viewed project.
+* Note: Projects cannot configure a per-project default canned query,
+* so there is only a sitewide default.
+*/
+export const currentCan = createSelector(queryParams,
+    (params) => params.can || SITEWIDE_DEFAULT_CAN);
+
+/**
+ * Compute the current issue search query that the user has
+ * entered for a project, based on queryParams and the default
+ * project search.
+ */
+export const currentQuery = createSelector(
+    projectV0.defaultQuery,
+    queryParams,
+    (defaultQuery, params = {}) => {
+      // Make sure entering an empty search still works.
+      if (params.q === '') return params.q;
+      return params.q || defaultQuery;
+    });
+
+export const requests = createSelector(sitewide,
+    (sitewide) => sitewide.requests || {});
+
+// Action Creators
+export const setQueryParams =
+    (queryParams) => ({type: SET_QUERY_PARAMS, queryParams});
+
+export const setPageTitle = (title) => ({type: SET_PAGE_TITLE, title});
+
+export const setHeaderTitle = (title) => ({type: SET_HEADER_TITLE, title});
+
+export const getServerStatus = () => async (dispatch) => {
+  dispatch({type: GET_SERVER_STATUS_START});
+
+  try {
+    const serverStatus = await prpcClient.call(
+        'monorail.Sitewide', 'GetServerStatus', {});
+
+    dispatch({type: GET_SERVER_STATUS_SUCCESS, serverStatus});
+  } catch (error) {
+    dispatch({type: GET_SERVER_STATUS_FAILURE, error});
+  }
+};
diff --git a/static_src/reducers/sitewide.test.js b/static_src/reducers/sitewide.test.js
new file mode 100644
index 0000000..114ecaf
--- /dev/null
+++ b/static_src/reducers/sitewide.test.js
@@ -0,0 +1,235 @@
+// 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 {store, stateUpdated, resetState} from 'reducers/base.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import * as sitewide from './sitewide.js';
+
+let prpcCall;
+
+describe('sitewide selectors', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+  });
+  it('queryParams', () => {
+    assert.deepEqual(sitewide.queryParams({}), {});
+    assert.deepEqual(sitewide.queryParams({sitewide: {}}), {});
+    assert.deepEqual(sitewide.queryParams({sitewide: {queryParams:
+      {q: 'owner:me'}}}), {q: 'owner:me'});
+  });
+
+  describe('pageTitle', () => {
+    it('defaults to Monorail when no data', () => {
+      assert.equal(sitewide.pageTitle({}), 'Monorail');
+      assert.equal(sitewide.pageTitle({sitewide: {}}), 'Monorail');
+    });
+
+    it('uses local page title when one exists', () => {
+      assert.equal(sitewide.pageTitle(
+          {sitewide: {pageTitle: 'Issue Detail'}}), 'Issue Detail');
+    });
+
+    it('shows name of viewed project', () => {
+      assert.equal(sitewide.pageTitle({
+        sitewide: {pageTitle: 'Page'},
+        projectV0: {
+          name: 'chromium',
+          configs: {chromium: {projectName: 'chromium'}},
+        },
+      }), 'Page - chromium');
+    });
+  });
+
+  describe('currentColumns', () => {
+    it('returns null no configuration', () => {
+      assert.deepEqual(sitewide.currentColumns({}), null);
+      assert.deepEqual(sitewide.currentColumns({projectV0: {}}), null);
+      const state = {projectV0: {presentationConfig: {}}};
+      assert.deepEqual(sitewide.currentColumns(state), null);
+    });
+
+    it('gets columns from URL query params', () => {
+      const state = {sitewide: {
+        queryParams: {colspec: 'ID+Summary+ColumnName+Priority'},
+      }};
+      const expected = ['ID', 'Summary', 'ColumnName', 'Priority'];
+      assert.deepEqual(sitewide.currentColumns(state), expected);
+    });
+  });
+
+  describe('currentCan', () => {
+    it('uses sitewide default can by default', () => {
+      assert.deepEqual(sitewide.currentCan({}), '2');
+    });
+
+    it('URL params override default can', () => {
+      assert.deepEqual(sitewide.currentCan({
+        sitewide: {
+          queryParams: {can: '3'},
+        },
+      }), '3');
+    });
+
+    it('undefined query param does not override default can', () => {
+      assert.deepEqual(sitewide.currentCan({
+        sitewide: {
+          queryParams: {can: undefined},
+        },
+      }), '2');
+    });
+  });
+
+  describe('currentQuery', () => {
+    it('defaults to empty', () => {
+      assert.deepEqual(sitewide.currentQuery({}), '');
+      assert.deepEqual(sitewide.currentQuery({projectV0: {}}), '');
+    });
+
+    it('uses project default when no params', () => {
+      assert.deepEqual(sitewide.currentQuery({projectV0: {
+        name: 'chromium',
+        presentationConfigs: {
+          chromium: {defaultQuery: 'owner:me'},
+        },
+      }}), 'owner:me');
+    });
+
+    it('URL query params override default query', () => {
+      assert.deepEqual(sitewide.currentQuery({
+        projectV0: {
+          name: 'chromium',
+          presentationConfigs: {
+            chromium: {defaultQuery: 'owner:me'},
+          },
+        },
+        sitewide: {
+          queryParams: {q: 'component:Infra'},
+        },
+      }), 'component:Infra');
+    });
+
+    it('empty string in param overrides default project query', () => {
+      assert.deepEqual(sitewide.currentQuery({
+        projectV0: {
+          name: 'chromium',
+          presentationConfigs: {
+            chromium: {defaultQuery: 'owner:me'},
+          },
+        },
+        sitewide: {
+          queryParams: {q: ''},
+        },
+      }), '');
+    });
+
+    it('undefined query param does not override default search', () => {
+      assert.deepEqual(sitewide.currentQuery({
+        projectV0: {
+          name: 'chromium',
+          presentationConfigs: {
+            chromium: {defaultQuery: 'owner:me'},
+          },
+        },
+        sitewide: {
+          queryParams: {q: undefined},
+        },
+      }), 'owner:me');
+    });
+  });
+});
+
+
+describe('sitewide action creators', () => {
+  beforeEach(() => {
+    prpcCall = sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  it('setQueryParams updates queryParams', async () => {
+    store.dispatch(sitewide.setQueryParams({test: 'param'}));
+
+    await stateUpdated;
+
+    assert.deepEqual(sitewide.queryParams(store.getState()), {test: 'param'});
+  });
+
+  describe('getServerStatus', () => {
+    it('gets server status', async () => {
+      prpcCall.callsFake(() => {
+        return {
+          bannerMessage: 'Message',
+          bannerTime: 1234,
+          readOnly: true,
+        };
+      });
+
+      store.dispatch(sitewide.getServerStatus());
+
+      await stateUpdated;
+      const state = store.getState();
+
+      assert.deepEqual(sitewide.bannerMessage(state), 'Message');
+      assert.deepEqual(sitewide.bannerTime(state), 1234);
+      assert.isTrue(sitewide.readOnly(state));
+
+      assert.deepEqual(sitewide.requests(state), {
+        serverStatus: {
+          error: null,
+          requesting: false,
+        },
+      });
+    });
+
+    it('gets empty status', async () => {
+      prpcCall.callsFake(() => {
+        return {};
+      });
+
+      store.dispatch(sitewide.getServerStatus());
+
+      await stateUpdated;
+      const state = store.getState();
+
+      assert.deepEqual(sitewide.bannerMessage(state), '');
+      assert.deepEqual(sitewide.bannerTime(state), 0);
+      assert.isFalse(sitewide.readOnly(state));
+
+      assert.deepEqual(sitewide.requests(state), {
+        serverStatus: {
+          error: null,
+          requesting: false,
+        },
+      });
+    });
+
+    it('fails', async () => {
+      const error = new Error('error');
+      prpcCall.callsFake(() => {
+        throw error;
+      });
+
+      store.dispatch(sitewide.getServerStatus());
+
+      await stateUpdated;
+      const state = store.getState();
+
+      assert.deepEqual(sitewide.bannerMessage(state), '');
+      assert.deepEqual(sitewide.bannerTime(state), 0);
+      assert.isFalse(sitewide.readOnly(state));
+
+      assert.deepEqual(sitewide.requests(state), {
+        serverStatus: {
+          error: error,
+          requesting: false,
+        },
+      });
+    });
+  });
+});
diff --git a/static_src/reducers/stars.js b/static_src/reducers/stars.js
new file mode 100644
index 0000000..b67ff9d
--- /dev/null
+++ b/static_src/reducers/stars.js
@@ -0,0 +1,172 @@
+// Copyright 2020 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.
+
+/**
+ * @fileoverview Star actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving star state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer,
+  createKeyedRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {projectAndUserToStarName} from 'shared/converters.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const LIST_PROJECTS_START = 'stars/LIST_PROJECTS_START';
+export const LIST_PROJECTS_SUCCESS = 'stars/LIST_PROJECTS_SUCCESS';
+export const LIST_PROJECTS_FAILURE = 'stars/LIST_PROJECTS_FAILURE';
+
+export const STAR_PROJECT_START = 'stars/STAR_PROJECT_START';
+export const STAR_PROJECT_SUCCESS = 'stars/STAR_PROJECT_SUCCESS';
+export const STAR_PROJECT_FAILURE = 'stars/STAR_PROJECT_FAILURE';
+
+export const UNSTAR_PROJECT_START = 'stars/UNSTAR_PROJECT_START';
+export const UNSTAR_PROJECT_SUCCESS = 'stars/UNSTAR_PROJECT_SUCCESS';
+export const UNSTAR_PROJECT_FAILURE = 'stars/UNSTAR_PROJECT_FAILURE';
+
+/* State Shape
+{
+  byName: Object<StarName, Star>,
+
+  requests: {
+    listProjects: ReduxRequestState,
+  },
+}
+*/
+
+/**
+ * All star data indexed by resource name.
+ * @param {Object<ProjectName, Star>} state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Star>} action.star The Stars that were fetched.
+ * @param {ProjectStar} action.projectStar A single ProjectStar that was
+ *   created.
+ * @param {StarName} action.starName The StarName that was mutated.
+ * @return {Object<ProjectName, Star>}
+ */
+export const byNameReducer = createReducer({}, {
+  [LIST_PROJECTS_SUCCESS]: (state, {stars}) => {
+    const newStars = {};
+    stars.forEach((star) => {
+      newStars[star.name] = star;
+    });
+    return {...state, ...newStars};
+  },
+  [STAR_PROJECT_SUCCESS]: (state, {projectStar}) => {
+    return {...state, [projectStar.name]: projectStar};
+  },
+  [UNSTAR_PROJECT_SUCCESS]: (state, {starName}) => {
+    const newState = {...state};
+    delete newState[starName];
+    return newState;
+  },
+});
+
+
+const requestsReducer = combineReducers({
+  listProjects: createRequestReducer(LIST_PROJECTS_START,
+      LIST_PROJECTS_SUCCESS, LIST_PROJECTS_FAILURE),
+  starProject: createKeyedRequestReducer(STAR_PROJECT_START,
+      STAR_PROJECT_SUCCESS, STAR_PROJECT_FAILURE),
+  unstarProject: createKeyedRequestReducer(UNSTAR_PROJECT_START,
+      UNSTAR_PROJECT_SUCCESS, UNSTAR_PROJECT_FAILURE),
+});
+
+
+export const reducer = combineReducers({
+  byName: byNameReducer,
+  requests: requestsReducer,
+});
+
+
+/**
+ * Returns normalized star data by name.
+ * @param {any} state
+ * @return {Object<StarName, Star>}
+ * @private
+ */
+export const byName = (state) => state.stars.byName;
+
+/**
+ * Returns star requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.stars.requests;
+
+/**
+ * Retrieves the starred projects for a given user.
+ * @param {UserName} user The resource name of the user to fetch
+ *   starred projects for.
+ * @return {function(function): Promise<void>}
+ */
+export const listProjects = (user) => async (dispatch) => {
+  dispatch({type: LIST_PROJECTS_START});
+
+  try {
+    const {projectStars} = await prpcClient.call(
+        'monorail.v3.Users', 'ListProjectStars', {parent: user});
+    dispatch({type: LIST_PROJECTS_SUCCESS, stars: projectStars});
+  } catch (error) {
+    dispatch({type: LIST_PROJECTS_FAILURE, error});
+  };
+};
+
+/**
+ * Stars a given project.
+ * @param {ProjectName} project The resource name of the project to star.
+ * @param {UserName} user The resource name of the user who is starring
+ *   the issue. This will always be the currently logged in user.
+ * @return {function(function): Promise<void>}
+ */
+export const starProject = (project, user) => async (dispatch) => {
+  const requestKey = projectAndUserToStarName(project, user);
+  dispatch({type: STAR_PROJECT_START, requestKey});
+  try {
+    const projectStar = await prpcClient.call(
+        'monorail.v3.Users', 'StarProject', {project});
+    dispatch({type: STAR_PROJECT_SUCCESS, requestKey, projectStar});
+  } catch (error) {
+    dispatch({type: STAR_PROJECT_FAILURE, requestKey, error});
+  };
+};
+
+/**
+ * Unstars a given project.
+ * @param {ProjectName} project The resource name of the project to unstar.
+ * @param {UserName} user The resource name of the user who is unstarring
+ *   the issue. This will always be the currently logged in user, but
+ *   passing in the user's resource name is necessary to make it possible to
+ *   generate the resource name of the removed star.
+ * @return {function(function): Promise<void>}
+ */
+export const unstarProject = (project, user) => async (dispatch) => {
+  const starName = projectAndUserToStarName(project, user);
+  const requestKey = starName;
+  dispatch({type: UNSTAR_PROJECT_START, requestKey});
+
+  try {
+    await prpcClient.call(
+        'monorail.v3.Users', 'UnStarProject', {project});
+    dispatch({type: UNSTAR_PROJECT_SUCCESS, requestKey, starName});
+  } catch (error) {
+    dispatch({type: UNSTAR_PROJECT_FAILURE, requestKey, error});
+  };
+};
+
+export const stars = {
+  reducer,
+  byName,
+  requests,
+  listProjects,
+  starProject,
+  unstarProject,
+};
diff --git a/static_src/reducers/stars.test.js b/static_src/reducers/stars.test.js
new file mode 100644
index 0000000..3437723
--- /dev/null
+++ b/static_src/reducers/stars.test.js
@@ -0,0 +1,247 @@
+// 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 * as stars from './stars.js';
+import * as example from 'shared/test/constants-stars.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+
+describe('star reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = stars.reducer(undefined, {type: null});
+    const expected = {
+      byName: {},
+      requests: {
+        listProjects: {error: null, requesting: false},
+        starProject: {},
+        unstarProject: {},
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  describe('byNameReducer', () => {
+    it('populated on LIST_PROJECTS_SUCCESS', () => {
+      const action = {type: stars.LIST_PROJECTS_SUCCESS, stars:
+          [example.PROJECT_STAR, example.PROJECT_STAR_2]};
+      const actual = stars.byNameReducer({}, action);
+
+      assert.deepEqual(actual, {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      });
+    });
+
+    it('keeps original state on empty LIST_PROJECTS_SUCCESS', () => {
+      const originalState = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      const action = {type: stars.LIST_PROJECTS_SUCCESS, stars: []};
+      const actual = stars.byNameReducer(originalState, action);
+
+      assert.deepEqual(actual, originalState);
+    });
+
+    it('appends new stars to state on LIST_PROJECTS_SUCCESS', () => {
+      const originalState = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+      };
+      const action = {type: stars.LIST_PROJECTS_SUCCESS,
+        stars: [example.PROJECT_STAR_2]};
+      const actual = stars.byNameReducer(originalState, action);
+
+      const expected = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+
+    it('adds star on STAR_PROJECT_SUCCESS', () => {
+      const originalState = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+      };
+      const action = {type: stars.STAR_PROJECT_SUCCESS,
+        projectStar: example.PROJECT_STAR_2};
+      const actual = stars.byNameReducer(originalState, action);
+
+      const expected = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+
+    it('removes star on UNSTAR_PROJECT_SUCCESS', () => {
+      const originalState = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      const action = {type: stars.UNSTAR_PROJECT_SUCCESS,
+        starName: example.PROJECT_STAR_NAME};
+      const actual = stars.byNameReducer(originalState, action);
+
+      const expected = {
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+  });
+});
+
+describe('project selectors', () => {
+  it('byName', () => {
+    const normalizedStars = {
+      [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+    };
+    const state = {stars: {
+      byName: normalizedStars,
+    }};
+    assert.deepEqual(stars.byName(state), normalizedStars);
+  });
+
+  it('requests', () => {
+    const state = {stars: {
+      requests: {
+        listProjects: {error: null, requesting: false},
+        starProject: {},
+        unstarProject: {},
+      },
+    }};
+    assert.deepEqual(stars.requests(state), {
+      listProjects: {error: null, requesting: false},
+      starProject: {},
+      unstarProject: {},
+    });
+  });
+});
+
+describe('star action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('listProjects', () => {
+    it('success', async () => {
+      const starsResponse = {
+        projectStars: [example.PROJECT_STAR, example.PROJECT_STAR_2],
+      };
+      prpcClient.call.returns(Promise.resolve(starsResponse));
+
+      await stars.listProjects('users/1234')(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: stars.LIST_PROJECTS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'ListProjectStars',
+          {parent: 'users/1234'});
+
+      const successAction = {
+        type: stars.LIST_PROJECTS_SUCCESS,
+        stars: [example.PROJECT_STAR, example.PROJECT_STAR_2],
+      };
+      sinon.assert.calledWith(dispatch, successAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await stars.listProjects('users/1234')(dispatch);
+
+      const action = {
+        type: stars.LIST_PROJECTS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('starProject', () => {
+    it('success', async () => {
+      const starResponse = example.PROJECT_STAR;
+      prpcClient.call.returns(Promise.resolve(starResponse));
+
+      await stars.starProject('projects/monorail', 'users/1234')(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: stars.STAR_PROJECT_START,
+        requestKey: example.PROJECT_STAR_NAME,
+      });
+
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'StarProject',
+          {project: 'projects/monorail'});
+
+      const successAction = {
+        type: stars.STAR_PROJECT_SUCCESS,
+        requestKey: example.PROJECT_STAR_NAME,
+        projectStar: example.PROJECT_STAR,
+      };
+      sinon.assert.calledWith(dispatch, successAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await stars.starProject('projects/monorail', 'users/1234')(dispatch);
+
+      const action = {
+        type: stars.STAR_PROJECT_FAILURE,
+        requestKey: example.PROJECT_STAR_NAME,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('unstarProject', () => {
+    it('success', async () => {
+      const starResponse = {};
+      prpcClient.call.returns(Promise.resolve(starResponse));
+
+      await stars.unstarProject('projects/monorail', 'users/1234')(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: stars.UNSTAR_PROJECT_START,
+        requestKey: example.PROJECT_STAR_NAME,
+      });
+
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'UnStarProject',
+          {project: 'projects/monorail'});
+
+      const successAction = {
+        type: stars.UNSTAR_PROJECT_SUCCESS,
+        requestKey: example.PROJECT_STAR_NAME,
+        starName: example.PROJECT_STAR_NAME,
+      };
+      sinon.assert.calledWith(dispatch, successAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await stars.unstarProject('projects/monorail', 'users/1234')(dispatch);
+
+      const action = {
+        type: stars.UNSTAR_PROJECT_FAILURE,
+        requestKey: example.PROJECT_STAR_NAME,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
diff --git a/static_src/reducers/ui.js b/static_src/reducers/ui.js
new file mode 100644
index 0000000..871cf87
--- /dev/null
+++ b/static_src/reducers/ui.js
@@ -0,0 +1,170 @@
+// 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 {combineReducers} from 'redux';
+import {createReducer} from './redux-helpers.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+const DEFAULT_SNACKBAR_TIMEOUT_MS = 10 * 1000;
+
+
+/**
+ * Object of various constant strings used to uniquely identify
+ * snackbar instances used in the app.
+ * TODO(https://crbug.com/monorail/7491): Avoid using this Object.
+ * @type {Object<string, string>}
+ */
+export const snackbarNames = Object.freeze({
+  // Issue list page snackbars.
+  FETCH_ISSUE_LIST_ERROR: 'FETCH_ISSUE_LIST_ERROR',
+  FETCH_ISSUE_LIST: 'FETCH_ISSUE_LIST',
+  UPDATE_HOTLISTS_SUCCESS: 'UPDATE_HOTLISTS_SUCCESS',
+
+  // Issue detail page snackbars.
+  ISSUE_COMMENT_ADDED: 'ISSUE_COMMENT_ADDED',
+});
+
+// Actions
+const INCREMENT_NAVIGATION_COUNT = 'INCREMENT_NAVIGATION_COUNT';
+const REPORT_DIRTY_FORM = 'REPORT_DIRTY_FORM';
+const CLEAR_DIRTY_FORMS = 'CLEAR_DIRTY_FORMS';
+const SET_FOCUS_ID = 'SET_FOCUS_ID';
+export const SHOW_SNACKBAR = 'SHOW_SNACKBAR';
+const HIDE_SNACKBAR = 'HIDE_SNACKBAR';
+
+/**
+ * @typedef {Object} Snackbar
+ * @param {string} id Unique string identifying the snackbar.
+ * @param {string} text The text to show in the snackbar.
+ */
+
+/* State Shape
+{
+  navigationCount: number,
+  dirtyForms: Array,
+  focusId: String,
+  snackbars: Array<Snackbar>,
+}
+*/
+
+// Reducers
+
+
+const navigationCountReducer = createReducer(0, {
+  [INCREMENT_NAVIGATION_COUNT]: (state) => state + 1,
+});
+
+/**
+ * Saves state on which forms have been edited, to warn the user
+ * about possible data loss when they navigate away from a page.
+ * @param {Array<string>} state Dirty form names.
+ * @param {AnyAction} action
+ * @param {string} action.name The name of the form being updated.
+ * @param {boolean} action.isDirty Whether the form is dirty or not dirty.
+ * @return {Array<string>}
+ */
+const dirtyFormsReducer = createReducer([], {
+  [REPORT_DIRTY_FORM]: (state, {name, isDirty}) => {
+    const newState = [...state];
+    const index = state.indexOf(name);
+    if (isDirty && index === -1) {
+      newState.push(name);
+    } else if (!isDirty && index !== -1) {
+      newState.splice(index, 1);
+    }
+    return newState;
+  },
+  [CLEAR_DIRTY_FORMS]: () => [],
+});
+
+const focusIdReducer = createReducer(null, {
+  [SET_FOCUS_ID]: (_state, action) => action.focusId,
+});
+
+/**
+ * Updates snackbar state.
+ * @param {Array<Snackbar>} state A snackbar-shaped slice of Redux state.
+ * @param {AnyAction} action
+ * @param {string} action.text The text to display in the snackbar.
+ * @param {string} action.id A unique global ID for the snackbar.
+ * @return {Array<Snackbar>} New snackbar state.
+ */
+export const snackbarsReducer = createReducer([], {
+  [SHOW_SNACKBAR]: (state, {text, id}) => {
+    return [...state, {text, id}];
+  },
+  [HIDE_SNACKBAR]: (state, {id}) => {
+    return state.filter((snackbar) => snackbar.id !== id);
+  },
+});
+
+export const reducer = combineReducers({
+  // Count of "page" navigations.
+  navigationCount: navigationCountReducer,
+  // Forms to be checked for user changes before leaving the page.
+  dirtyForms: dirtyFormsReducer,
+  // The ID of the element to be focused, as given by the hash part of the URL.
+  focusId: focusIdReducer,
+  // Array of snackbars to render on the page.
+  snackbars: snackbarsReducer,
+});
+
+// Selectors
+export const navigationCount = (state) => state.ui.navigationCount;
+export const dirtyForms = (state) => state.ui.dirtyForms;
+export const focusId = (state) => state.ui.focusId;
+
+/**
+ * Retrieves snackbar data from the Redux store.
+ * @param {any} state Redux state.
+ * @return {Array<Snackbar>} All the snackbars in the store.
+ */
+export const snackbars = (state) => state.ui.snackbars;
+
+// Action Creators
+export const incrementNavigationCount = () => {
+  return {type: INCREMENT_NAVIGATION_COUNT};
+};
+
+export const reportDirtyForm = (name, isDirty) => {
+  return {type: REPORT_DIRTY_FORM, name, isDirty};
+};
+
+export const clearDirtyForms = () => ({type: CLEAR_DIRTY_FORMS});
+
+export const setFocusId = (focusId) => {
+  return {type: SET_FOCUS_ID, focusId};
+};
+
+/**
+ * Displays a snackbar.
+ * @param {string} id Unique identifier for a given snackbar. We depend on
+ *   snackbar users to keep this unique.
+ * @param {string} text The text to be shown in the snackbar.
+ * @param {number} timeout An optional timeout in milliseconds for how
+ *   long to wait to dismiss a snackbar.
+ * @return {function(function): Promise<void>}
+ */
+export const showSnackbar = (id, text,
+    timeout = DEFAULT_SNACKBAR_TIMEOUT_MS) => (dispatch) => {
+  dispatch({type: SHOW_SNACKBAR, text, id});
+
+  if (timeout) {
+    window.setTimeout(() => dispatch(hideSnackbar(id)),
+        timeout);
+  }
+};
+
+/**
+ * Hides a snackbar.
+ * @param {string} id The unique name of the snackbar to be hidden.
+ * @return {any} A Redux action.
+ */
+export const hideSnackbar = (id) => {
+  return {
+    type: HIDE_SNACKBAR,
+    id,
+  };
+};
diff --git a/static_src/reducers/ui.test.js b/static_src/reducers/ui.test.js
new file mode 100644
index 0000000..587bc0c
--- /dev/null
+++ b/static_src/reducers/ui.test.js
@@ -0,0 +1,123 @@
+// 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 * as ui from './ui.js';
+
+
+describe('ui', () => {
+  describe('reducers', () => {
+    describe('snackbarsReducer', () => {
+      it('adds snackbar', () => {
+        let state = ui.snackbarsReducer([],
+            {type: 'SHOW_SNACKBAR', id: 'one', text: 'A snackbar'});
+
+        assert.deepEqual(state, [{id: 'one', text: 'A snackbar'}]);
+
+        state = ui.snackbarsReducer(state,
+            {type: 'SHOW_SNACKBAR', id: 'two', text: 'Another snack'});
+
+        assert.deepEqual(state, [
+          {id: 'one', text: 'A snackbar'},
+          {id: 'two', text: 'Another snack'},
+        ]);
+      });
+
+      it('removes snackbar', () => {
+        let state = [
+          {id: 'one', text: 'A snackbar'},
+          {id: 'two', text: 'Another snack'},
+        ];
+
+        state = ui.snackbarsReducer(state,
+            {type: 'HIDE_SNACKBAR', id: 'one'});
+
+        assert.deepEqual(state, [
+          {id: 'two', text: 'Another snack'},
+        ]);
+
+        state = ui.snackbarsReducer(state,
+            {type: 'HIDE_SNACKBAR', id: 'two'});
+
+        assert.deepEqual(state, []);
+      });
+
+      it('does not remove non-existent snackbar', () => {
+        let state = [
+          {id: 'one', text: 'A snackbar'},
+          {id: 'two', text: 'Another snack'},
+        ];
+
+        state = ui.snackbarsReducer(state,
+            {action: 'HIDE_SNACKBAR', id: 'whatever'});
+
+        assert.deepEqual(state, [
+          {id: 'one', text: 'A snackbar'},
+          {id: 'two', text: 'Another snack'},
+        ]);
+      });
+    });
+  });
+
+  describe('selectors', () => {
+    it('snackbars', () => {
+      assert.deepEqual(ui.snackbars({ui: {snackbars: []}}), []);
+      assert.deepEqual(ui.snackbars({ui: {snackbars: [
+        {text: 'Snackbar one', id: 'one'},
+        {text: 'Snackbar two', id: 'two'},
+      ]}}), [
+        {text: 'Snackbar one', id: 'one'},
+        {text: 'Snackbar two', id: 'two'},
+      ]);
+    });
+  });
+
+  describe('action creators', () => {
+    describe('showSnackbar', () => {
+      it('produces action', () => {
+        const action = ui.showSnackbar('id', 'text');
+        const dispatch = sinon.stub();
+
+        action(dispatch);
+
+        sinon.assert.calledWith(dispatch,
+            {type: 'SHOW_SNACKBAR', text: 'text', id: 'id'});
+      });
+
+      it('hides snackbar after timeout', () => {
+        const clock = sinon.useFakeTimers(0);
+
+        const action = ui.showSnackbar('id', 'text', 1000);
+        const dispatch = sinon.stub();
+
+        action(dispatch);
+
+        sinon.assert.neverCalledWith(dispatch,
+            {type: 'HIDE_SNACKBAR', id: 'id'});
+
+        clock.tick(1000);
+
+        sinon.assert.calledWith(dispatch, {type: 'HIDE_SNACKBAR', id: 'id'});
+
+        clock.restore();
+      });
+
+      it('does not setTimeout when no timeout specified', () => {
+        sinon.stub(window, 'setTimeout');
+
+        ui.showSnackbar('id', 'text', 0);
+
+        sinon.assert.notCalled(window.setTimeout);
+
+        window.setTimeout.restore();
+      });
+    });
+
+    it('hideSnackbar produces action', () => {
+      assert.deepEqual(ui.hideSnackbar('one'),
+          {type: 'HIDE_SNACKBAR', id: 'one'});
+    });
+  });
+});
diff --git a/static_src/reducers/userV0.js b/static_src/reducers/userV0.js
new file mode 100644
index 0000000..42a93fc
--- /dev/null
+++ b/static_src/reducers/userV0.js
@@ -0,0 +1,384 @@
+// 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 {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {objectToMap} from 'shared/helpers.js';
+import {userRefToId, userToUserRef} from 'shared/convertersV0.js';
+import loadGapi, {fetchGapiEmail} from 'shared/gapi-loader.js';
+import {DEFAULT_MD_PROJECTS} from 'shared/md-helper.js';
+import {viewedProjectName} from 'reducers/projectV0.js';
+
+// Actions
+const FETCH_START = 'userV0/FETCH_START';
+const FETCH_SUCCESS = 'userV0/FETCH_SUCCESS';
+const FETCH_FAILURE = 'userV0/FETCH_FAILURE';
+
+export const FETCH_PROJECTS_START = 'userV0/FETCH_PROJECTS_START';
+export const FETCH_PROJECTS_SUCCESS = 'userV0/FETCH_PROJECTS_SUCCESS';
+export const FETCH_PROJECTS_FAILURE = 'userV0/FETCH_PROJECTS_FAILURE';
+
+const FETCH_HOTLISTS_START = 'userV0/FETCH_HOTLISTS_START';
+const FETCH_HOTLISTS_SUCCESS = 'userV0/FETCH_HOTLISTS_SUCCESS';
+const FETCH_HOTLISTS_FAILURE = 'userV0/FETCH_HOTLISTS_FAILURE';
+
+const FETCH_PREFS_START = 'userV0/FETCH_PREFS_START';
+const FETCH_PREFS_SUCCESS = 'userV0/FETCH_PREFS_SUCCESS';
+const FETCH_PREFS_FAILURE = 'userV0/FETCH_PREFS_FAILURE';
+
+export const SET_PREFS_START = 'userV0/SET_PREFS_START';
+export const SET_PREFS_SUCCESS = 'userV0/SET_PREFS_SUCCESS';
+export const SET_PREFS_FAILURE = 'userV0/SET_PREFS_FAILURE';
+
+const GAPI_LOGIN_START = 'GAPI_LOGIN_START';
+export const GAPI_LOGIN_SUCCESS = 'GAPI_LOGIN_SUCCESS';
+const GAPI_LOGIN_FAILURE = 'GAPI_LOGIN_FAILURE';
+
+const GAPI_LOGOUT_START = 'GAPI_LOGOUT_START';
+export const GAPI_LOGOUT_SUCCESS = 'GAPI_LOGOUT_SUCCESS';
+const GAPI_LOGOUT_FAILURE = 'GAPI_LOGOUT_FAILURE';
+
+
+// Monorial UserPrefs are stored as plain strings in Monorail's backend.
+// We want boolean preferences to be converted into booleans for convenience.
+// Currently, there are no user prefs in Monorail that are NOT booleans, so
+// we default to converting all user prefs to booleans unless otherwise
+// specified.
+// See: https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/framework/framework_bizobj.py;l=409
+const NON_BOOLEAN_PREFS = new Set(['test_non_bool']);
+
+
+/* State Shape
+{
+  currentUser: {
+    ...user: Object,
+    groups: Array,
+    hotlists: Array,
+    prefs: Object,
+    gapiEmail: String,
+  },
+  requests: {
+    fetch: Object,
+    fetchHotlists: Object,
+    fetchPrefs: Object,
+  },
+}
+*/
+
+// Reducers
+const USER_DEFAULT = {
+  groups: [],
+  hotlists: [],
+  projects: {},
+  prefs: {},
+  prefsLoaded: false,
+};
+
+const gapiEmailReducer = (user, action) => {
+  return {
+    ...user,
+    gapiEmail: action.email || '',
+  };
+};
+
+export const currentUserReducer = createReducer(USER_DEFAULT, {
+  [FETCH_SUCCESS]: (_user, action) => {
+    return {
+      ...USER_DEFAULT,
+      ...action.user,
+      groups: action.groups,
+    };
+  },
+  [FETCH_HOTLISTS_SUCCESS]: (user, action) => {
+    return {...user, hotlists: action.hotlists};
+  },
+  [FETCH_PREFS_SUCCESS]: (user, action) => {
+    return {
+      ...user,
+      prefs: action.prefs,
+      prefsLoaded: true,
+    };
+  },
+  [SET_PREFS_SUCCESS]: (user, action) => {
+    const newPrefs = action.newPrefs;
+    const prefs = Object.assign({}, user.prefs);
+    newPrefs.forEach(({name, value}) => {
+      prefs[name] = value;
+    });
+    return {
+      ...user,
+      prefs,
+    };
+  },
+  [GAPI_LOGIN_SUCCESS]: gapiEmailReducer,
+  [GAPI_LOGOUT_SUCCESS]: gapiEmailReducer,
+});
+
+export const usersByIdReducer = createReducer({}, {
+  [FETCH_PROJECTS_SUCCESS]: (state, action) => {
+    const newState = {...state};
+
+    action.usersProjects.forEach((userProjects) => {
+      const {userRef, ownerOf = [], memberOf = [], contributorTo = [],
+        starredProjects = []} = userProjects;
+
+      const userId = userRefToId(userRef);
+
+      newState[userId] = {
+        ...newState[userId],
+        projects: {
+          ownerOf,
+          memberOf,
+          contributorTo,
+          starredProjects,
+        },
+      };
+    });
+
+    return newState;
+  },
+});
+
+const requestsReducer = combineReducers({
+  // Request for getting backend metadata related to a user, such as
+  // which groups they belong to and whether they're a site admin.
+  fetch: createRequestReducer(FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+  // Requests for fetching projects a user is related to.
+  fetchProjects: createRequestReducer(
+      FETCH_PROJECTS_START, FETCH_PROJECTS_SUCCESS, FETCH_PROJECTS_FAILURE),
+  // Request for getting a user's hotlists.
+  fetchHotlists: createRequestReducer(
+      FETCH_HOTLISTS_START, FETCH_HOTLISTS_SUCCESS, FETCH_HOTLISTS_FAILURE),
+  // Request for getting a user's prefs.
+  fetchPrefs: createRequestReducer(
+      FETCH_PREFS_START, FETCH_PREFS_SUCCESS, FETCH_PREFS_FAILURE),
+  // Request for setting a user's prefs.
+  setPrefs: createRequestReducer(
+      SET_PREFS_START, SET_PREFS_SUCCESS, SET_PREFS_FAILURE),
+});
+
+export const reducer = combineReducers({
+  currentUser: currentUserReducer,
+  usersById: usersByIdReducer,
+  requests: requestsReducer,
+});
+
+// Selectors
+export const requests = (state) => state.userV0.requests;
+export const currentUser = (state) => state.userV0.currentUser || {};
+// TODO(zhangtiff): Replace custom logic to check if the user is logged in
+// across the frontend.
+export const isLoggedIn = createSelector(
+    currentUser, (user) => user && user.userId);
+export const userRef = createSelector(
+    currentUser, (user) => userToUserRef(user));
+export const prefs = createSelector(
+    currentUser, viewedProjectName, (user, projectName = '') => {
+      const prefs = {
+        // Make Markdown default to true for projects who have opted in.
+        render_markdown: String(DEFAULT_MD_PROJECTS.has(projectName)),
+        ...user.prefs
+      };
+      for (let prefName of Object.keys(prefs)) {
+        if (!NON_BOOLEAN_PREFS.has(prefName)) {
+          // Monorail user preferences are stored as strings.
+          prefs[prefName] = prefs[prefName] === 'true';
+        }
+      }
+      return objectToMap(prefs);
+    });
+
+const _usersById = (state) => state.userV0.usersById || {};
+export const usersById = createSelector(_usersById,
+    (usersById) => objectToMap(usersById));
+
+export const projectsPerUser = createSelector(usersById, (usersById) => {
+  const map = new Map();
+  for (const [key, value] of usersById.entries()) {
+    if (value.projects) {
+      map.set(key, value.projects);
+    }
+  }
+  return map;
+});
+
+// Projects for just the current user.
+export const projects = createSelector(projectsPerUser, userRef,
+    (projectsMap, userRef) => projectsMap.get(userRefToId(userRef)) || {});
+
+// Action Creators
+/**
+ * Fetches the data required to view the logged in user.
+ * @param {string} displayName The display name of the logged in user. Note that
+ *   while usernames may be hidden for users in Monorail, the logged in user
+ *   will always be able to view their own name.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (displayName) => async (dispatch) => {
+  dispatch({type: FETCH_START});
+
+  const message = {
+    userRef: {displayName},
+  };
+
+  try {
+    const resp = await Promise.all([
+      prpcClient.call(
+          'monorail.Users', 'GetUser', message),
+      prpcClient.call(
+          'monorail.Users', 'GetMemberships', message),
+    ]);
+
+    const user = resp[0];
+
+    dispatch({
+      type: FETCH_SUCCESS,
+      user,
+      groups: resp[1].groupRefs || [],
+    });
+
+    const userRef = userToUserRef(user);
+
+    dispatch(fetchProjects([userRef]));
+    const hotlistsPromise = dispatch(fetchHotlists(userRef));
+    dispatch(fetchPrefs());
+
+    hotlistsPromise.then((hotlists) => {
+      // TODO(crbug.com/monorail/5828): Remove
+      // window.TKR_populateHotlistAutocomplete once the old
+      // autocomplete code is deprecated.
+      window.TKR_populateHotlistAutocomplete(hotlists);
+    });
+  } catch (error) {
+    dispatch({type: FETCH_FAILURE, error});
+  };
+};
+
+export const fetchProjects = (userRefs) => async (dispatch) => {
+  dispatch({type: FETCH_PROJECTS_START});
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Users', 'GetUsersProjects', {userRefs});
+    dispatch({type: FETCH_PROJECTS_SUCCESS, usersProjects: resp.usersProjects});
+  } catch (error) {
+    dispatch({type: FETCH_PROJECTS_FAILURE, error});
+  }
+};
+
+/**
+ * Fetches all of a given user's hotlists.
+ * @param {UserRef} userRef The user to fetch hotlists for.
+ * @return {function(function): Promise<Array<HotlistV0>>}
+ */
+export const fetchHotlists = (userRef) => async (dispatch) => {
+  dispatch({type: FETCH_HOTLISTS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Features', 'ListHotlistsByUser', {user: userRef});
+
+    const hotlists = resp.hotlists || [];
+    hotlists.sort((hotlistA, hotlistB) => {
+      return hotlistA.name.localeCompare(hotlistB.name);
+    });
+    dispatch({type: FETCH_HOTLISTS_SUCCESS, hotlists});
+
+    return hotlists;
+  } catch (error) {
+    dispatch({type: FETCH_HOTLISTS_FAILURE, error});
+  };
+};
+
+/**
+ * Fetches user preferences for the logged in user.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchPrefs = () => async (dispatch) => {
+  dispatch({type: FETCH_PREFS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Users', 'GetUserPrefs', {});
+    const prefs = {};
+    (resp.prefs || []).forEach(({name, value}) => {
+      prefs[name] = value;
+    });
+    dispatch({type: FETCH_PREFS_SUCCESS, prefs});
+  } catch (error) {
+    dispatch({type: FETCH_PREFS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator for setting a user's preferences.
+ *
+ * @param {Object} newPrefs
+ * @param {boolean} saveChanges
+ * @return {function(function): Promise<void>}
+ */
+export const setPrefs = (newPrefs, saveChanges = true) => async (dispatch) => {
+  if (!saveChanges) {
+    dispatch({type: SET_PREFS_SUCCESS, newPrefs});
+    return;
+  }
+
+  dispatch({type: SET_PREFS_START});
+
+  try {
+    const message = {prefs: newPrefs};
+    await prpcClient.call(
+        'monorail.Users', 'SetUserPrefs', message);
+    dispatch({type: SET_PREFS_SUCCESS, newPrefs});
+
+    // Re-fetch the user's prefs after saving to prevent prefs from
+    // getting out of sync.
+    dispatch(fetchPrefs());
+  } catch (error) {
+    dispatch({type: SET_PREFS_FAILURE, error});
+  }
+};
+
+/**
+ * Action creator to initiate the gapi.js login flow.
+ *
+ * @return {Promise} Resolved only when gapi.js login succeeds.
+ */
+export const initGapiLogin = () => (dispatch) => {
+  dispatch({type: GAPI_LOGIN_START});
+
+  return new Promise(async (resolve) => {
+    try {
+      await loadGapi();
+      gapi.auth2.getAuthInstance().signIn().then(async () => {
+        const email = await fetchGapiEmail();
+        dispatch({type: GAPI_LOGIN_SUCCESS, email: email});
+        resolve();
+      });
+    } catch (error) {
+      // TODO(jeffcarp): Pop up a message that signIn failed.
+      dispatch({type: GAPI_LOGIN_FAILURE, error});
+    }
+  });
+};
+
+/**
+ * Action creator to log the user out of gapi.js
+ *
+ * @return {undefined}
+ */
+export const initGapiLogout = () => async (dispatch) => {
+  dispatch({type: GAPI_LOGOUT_START});
+
+  try {
+    await loadGapi();
+    gapi.auth2.getAuthInstance().signOut().then(() => {
+      dispatch({type: GAPI_LOGOUT_SUCCESS, email: ''});
+    });
+  } catch (error) {
+    // TODO(jeffcarp): Pop up a message that signOut failed.
+    dispatch({type: GAPI_LOGOUT_FAILURE, error});
+  }
+};
diff --git a/static_src/reducers/userV0.test.js b/static_src/reducers/userV0.test.js
new file mode 100644
index 0000000..aab7989
--- /dev/null
+++ b/static_src/reducers/userV0.test.js
@@ -0,0 +1,372 @@
+// 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 * as userV0 from './userV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+
+let dispatch;
+
+describe('userV0', () => {
+  describe('reducers', () => {
+    it('SET_PREFS_SUCCESS updates existing prefs with new prefs', () => {
+      const state = {prefs: {
+        testPref: 'true',
+        anotherPref: 'hello-world',
+      }};
+
+      const newPrefs = [
+        {name: 'anotherPref', value: 'override'},
+        {name: 'newPref', value: 'test-me'},
+      ];
+
+      const newState = userV0.currentUserReducer(state,
+          {type: userV0.SET_PREFS_SUCCESS, newPrefs});
+
+      assert.deepEqual(newState, {prefs: {
+        testPref: 'true',
+        anotherPref: 'override',
+        newPref: 'test-me',
+      }});
+    });
+
+    it('FETCH_PROJECTS_SUCCESS overrides existing entry in usersById', () => {
+      const state = {
+        ['123']: {
+          projects: {
+            ownerOf: [],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+      };
+
+      const usersProjects = [
+        {
+          userRef: {userId: '123'},
+          ownerOf: ['chromium'],
+        },
+      ];
+
+      const newState = userV0.usersByIdReducer(state,
+          {type: userV0.FETCH_PROJECTS_SUCCESS, usersProjects});
+
+      assert.deepEqual(newState, {
+        ['123']: {
+          projects: {
+            ownerOf: ['chromium'],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+      });
+    });
+
+    it('FETCH_PROJECTS_SUCCESS adds new entry to usersById', () => {
+      const state = {
+        ['123']: {
+          projects: {
+            ownerOf: [],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+      };
+
+      const usersProjects = [
+        {
+          userRef: {userId: '543'},
+          ownerOf: ['chromium'],
+        },
+        {
+          userRef: {userId: '789'},
+          memberOf: ['v8'],
+        },
+      ];
+
+      const newState = userV0.usersByIdReducer(state,
+          {type: userV0.FETCH_PROJECTS_SUCCESS, usersProjects});
+
+      assert.deepEqual(newState, {
+        ['123']: {
+          projects: {
+            ownerOf: [],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+        ['543']: {
+          projects: {
+            ownerOf: ['chromium'],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+        ['789']: {
+          projects: {
+            ownerOf: [],
+            memberOf: ['v8'],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+      });
+    });
+
+    describe('GAPI_LOGIN_SUCCESS', () => {
+      it('sets currentUser.gapiEmail', () => {
+        const newState = userV0.currentUserReducer({}, {
+          type: userV0.GAPI_LOGIN_SUCCESS,
+          email: 'rutabaga@rutabaga.com',
+        });
+        assert.deepEqual(newState, {
+          gapiEmail: 'rutabaga@rutabaga.com',
+        });
+      });
+
+      it('defaults to an empty string', () => {
+        const newState = userV0.currentUserReducer({}, {
+          type: userV0.GAPI_LOGIN_SUCCESS,
+        });
+        assert.deepEqual(newState, {
+          gapiEmail: '',
+        });
+      });
+    });
+
+    describe('GAPI_LOGOUT_SUCCESS', () => {
+      it('sets currentUser.gapiEmail', () => {
+        const newState = userV0.currentUserReducer({}, {
+          type: userV0.GAPI_LOGOUT_SUCCESS,
+          email: '',
+        });
+        assert.deepEqual(newState, {
+          gapiEmail: '',
+        });
+      });
+
+      it('defaults to an empty string', () => {
+        const state = {};
+        const newState = userV0.currentUserReducer(state, {
+          type: userV0.GAPI_LOGOUT_SUCCESS,
+        });
+        assert.deepEqual(newState, {
+          gapiEmail: '',
+        });
+      });
+    });
+  });
+
+  describe('selectors', () => {
+    it('prefs', () => {
+      const state = wrapCurrentUser({prefs: {
+        testPref: 'true',
+        test_non_bool: 'hello-world',
+      }});
+
+      assert.deepEqual(userV0.prefs(state), new Map([
+        ['render_markdown', false],
+        ['testPref', true],
+        ['test_non_bool', 'hello-world'],
+      ]));
+    });
+
+    it('prefs is set with the correct type', async () =>{
+      // When setting prefs it's important that they are set as their
+      // String value.
+      const state = wrapCurrentUser({prefs: {
+        render_markdown : 'true',
+      }});
+      const markdownPref = userV0.prefs(state).get('render_markdown');
+      assert.isTrue(markdownPref);
+    });
+
+    it('prefs is NOT set with the correct type', async () =>{
+      // Here the value is a boolean so when it gets set it would
+      // appear as false because it's compared with a String.
+      const state = wrapCurrentUser({prefs: {
+        render_markdown : true,
+      }});
+      const markdownPref = userV0.prefs(state).get('render_markdown');
+      // Thus this is false when it was meant to be true.
+      assert.isFalse(markdownPref);
+    });
+
+    it('projects', () => {
+      assert.deepEqual(userV0.projects(wrapUser({})), {});
+
+      const state = wrapUser({
+        currentUser: {userId: '123'},
+        usersById: {
+          ['123']: {
+            projects: {
+              ownerOf: ['chromium'],
+              memberOf: ['v8'],
+              contributorTo: [],
+              starredProjects: [],
+            },
+          },
+        },
+      });
+
+      assert.deepEqual(userV0.projects(state), {
+        ownerOf: ['chromium'],
+        memberOf: ['v8'],
+        contributorTo: [],
+        starredProjects: [],
+      });
+    });
+
+    it('projectPerUser', () => {
+      assert.deepEqual(userV0.projectsPerUser(wrapUser({})), new Map());
+
+      const state = wrapUser({
+        usersById: {
+          ['123']: {
+            projects: {
+              ownerOf: ['chromium'],
+              memberOf: ['v8'],
+              contributorTo: [],
+              starredProjects: [],
+            },
+          },
+        },
+      });
+
+      assert.deepEqual(userV0.projectsPerUser(state), new Map([
+        ['123', {
+          ownerOf: ['chromium'],
+          memberOf: ['v8'],
+          contributorTo: [],
+          starredProjects: [],
+        }],
+      ]));
+    });
+  });
+
+  describe('action creators', () => {
+    beforeEach(() => {
+      sinon.stub(prpcClient, 'call');
+
+      dispatch = sinon.stub();
+    });
+
+    afterEach(() => {
+      prpcClient.call.restore();
+    });
+
+    it('fetchProjects succeeds', async () => {
+      const action = userV0.fetchProjects([{userId: '123'}]);
+
+      prpcClient.call.returns(Promise.resolve({
+        usersProjects: [
+          {
+            userRef: {
+              userId: '123',
+            },
+            ownerOf: ['chromium'],
+          },
+        ],
+      }));
+
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: userV0.FETCH_PROJECTS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Users',
+          'GetUsersProjects',
+          {userRefs: [{userId: '123'}]});
+
+      sinon.assert.calledWith(dispatch, {
+        type: userV0.FETCH_PROJECTS_SUCCESS,
+        usersProjects: [
+          {
+            userRef: {
+              userId: '123',
+            },
+            ownerOf: ['chromium'],
+          },
+        ],
+      });
+    });
+
+    it('fetchProjects fails', async () => {
+      const action = userV0.fetchProjects([{userId: '123'}]);
+
+      const error = new Error('mistakes were made');
+      prpcClient.call.returns(Promise.reject(error));
+
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: userV0.FETCH_PROJECTS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Users',
+          'GetUsersProjects',
+          {userRefs: [{userId: '123'}]});
+
+      sinon.assert.calledWith(dispatch, {
+        type: userV0.FETCH_PROJECTS_FAILURE,
+        error,
+      });
+    });
+
+    it('setPrefs', async () => {
+      const action = userV0.setPrefs([{name: 'pref_name', value: 'true'}]);
+
+      prpcClient.call.returns(Promise.resolve({}));
+
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: userV0.SET_PREFS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Users',
+          'SetUserPrefs',
+          {prefs: [{name: 'pref_name', value: 'true'}]});
+
+      sinon.assert.calledWith(dispatch, {
+        type: userV0.SET_PREFS_SUCCESS,
+        newPrefs: [{name: 'pref_name', value: 'true'}],
+      });
+    });
+
+    it('setPrefs fails', async () => {
+      const action = userV0.setPrefs([{name: 'pref_name', value: 'true'}]);
+
+      const error = new Error('mistakes were made');
+      prpcClient.call.returns(Promise.reject(error));
+
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: userV0.SET_PREFS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Users',
+          'SetUserPrefs',
+          {prefs: [{name: 'pref_name', value: 'true'}]});
+
+      sinon.assert.calledWith(dispatch, {
+        type: userV0.SET_PREFS_FAILURE,
+        error,
+      });
+    });
+  });
+});
+
+
+const wrapCurrentUser = (currentUser = {}) => ({userV0: {currentUser}});
+const wrapUser = (user) => ({userV0: user});
diff --git a/static_src/reducers/users.js b/static_src/reducers/users.js
new file mode 100644
index 0000000..af8609c
--- /dev/null
+++ b/static_src/reducers/users.js
@@ -0,0 +1,222 @@
+// Copyright 2020 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.
+
+/**
+ * @fileoverview User actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving user state
+ * on the frontend.
+ *
+ * The User data is stored in a normalized format.
+ * `byName` stores all User data indexed by User name.
+ * `user` is a selector that gets the currently viewed User data.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createKeyedRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const LOG_IN = 'user/LOG_IN';
+
+export const BATCH_GET_START = 'user/BATCH_GET_START';
+export const BATCH_GET_SUCCESS = 'user/BATCH_GET_SUCCESS';
+export const BATCH_GET_FAILURE = 'user/BATCH_GET_FAILURE';
+
+export const FETCH_START = 'user/FETCH_START';
+export const FETCH_SUCCESS = 'user/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'user/FETCH_FAILURE';
+
+export const GATHER_PROJECT_MEMBERSHIPS_START =
+  'user/GATHER_PROJECT_MEMBERSHIPS_START';
+export const GATHER_PROJECT_MEMBERSHIPS_SUCCESS =
+  'user/GATHER_PROJECT_MEMBERSHIPS_SUCCESS';
+export const GATHER_PROJECT_MEMBERSHIPS_FAILURE =
+  'user/GATHER_PROJECT_MEMBERSHIPS_FAILURE';
+
+/* State Shape
+{
+  currentUserName: ?string,
+
+  byName: Object<UserName, User>,
+
+  requests: {
+    batchGet: ReduxRequestState,
+    fetch: ReduxRequestState,
+    gatherProjectMemberships: ReduxRequestState,
+  },
+}
+*/
+
+// Reducers
+
+/**
+ * A reference to the currently logged in user.
+ * @param {?string} state The current user name.
+ * @param {AnyAction} action
+ * @param {User} action.user The user that was logged in.
+ * @return {?string}
+ */
+export const currentUserNameReducer = createReducer(null, {
+  [LOG_IN]: (_state, {user}) => user.name,
+});
+
+/**
+ * All User data indexed by User name.
+ * @param {Object<UserName, User>} state The existing User data.
+ * @param {AnyAction} action
+ * @param {User} action.user The user that was fetched.
+ * @return {Object<UserName, User>}
+ */
+export const byNameReducer = createReducer({}, {
+  [BATCH_GET_SUCCESS]: (state, {users}) => {
+    const newState = {...state};
+    for (const user of users) {
+      newState[user.name] = user;
+    }
+    return newState;
+  },
+  [FETCH_SUCCESS]: (state, {user}) => ({...state, [user.name]: user}),
+});
+
+/**
+ * ProjectMember data indexed by User name.
+ *
+ * Pragma: No normalization for ProjectMember objects. There is never a
+ *  situation when we will have access to ProjectMember names but not associated
+ *  ProjectMember objects so normalizing is unnecessary.
+ * @param {Object<UserName, Array<ProjectMember>>} state The existing User data.
+ * @param {AnyAction} action
+ * @param {UserName} action.userName The resource name of the user that was
+ *   fetched.
+ * @param {Array<ProjectMember>=} action.projectMemberships The project
+ *   memberships for the fetched user.
+ * @return {Object<UserName, Array<ProjectMember>>}
+ */
+export const projectMembershipsReducer = createReducer({}, {
+  [GATHER_PROJECT_MEMBERSHIPS_SUCCESS]: (state, {userName,
+    projectMemberships}) => {
+    const newState = {...state};
+
+    newState[userName] = projectMemberships || [];
+    return newState;
+  },
+});
+
+const requestsReducer = combineReducers({
+  batchGet: createKeyedRequestReducer(
+      BATCH_GET_START, BATCH_GET_SUCCESS, BATCH_GET_FAILURE),
+  fetch: createKeyedRequestReducer(
+      FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+  gatherProjectMemberships: createKeyedRequestReducer(
+      GATHER_PROJECT_MEMBERSHIPS_START, GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+      GATHER_PROJECT_MEMBERSHIPS_FAILURE),
+});
+
+export const reducer = combineReducers({
+  currentUserName: currentUserNameReducer,
+  byName: byNameReducer,
+  projectMemberships: projectMembershipsReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns the currently logged in user name, or null if there is none.
+ * @param {any} state
+ * @return {?string}
+ */
+export const currentUserName = (state) => state.users.currentUserName;
+
+/**
+ * Returns all the User data in the store as a mapping from name to User.
+ * @param {any} state
+ * @return {Object<UserName, User>}
+ */
+export const byName = (state) => state.users.byName;
+
+/**
+ * Returns all the ProjectMember data in the store, mapped to Users' names.
+ * @param {any} state
+ * @return {Object<UserName, ProjectMember>}
+ */
+export const projectMemberships = (state) => state.users.projectMemberships;
+
+// Action Creators
+
+/**
+ * Action creator to fetch multiple User objects.
+ * @param {Array<UserName>} names The names of the Users to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const batchGet = (names) => async (dispatch) => {
+  dispatch({type: BATCH_GET_START});
+
+  try {
+    /** @type {{users: Array<User>}} */
+    const {users} = await prpcClient.call(
+        'monorail.v3.Users', 'BatchGetUsers', {names});
+
+    dispatch({type: BATCH_GET_SUCCESS, users});
+  } catch (error) {
+    dispatch({type: BATCH_GET_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to fetch a single User object.
+ * TODO(https://crbug.com/monorail/7824): Maybe decouple LOG_IN from
+ * FETCH_SUCCESS once we move away from server-side logins.
+ * @param {UserName} name The resource name of the User to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (name) => async (dispatch) => {
+  dispatch({type: FETCH_START});
+
+  try {
+    /** @type {User} */
+    const user = await prpcClient.call('monorail.v3.Users', 'GetUser', {name});
+    dispatch({type: FETCH_SUCCESS, user});
+    dispatch({type: LOG_IN, user});
+  } catch (error) {
+    dispatch({type: FETCH_FAILURE, error});
+  }
+};
+
+/**
+ * Action creator to fetch ProjectMember objects for a given User.
+ * @param {UserName} name The resource name of the User.
+ * @return {function(function): Promise<void>}
+ */
+export const gatherProjectMemberships = (name) => async (dispatch) => {
+  dispatch({type: GATHER_PROJECT_MEMBERSHIPS_START});
+
+  try {
+    /** @type {{projectMemberships: Array<ProjectMember>}} */
+    const {projectMemberships} = await prpcClient.call(
+        'monorail.v3.Frontend', 'GatherProjectMembershipsForUser',
+        {user: name});
+
+    dispatch({type: GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+      userName: name, projectMemberships});
+  } catch (error) {
+    // TODO(crbug.com/monorail/7627): Catch actual API errors.
+    dispatch({type: GATHER_PROJECT_MEMBERSHIPS_FAILURE, error});
+  };
+};
+
+export const users = {
+  currentUserName,
+  byName,
+  projectMemberships,
+  batchGet,
+  fetch,
+  gatherProjectMemberships,
+};
diff --git a/static_src/reducers/users.test.js b/static_src/reducers/users.test.js
new file mode 100644
index 0000000..ea0ce61
--- /dev/null
+++ b/static_src/reducers/users.test.js
@@ -0,0 +1,178 @@
+// Copyright 2020 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 * as users from './users.js';
+import * as example from 'shared/test/constants-users.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('user reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = users.reducer(undefined, {type: null});
+    const expected = {
+      currentUserName: null,
+      byName: {},
+      projectMemberships: {},
+      requests: {
+        batchGet: {},
+        fetch: {},
+        gatherProjectMemberships: {},
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('currentUserName updates on LOG_IN', () => {
+    const action = {type: users.LOG_IN, user: example.USER};
+    const actual = users.currentUserNameReducer(null, action);
+    assert.deepEqual(actual, example.NAME);
+  });
+
+  it('byName updates on BATCH_GET_SUCCESS', () => {
+    const action = {type: users.BATCH_GET_SUCCESS, users: [example.USER]};
+    const actual = users.byNameReducer({}, action);
+    assert.deepEqual(actual, {[example.NAME]: example.USER});
+  });
+
+  describe('projectMembershipsReducer', () => {
+    it('updates on GATHER_PROJECT_MEMBERSHIPS_SUCCESS', () => {
+      const action = {type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+        userName: example.NAME, projectMemberships: [example.PROJECT_MEMBER]};
+      const actual = users.projectMembershipsReducer({}, action);
+      assert.deepEqual(actual, {[example.NAME]: [example.PROJECT_MEMBER]});
+    });
+
+    it('sets empty on GATHER_PROJECT_MEMBERSHIPS_SUCCESS', () => {
+      const action = {type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+        userName: example.NAME, projectMemberships: undefined};
+      const actual = users.projectMembershipsReducer({}, action);
+      assert.deepEqual(actual, {[example.NAME]: []});
+    });
+  });
+});
+
+describe('user selectors', () => {
+  it('currentUserName', () => {
+    const state = {users: {currentUserName: example.NAME}};
+    assert.deepEqual(users.currentUserName(state), example.NAME);
+  });
+
+  it('byName', () => {
+    const state = {users: {byName: example.BY_NAME}};
+    assert.deepEqual(users.byName(state), example.BY_NAME);
+  });
+
+  it('projectMemberships', () => {
+    const membershipsByName = {[example.NAME]: [example.PROJECT_MEMBER]};
+    const state = {users: {projectMemberships: membershipsByName}};
+    assert.deepEqual(users.projectMemberships(state), membershipsByName);
+  });
+});
+
+describe('user action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('batchGet', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({users: [example.USER]}));
+
+      await users.batchGet([example.NAME])(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: users.BATCH_GET_START});
+
+      const args = {names: [example.NAME]};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'BatchGetUsers', args);
+
+      const action = {type: users.BATCH_GET_SUCCESS, users: [example.USER]};
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await users.batchGet([example.NAME])(dispatch);
+
+      const action = {type: users.BATCH_GET_FAILURE, error: sinon.match.any};
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('fetch', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve(example.USER));
+
+      await users.fetch(example.NAME)(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: users.FETCH_START});
+
+      const args = {name: example.NAME};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'GetUser', args);
+
+      const fetchAction = {type: users.FETCH_SUCCESS, user: example.USER};
+      sinon.assert.calledWith(dispatch, fetchAction);
+
+      const logInAction = {type: users.LOG_IN, user: example.USER};
+      sinon.assert.calledWith(dispatch, logInAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await users.fetch(example.NAME)(dispatch);
+
+      const action = {type: users.FETCH_FAILURE, error: sinon.match.any};
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('gatherProjectMemberships', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({projectMemberships: [
+        example.PROJECT_MEMBER,
+      ]}));
+
+      await users.gatherProjectMemberships(
+          example.NAME)(dispatch);
+
+      sinon.assert.calledWith(dispatch,
+          {type: users.GATHER_PROJECT_MEMBERSHIPS_START});
+
+      const args = {user: example.NAME};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Frontend',
+          'GatherProjectMembershipsForUser', args);
+
+      const action = {
+        type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+        projectMemberships: [example.PROJECT_MEMBER],
+        userName: example.NAME,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws(new Error());
+
+      await users.batchGet([example.NAME])(dispatch);
+
+      const action = {type: users.BATCH_GET_FAILURE,
+        error: sinon.match.instanceOf(Error)};
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
diff --git a/static_src/shared/consts/approval.js b/static_src/shared/consts/approval.js
new file mode 100644
index 0000000..772025d
--- /dev/null
+++ b/static_src/shared/consts/approval.js
@@ -0,0 +1,79 @@
+// 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.
+
+/**
+ * @fileoverview A file containing common constants used in the Approvals
+ * feature.
+ */
+
+// Only approvers are allowed to set an approval state to one of these states.
+export const APPROVER_RESTRICTED_STATUSES = new Set(
+    ['NA', 'Approved', 'NotApproved']);
+
+// Map the internal enum names used in Monorail's backend from approval
+// statuses to user friendly names.
+export const STATUS_ENUM_TO_TEXT = {
+  '': 'NotSet',
+  'NEEDS_REVIEW': 'NeedsReview',
+  'NA': 'NA',
+  'REVIEW_REQUESTED': 'ReviewRequested',
+  'REVIEW_STARTED': 'ReviewStarted',
+  'NEED_INFO': 'NeedInfo',
+  'APPROVED': 'Approved',
+  'NOT_APPROVED': 'NotApproved',
+};
+
+// Reverse mapping of user friendly names to internal enum names.
+// Note that NotSet -> NOT_SET maps differently in reverse because
+// the backend sends an empty message to communicate NOT_SET.
+export const TEXT_TO_STATUS_ENUM = {
+  'NotSet': 'NOT_SET',
+  'NeedsReview': 'NEEDS_REVIEW',
+  'NA': 'NA',
+  'ReviewRequested': 'REVIEW_REQUESTED',
+  'ReviewStarted': 'REVIEW_STARTED',
+  'NeedInfo': 'NEED_INFO',
+  'Approved': 'APPROVED',
+  'NotApproved': 'NOT_APPROVED',
+};
+
+// Statuses mapped to CSS classes used to apply custom styles per
+// status like background colors.
+export const STATUS_CLASS_MAP = {
+  'NotSet': 'status-notset',
+  'NeedsReview': 'status-notset',
+  'NA': 'status-na',
+  'ReviewRequested': 'status-pending',
+  'ReviewStarted': 'status-pending',
+  'NeedInfo': 'status-pending',
+  'Approved': 'status-approved',
+  'NotApproved': 'status-rejected',
+};
+
+// Hardcoded frontent documentation for each approval status.
+export const STATUS_DOCSTRING_MAP = {
+  'NotSet': '',
+  'NeedsReview': 'Review/survey not started',
+  'NA': 'Approval gate not required',
+  'ReviewRequested': 'Approval requested',
+  'ReviewStarted': 'Approval in progress',
+  'NeedInfo': 'Approval review needs more information',
+  'Approved': 'Approved for Launch',
+  'NotApproved': 'Not Approved for Launch',
+};
+
+// The Material Design icon names that are attached to each
+// CSS class.
+export const CLASS_ICON_MAP = {
+  'status-na': 'remove',
+  'status-notset': 'warning',
+  'status-pending': 'autorenew',
+  'status-approved': 'done',
+  'status-rejected': 'close',
+};
+
+// Statuses formated as an Array rather than an Object for ease of use
+// by components.
+export const APPROVAL_STATUSES = Object.keys(STATUS_CLASS_MAP).map(
+    (status) => ({status, docstring: STATUS_DOCSTRING_MAP[status], rank: 1}));
diff --git a/static_src/shared/consts/index.js b/static_src/shared/consts/index.js
new file mode 100644
index 0000000..bb196b3
--- /dev/null
+++ b/static_src/shared/consts/index.js
@@ -0,0 +1,4 @@
+// Copyright 2020 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.
+export const SERVER_LIST_ISSUES_LIMIT = 100000;
diff --git a/static_src/shared/consts/permissions.js b/static_src/shared/consts/permissions.js
new file mode 100644
index 0000000..8c8ef1b
--- /dev/null
+++ b/static_src/shared/consts/permissions.js
@@ -0,0 +1,11 @@
+// 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.
+
+export const ISSUE_EDIT_PERMISSION = 'editissue';
+export const ISSUE_EDIT_SUMMARY_PERMISSION = 'editissuesummary';
+export const ISSUE_EDIT_STATUS_PERMISSION = 'editissuestatus';
+export const ISSUE_EDIT_OWNER_PERMISSION = 'editissueowner';
+export const ISSUE_EDIT_CC_PERMISSION = 'editissuecc';
+export const ISSUE_DELETE_PERMISSION = 'deleteissue';
+export const ISSUE_FLAGSPAM_PERMISSION = 'flagspam';
diff --git a/static_src/shared/converters.js b/static_src/shared/converters.js
new file mode 100644
index 0000000..308df2d
--- /dev/null
+++ b/static_src/shared/converters.js
@@ -0,0 +1,102 @@
+// Copyright 2020 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.
+// Based on: https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/project/project_constants.py;l=13
+const PROJECT_NAME_PATTERN = '[a-z0-9][-a-z0-9]*[a-z0-9]';
+const USER_ID_PATTERN = '\\d+';
+
+const PROJECT_MEMBER_NAME_REGEX = new RegExp(
+    `projects/(${PROJECT_NAME_PATTERN})/members/(${USER_ID_PATTERN})`);
+
+const USER_NAME_REGEX = new RegExp(`users/(${USER_ID_PATTERN})`);
+
+const PROJECT_NAME_REGEX = new RegExp(`projects/(${PROJECT_NAME_PATTERN})`);
+
+
+/**
+ * Custom error class for handling invalidly formatted resource names.
+ */
+export class ResourceNameError extends Error {
+  /** @override */
+  constructor(message) {
+    super(message || 'Invalid resource name format');
+  }
+}
+
+/**
+ * Returns a FieldMask given an array of string paths.
+ * https://developers.google.com/protocol-buffers/docs/reference/csharp/class/google/protobuf/well-known-types/field-mask#paths
+ * https://source.chromium.org/chromium/chromium/src/+/main:third_party/protobuf/python/google/protobuf/internal/well_known_types.py;l=425;drc=e10d98917fee771b0947a57468d1cadac446bc42
+ * @param {Array<string>} paths The given paths to turn into a field mask.
+ *   These should be a comma separated list of camel case strings.
+ * @return {string}
+ */
+export function pathsToFieldMask(paths) {
+  return paths.join(',');
+}
+
+/**
+ * Extract a User ID from a User resource name.
+ * @param {UserName} user User resource name.
+ * @return {string} User ID.
+ * @throws {Error} if the User resource name is invalid.
+ */
+export function extractUserId(user) {
+  const matches = user.match(USER_NAME_REGEX);
+  if (!matches) {
+    throw new ResourceNameError();
+  }
+  return matches[1];
+}
+
+/**
+ * Extract a project's displayName from a Project resource name.
+ * @param {ProjectName} project Project resource name.
+ * @return {string} The project's displayName.
+ * @throws {Error} if the Project resource name is invalid.
+ */
+export function extractProjectDisplayName(project) {
+  const matches = project.match(PROJECT_NAME_REGEX);
+  if (!matches) {
+    throw new ResourceNameError();
+  }
+  return matches[1];
+}
+
+/**
+ * Gets the displayName of the Project referenced in a ProjectMember
+ * resource name.
+ * @param {ProjectMemberName} projectMember ProjectMember resource name.
+ * @return {string} A display name for a project.
+ */
+export function extractProjectFromProjectMember(projectMember) {
+  const matches = projectMember.match(PROJECT_MEMBER_NAME_REGEX);
+  if (!matches) {
+    throw new ResourceNameError();
+  }
+  return matches[1];
+}
+
+/**
+ * Creates a ProjectStar resource name based on a UserName nad a ProjectName.
+ * @param {ProjectName} project Resource name of the referenced project.
+ * @param {UserName} user Resource name of the referenced user.
+ * @return {ProjectStarName}
+ * @throws {Error} If the project or user resource name is invalid.
+ */
+export function projectAndUserToStarName(project, user) {
+  if (!project || !user) return undefined;
+  const userId = extractUserId(user);
+  const projectName = extractProjectDisplayName(project);
+  return `users/${userId}/projectStars/${projectName}`;
+}
+
+/**
+ * Converts a given ProjectMemberName to just the ProjectName segment present.
+ * @param {ProjectMemberName} projectMember Resource name of a ProjectMember.
+ * @return {ProjectName} Resource name of the referenced project.
+ */
+export function projectMemberToProjectName(projectMember) {
+  const project = extractProjectFromProjectMember(projectMember);
+  return `projects/${project}`;
+}
diff --git a/static_src/shared/converters.test.js b/static_src/shared/converters.test.js
new file mode 100644
index 0000000..428a74d
--- /dev/null
+++ b/static_src/shared/converters.test.js
@@ -0,0 +1,112 @@
+// Copyright 2020 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 {ResourceNameError, pathsToFieldMask, extractUserId,
+  extractProjectDisplayName, extractProjectFromProjectMember,
+  projectAndUserToStarName, projectMemberToProjectName} from './converters.js';
+
+describe('pathsToFieldMask', () => {
+  it('converts an array of strings to a FieldMask', () => {
+    assert.equal(pathsToFieldMask(['foo', 'barQux', 'qaz']), 'foo,barQux,qaz');
+  });
+});
+
+describe('extractUserId', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(() => extractUserId('projects/1234'),
+        ResourceNameError);
+    assert.throws(() => extractUserId('users/notAnId'),
+        ResourceNameError);
+    assert.throws(() => extractUserId('user/1234'),
+        ResourceNameError);
+  });
+
+  it('extracts user ID', () => {
+    assert.equal(extractUserId('users/1234'), '1234');
+  });
+});
+
+describe('extractProjectDisplayName', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(() => extractProjectDisplayName('users/1234'),
+        ResourceNameError);
+    assert.throws(() => extractProjectDisplayName('projects/(what)'),
+        ResourceNameError);
+    assert.throws(() => extractProjectDisplayName('project/test'),
+        ResourceNameError);
+    assert.throws(() => extractProjectDisplayName('projects/-test-'),
+        ResourceNameError);
+  });
+
+  it('extracts project display name', () => {
+    assert.equal(extractProjectDisplayName('projects/1234'), '1234');
+    assert.equal(extractProjectDisplayName('projects/monorail'), 'monorail');
+    assert.equal(extractProjectDisplayName('projects/test-project'),
+        'test-project');
+    assert.equal(extractProjectDisplayName('projects/what-is-love2'),
+        'what-is-love2');
+  });
+});
+
+describe('extractProjectFromProjectMember', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(
+        () => extractProjectFromProjectMember(
+            'projects/monorail/members/fakeName'),
+        ResourceNameError);
+    assert.throws(
+        () => extractProjectFromProjectMember(
+            'projects/-invalid-project-/members/1234'),
+        ResourceNameError);
+    assert.throws(
+        () => extractProjectFromProjectMember(
+            'projects/monorail/member/1234'),
+        ResourceNameError);
+  });
+
+  it('extracts project display name', () => {
+    assert.equal(extractProjectFromProjectMember(
+        'projects/1234/members/1234'), '1234');
+    assert.equal(extractProjectFromProjectMember(
+        'projects/monorail/members/1234'), 'monorail');
+    assert.equal(extractProjectFromProjectMember(
+        'projects/test-project/members/1234'), 'test-project');
+    assert.equal(extractProjectFromProjectMember(
+        'projects/what-is-love2/members/1234'), 'what-is-love2');
+  });
+});
+
+describe('projectAndUserToStarName', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(
+        () => projectAndUserToStarName('users/1234', 'projects/monorail'),
+        ResourceNameError);
+  });
+
+  it('generates project star resource name', () => {
+    assert.equal(projectAndUserToStarName('projects/monorail', 'users/1234'),
+        'users/1234/projectStars/monorail');
+  });
+});
+
+describe('projectMemberToProjectName', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(
+        () => projectMemberToProjectName(
+            'projects/monorail/members/fakeName'),
+        ResourceNameError);
+  });
+
+  it('creates project resource name', () => {
+    assert.equal(projectMemberToProjectName(
+        'projects/1234/members/1234'), 'projects/1234');
+    assert.equal(projectMemberToProjectName(
+        'projects/monorail/members/1234'), 'projects/monorail');
+    assert.equal(projectMemberToProjectName(
+        'projects/test-project/members/1234'), 'projects/test-project');
+    assert.equal(projectMemberToProjectName(
+        'projects/what-is-love2/members/1234'), 'projects/what-is-love2');
+  });
+});
diff --git a/static_src/shared/convertersV0.js b/static_src/shared/convertersV0.js
new file mode 100644
index 0000000..ffb8a36
--- /dev/null
+++ b/static_src/shared/convertersV0.js
@@ -0,0 +1,610 @@
+// 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.
+
+/**
+ * @fileoverview This file collects helpers for managing various canonical
+ * formats used within Monorail's frontend. When converting between common
+ * Objects, for example, it's recommended to use the helpers in this file
+ * to ensure consistency across conversions.
+ *
+ * Converters between v0 and v3 object types are included in this file
+ * as well.
+ */
+
+import qs from 'qs';
+
+import {equalsIgnoreCase, capitalizeFirst} from './helpers.js';
+import {fromShortlink} from 'shared/federated.js';
+import {UserInputError} from 'shared/errors.js';
+import './typedef.js';
+
+/**
+ * Common restriction labels to do things users frequently want to do
+ * with restrictions.
+ * This code is a frontend replication of old Python server code that
+ * hardcoded specific restriction labels.
+ * @type {Array<LabelDef>}
+ */
+const FREQUENT_ISSUE_RESTRICTIONS = Object.freeze([
+  {
+    label: 'Restrict-View-EditIssue',
+    docstring: 'Only users who can edit the issue may access it',
+  },
+  {
+    label: 'Restrict-AddIssueComment-EditIssue',
+    docstring: 'Only users who can edit the issue may add comments',
+  },
+]);
+
+/**
+ * The set of actions that permissions on an issue can be applied to.
+ * For example, in the Restrict-View-Google label, "View" is an action.
+ * @type {Array<string>}
+ */
+const STANDARD_ISSUE_ACTIONS = [
+  'View', 'EditIssue', 'AddIssueComment', 'DeleteIssue', 'FlagSpam'];
+
+// A Regex defining the canonical String format used in Monorail for allowing
+// users to input structured localId and projectName values in free text inputs.
+// Match: projectName:localId format where projectName is optional.
+// ie: "monorail:1234" or "1234".
+const ISSUE_ID_REGEX = /(?:([a-z0-9-]+):)?(\d+)/i;
+
+// RFC 2821-compliant email address regex used by the server when validating
+// email addresses.
+// eslint-disable-next-line max-len
+const RFC_2821_EMAIL_REGEX = /^[-a-zA-Z0-9!#$%&'*+\/=?^_`{|}~]+(?:[.][-a-zA-Z0-9!#$%&'*+\/=?^_`{|}~]+)*@(?:(?:[0-9a-zA-Z](?:[-]*[0-9a-zA-Z]+)*)(?:\.[0-9a-zA-Z](?:[-]*[0-9a-zA-Z]+)*)*)\.(?:[a-zA-Z]{2,9})$/;
+
+/**
+ * Converts a displayName into a canonical UserRef Object format.
+ *
+ * @param {string} displayName The user's email address, used as a display name.
+ * @return {UserRef} UserRef formatted object that contains a
+ *   user's displayName.
+ */
+export function displayNameToUserRef(displayName) {
+  if (displayName && !RFC_2821_EMAIL_REGEX.test(displayName)) {
+    throw new UserInputError(`Invalid email address: ${displayName}`);
+  }
+  return {displayName};
+}
+
+/**
+ * Converts a displayName into a canonical UserRef Object format.
+ *
+ * @param {string} user The user's email address, used as a display name,
+ *   or their numeric user ID.
+ * @return {UserRef} UserRef formatted object that contains a
+ *   user's displayName or userId.
+ */
+export function userIdOrDisplayNameToUserRef(user) {
+  if (RFC_2821_EMAIL_REGEX.test(user)) {
+    return {displayName: user};
+  }
+  const userId = Number.parseInt(user);
+  if (Number.isNaN(userId)) {
+    throw new UserInputError(`Invalid email address or user ID: ${user}`);
+  }
+  return {userId};
+}
+
+/**
+ * Converts an Object into a standard UserRef Object with only a displayName
+ * and userId. Used for cases when we need to use only the data required to
+ * identify a unique user, such as when requesting information related to a user
+ * through the API.
+ *
+ * @param {UserV0} user An Object representing a user, in the JSON format
+ *   returned by the pRPC API.
+ * @return {UserRef} UserRef style Object.
+ */
+export function userToUserRef(user) {
+  if (!user) return {};
+  const {userId, displayName} = user;
+  return {userId, displayName};
+}
+
+/**
+ * Converts a User resource name to a numeric user ID.
+ * @param {string} name
+ * @return {number}
+ */
+export function userNameToId(name) {
+  return Number.parseInt(name.split('/')[1]);
+}
+
+/**
+ * Converts a v3 API User object to a v0 API UserRef.
+ * @param {User} user
+ * @return {UserRef}
+ */
+export function userV3ToRef(user) {
+  return {userId: userNameToId(user.name), displayName: user.displayName};
+}
+
+/**
+ * Convert a UserRef style Object to a userId string.
+ *
+ * @param {UserRef} userRef Object expected to contain a userId key.
+ * @return {number} the unique ID of the user.
+ */
+export function userRefToId(userRef) {
+  return userRef && userRef.userId;
+}
+
+/**
+ * Extracts the displayName property from a UserRef Object.
+ *
+ * @param {UserRef} userRef UserRef Object uniquely identifying a user.
+ * @return {string} The user's display name (email address).
+ */
+export function userRefToDisplayName(userRef) {
+  return userRef && userRef.displayName;
+}
+
+/**
+ * Converts an Array of UserRefs to an Array of display name Strings.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<string>} Array of display names.
+ */
+export function userRefsToDisplayNames(userRefs) {
+  if (!userRefs) return [];
+  return userRefs.map(userRefToDisplayName);
+}
+
+/**
+ * Takes an Array of UserRefs and keeps only UserRefs where ID
+ * is known.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<UserRef>} Filtered Array IDs guaranteed.
+ */
+export function userRefsWithIds(userRefs) {
+  if (!userRefs) return [];
+  return userRefs.filter((u) => u.userId);
+}
+
+/**
+ * Takes an Array of UserRefs and returns displayNames for
+ * only those refs with IDs specified.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<string>} Array of user displayNames.
+ */
+export function filteredUserDisplayNames(userRefs) {
+  if (!userRefs) return [];
+  return userRefsToDisplayNames(userRefsWithIds(userRefs));
+}
+
+/**
+ * Takes in the name of a label and turns it into a LabelRef Object.
+ *
+ * @param {string} label The name of a label.
+ * @return {LabelRef}
+ */
+export function labelStringToRef(label) {
+  return {label};
+}
+
+/**
+ * Takes in the name of a label and turns it into a LabelRef Object.
+ *
+ * @param {LabelRef} labelRef
+ * @return {string} The name of the label.
+ */
+export function labelRefToString(labelRef) {
+  if (!labelRef) return;
+  return labelRef.label;
+}
+
+/**
+ * Converts an Array of LabelRef Objects to label name Strings.
+ *
+ * @param {Array<LabelRef>} labelRefs Array of LabelRef Objects.
+ * @return {Array<string>} Array of label names.
+ */
+export function labelRefsToStrings(labelRefs) {
+  if (!labelRefs) return [];
+  return labelRefs.map(labelRefToString);
+}
+
+/**
+ * Filters a list of labels into a list of only labels with one word.
+ *
+ * @param {Array<LabelRef>} labelRefs
+ * @return {Array<LabelRef>} only the LabelRefs that do not have multiple words.
+ */
+export function labelRefsToOneWordLabels(labelRefs) {
+  if (!labelRefs) return [];
+  return labelRefs.filter(({label}) => {
+    return isOneWordLabel(label);
+  });
+}
+
+/**
+ * Checks whether a particular label is one word.
+ *
+ * @param {string} label the name of the label being checked.
+ * @return {boolean} Whether the label is one word or not.
+ */
+export function isOneWordLabel(label = '') {
+  const words = label.split('-');
+  return words.length === 1;
+}
+
+/**
+ * Creates a LabelDef Object for a restriction label given an action
+ * and a permission.
+ * @param {string} action What action a restriction is applied to.
+ *   eg. "View", "EditIssue", "AddIssueComment".
+ * @param {string} permission The permission group that has access to
+ *   the restricted behavior. eg. "Google".
+ * @return {LabelDef}
+ */
+export function _makeRestrictionLabel(action, permission) {
+  const perm = capitalizeFirst(permission);
+  return {
+    label: `Restrict-${action}-${perm}`,
+    docstring: `Permission ${perm} needed to use ${action}`,
+  };
+}
+
+/**
+ * Given a list of custom permissions defined for a project, this function
+ * generates simulated LabelDef objects for those permissions + default
+ * restriction labels that all projects should have.
+ * @param {Array<string>=} customPermissions
+ * @param {Array<string>=} actions
+ * @param {Array<LabelDef>=} defaultRestrictionLabels Configurable default
+ *   restriction labels to include regardless of custom permissions.
+ * @return {Array<LabelDef>}
+ */
+export function restrictionLabelsForPermissions(customPermissions = [],
+    actions = STANDARD_ISSUE_ACTIONS,
+    defaultRestrictionLabels = FREQUENT_ISSUE_RESTRICTIONS) {
+  const labels = [];
+  actions.forEach((action) => {
+    customPermissions.forEach((permission) => {
+      labels.push(_makeRestrictionLabel(action, permission));
+    });
+  });
+  return [...labels, ...defaultRestrictionLabels];
+}
+
+/**
+ * Converts a custom field name in to the prefix format used in
+ * enum type field values. Monorail defines the enum options for
+ * a custom field as labels.
+ *
+ * @param {string} fieldName Name of a custom field.
+ * @return {string} The label prefixes for enum choices
+ *   associated with the field.
+ */
+export function fieldNameToLabelPrefix(fieldName) {
+  return `${fieldName.toLowerCase()}-`;
+}
+
+/**
+ * Finds all prefixes in a label's name, delimited by '-'. A given label
+ * can have multiple possible prefixes, one for each instance of '-'.
+ * Labels that share the same prefix are implicitly treated like
+ * enum fields in certain parts of Monorail's UI.
+ *
+ * @param {string} label The name of the label.
+ * @return {Array<string>} All prefixes in the label.
+ */
+export function labelNameToLabelPrefixes(label) {
+  if (!label) return;
+  const prefixes = [];
+  for (let i = 0; i < label.length; i++) {
+    if (label[i] === '-') {
+      prefixes.push(label.substring(0, i));
+    }
+  }
+  return prefixes;
+}
+
+/**
+ * Truncates a label to include only the label's value, delimited
+ * by '-'.
+ *
+ * @param {string} label The name of the label.
+ * @param {string} fieldName The field name that the label is having a
+ *   value extracted for.
+ * @return {string} The label's value.
+ */
+export function labelNameToLabelValue(label, fieldName) {
+  if (!label || !fieldName || isOneWordLabel(label)) return null;
+  const prefix = fieldName.toLowerCase() + '-';
+  if (!label.toLowerCase().startsWith(prefix)) return null;
+
+  return label.substring(prefix.length);
+}
+
+/**
+ * Converts a FieldDef to a v3 FieldDef resource name.
+ * @param {string} projectName The name of the project.
+ * @param {FieldDef} fieldDef A FieldDef Object from the pRPC API proto objects.
+ * @return {string} The v3 FieldDef name, e.g. 'projects/proj/fieldDefs/fieldId'
+ */
+export function fieldDefToName(projectName, fieldDef) {
+  return `projects/${projectName}/fieldDefs/${fieldDef.fieldRef.fieldId}`;
+}
+
+/**
+ * Extracts just the name of the status from a StatusRef Object.
+ *
+ * @param {StatusRef} statusRef
+ * @return {string} The name of the status.
+ */
+export function statusRefToString(statusRef) {
+  return statusRef.status;
+}
+
+/**
+ * Extracts the name of multiple statuses from multiple StatusRef Objects.
+ *
+ * @param {Array<StatusRef>} statusRefs
+ * @return {Array<string>} The names of the statuses inputted.
+ */
+export function statusRefsToStrings(statusRefs) {
+  return statusRefs.map(statusRefToString);
+}
+
+/**
+ * Takes the name of a component and converts it into a ComponentRef
+ * Object.
+ *
+ * @param {string} path Name of the component.
+ * @return {ComponentRef}
+ */
+export function componentStringToRef(path) {
+  return {path};
+}
+
+/**
+ * Extracts just the name of a component from a ComponentRef.
+ *
+ * @param {ComponentRef} componentRef
+ * @return {string} The name of the component.
+ */
+export function componentRefToString(componentRef) {
+  return componentRef && componentRef.path;
+}
+
+/**
+ * Extracts the names of multiple components from multiple refs.
+ *
+ * @param {Array<ComponentRef>} componentRefs
+ * @return {Array<string>} Array of component names.
+ */
+export function componentRefsToStrings(componentRefs) {
+  if (!componentRefs) return [];
+  return componentRefs.map(componentRefToString);
+}
+
+/**
+ * Takes a String with a project name and issue ID in Monorail's canonical
+ * IssueRef format and converts it into an IssueRef Object.
+ *
+ * @param {IssueRefString} idStr A String of the format projectName:1234, a
+ *   standard issue ID input format used across Monorail.
+ * @param {string=} defaultProjectName The implied projectName if none is
+ *   specified.
+ * @return {IssueRef}
+ * @throws {UserInputError} If the IssueRef string is invalidly formatted.
+ */
+export function issueStringToRef(idStr, defaultProjectName) {
+  if (!idStr) return {};
+
+  // If the string includes a slash, it's an external tracker ref.
+  if (idStr.includes('/')) {
+    return {extIdentifier: idStr};
+  }
+
+  const matches = idStr.match(ISSUE_ID_REGEX);
+  if (!matches) {
+    throw new UserInputError(
+        `Invalid issue ref: ${idStr}. Expected [projectName:]issueId.`);
+  }
+  const projectName = matches[1] ? matches[1] : defaultProjectName;
+
+  if (!projectName) {
+    throw new UserInputError(
+        `Issue ref must include a project name or specify a default project.`);
+  }
+
+  const localId = Number.parseInt(matches[2]);
+  return {localId, projectName};
+}
+
+/**
+ * Takes an IssueRefString and converts it into an IssueRef Object, checking
+ * that it's not the same as another specified issueRef. ie: validates that an
+ * inputted blocking issue is not the same as the issue being blocked.
+ *
+ * @param {IssueRef} issueRef The issue that the IssueRefString is being
+ *   compared to.
+ * @param {IssueRefString} idStr A String of the format projectName:1234, a
+ *   standard issue ID input format used across Monorail.
+ * @return {IssueRef}
+ * @throws {UserInputError} If the IssueRef string is invalidly formatted
+ *   or if the issue is equivalent to the linked issue.
+ */
+export function issueStringToBlockingRef(issueRef, idStr) {
+  // TODO(zhangtiff): Consider simplifying this helper function to only validate
+  // that an issue does not block itself rather than also doing string parsing.
+  const result = issueStringToRef(idStr, issueRef.projectName);
+  if (result.projectName === issueRef.projectName &&
+      result.localId === issueRef.localId) {
+    throw new UserInputError(
+        `Invalid issue ref: ${idStr
+        }. Cannot merge or block an issue on itself.`);
+  }
+  return result;
+}
+
+/**
+ * Converts an IssueRef into a canonical String format. ie: "project:1234"
+ *
+ * @param {IssueRef} ref
+ * @param {string=} projectName The current project context. The
+ *   generated String excludes the projectName if it matches the
+ *   project the user is currently viewing, to create simpler
+ *   issue ID links.
+ * @return {IssueRefString} A String representing the pieces of an IssueRef.
+ */
+export function issueRefToString(ref, projectName = undefined) {
+  if (!ref) return '';
+
+  if (ref.hasOwnProperty('extIdentifier')) {
+    return ref.extIdentifier;
+  }
+
+  if (projectName && projectName.length &&
+      equalsIgnoreCase(ref.projectName, projectName)) {
+    return `${ref.localId}`;
+  }
+  return `${ref.projectName}:${ref.localId}`;
+}
+
+/**
+ * Converts a full Issue Object into only the pieces of its data needed
+ * to define an IssueRef. Useful for cases when we don't want to send excess
+ * information to ifentify an Issue.
+ *
+ * @param {Issue} issue A full Issue Object.
+ * @return {IssueRef} Just the ID part of the Issue Object.
+ */
+export function issueToIssueRef(issue) {
+  if (!issue) return {};
+
+  return {localId: issue.localId,
+    projectName: issue.projectName};
+}
+
+/**
+ * Converts a full Issue Object into an IssueRefString
+ *
+ * @param {Issue} issue A full Issue Object.
+ * @param {string=} defaultProjectName The default project the String should
+ *   assume.
+ * @return {IssueRefString} A String with all the data needed to
+ *   construct an IssueRef.
+ */
+export function issueToIssueRefString(issue, defaultProjectName = undefined) {
+  if (!issue) return '';
+
+  const ref = issueToIssueRef(issue);
+  return issueRefToString(ref, defaultProjectName);
+}
+
+/**
+ * Creates a link to a particular issue specified in an IssueRef.
+ *
+ * @param {IssueRef} ref The issue that the generated URL will point to.
+ * @param {Object} queryParams The URL params for the URL.
+ * @return {string} The URL for the issue's page as a relative path.
+ */
+export function issueRefToUrl(ref, queryParams = {}) {
+  const queryParamsCopy = {...queryParams};
+
+  if (!ref) return '';
+
+  if (ref.extIdentifier) {
+    const extRef = fromShortlink(ref.extIdentifier);
+    if (!extRef) {
+      console.error(`No tracker found for reference: ${ref.extIdentifier}`);
+      return '';
+    }
+    return extRef.toURL();
+  }
+
+  let paramString = '';
+  if (Object.keys(queryParamsCopy).length) {
+    delete queryParamsCopy.id;
+
+    paramString = `&${qs.stringify(queryParamsCopy)}`;
+  }
+
+  return `/p/${ref.projectName}/issues/detail?id=${ref.localId}${paramString}`;
+}
+
+/**
+ * Converts multiple IssueRef Objects into Strings in the canonical IssueRef
+ * String form expeced by Monorail.
+ *
+ * @param {Array<IssueRef>} arr Array of IssueRefs to convert to Strings.
+ * @param {string} projectName The default project name.
+ * @return {Array<IssueRefString>} Array of Strings where each entry is
+ *   represents one IssueRef.
+ */
+export function issueRefsToStrings(arr, projectName) {
+  if (!arr || !arr.length) return [];
+  return arr.map((ref) => issueRefToString(ref, projectName));
+}
+
+/**
+ * Converts an issue name in the v3 API to an IssueRef in the v0 API.
+ * @param {string} name The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ * @return {IssueRef} An IssueRef.
+ */
+export function issueNameToRef(name) {
+  const nameParts = name.split('/');
+  return {
+    projectName: nameParts[1],
+    localId: parseInt(nameParts[3]),
+  };
+}
+
+/**
+ * Converts an issue name in the v3 API to an IssueRefString in the v0 API.
+ * @param {string} name The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ * @return {IssueRefString} A String with all the data needed to
+ *   construct an IssueRef.
+ */
+export function issueNameToRefString(name) {
+  const nameParts = name.split('/');
+  return `${nameParts[1]}:${nameParts[3]}`;
+}
+
+/**
+ * Converts an v0 Issue to a v3 Issue name.
+ * @param {Issue} issue An Issue Object from the pRPC API issue_objects.proto.
+ * @return {string} The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ */
+export function issueToName(issue) {
+  return `projects/${issue.projectName}/issues/${issue.localId}`;
+}
+
+/**
+ * Since Monorail stores issue descriptions and description updates as comments,
+ * this function exists to filter a list of comments to get only those comments
+ * that are marked as descriptions.
+ *
+ * @param {Array<IssueComment>} comments List of many comments, usually all
+ *   comments associated with an issue.
+ * @return {Array<IssueComment>} List of only the comments that are
+ *   descriptions.
+ */
+export function commentListToDescriptionList(comments) {
+  if (!comments) return [];
+  // First comment is always a description, even if it doesn't have a
+  // descriptionNum.
+  return comments.filter((c, i) => !i || c.descriptionNum);
+}
+
+/**
+ * Wraps a String value for a field and a FieldRef into a FieldValue
+ * Object.
+ *
+ * @param {FieldRef} fieldRef A reference to the custom field that this
+ *   value is tied to.
+ * @param {string} value The value associated with the FieldRef.
+ * @return {FieldValue}
+ */
+export function valueToFieldValue(fieldRef, value) {
+  return {fieldRef, value};
+}
diff --git a/static_src/shared/convertersV0.test.js b/static_src/shared/convertersV0.test.js
new file mode 100644
index 0000000..2e34622
--- /dev/null
+++ b/static_src/shared/convertersV0.test.js
@@ -0,0 +1,427 @@
+// 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 {UserInputError} from 'shared/errors.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+import {displayNameToUserRef, userIdOrDisplayNameToUserRef,
+  userNameToId, userV3ToRef, labelStringToRef,
+  labelRefToString, labelRefsToStrings, labelRefsToOneWordLabels,
+  isOneWordLabel, _makeRestrictionLabel, restrictionLabelsForPermissions,
+  fieldDefToName, statusRefToString, statusRefsToStrings,
+  componentStringToRef, componentRefToString, componentRefsToStrings,
+  issueStringToRef, issueStringToBlockingRef, issueRefToString,
+  issueRefToUrl, fieldNameToLabelPrefix, labelNameToLabelPrefixes,
+  labelNameToLabelValue, commentListToDescriptionList, valueToFieldValue,
+  issueToIssueRef, issueNameToRef, issueNameToRefString, issueToName,
+} from './convertersV0.js';
+
+describe('displayNameToUserRef', () => {
+  it('converts displayName', () => {
+    assert.deepEqual(
+        displayNameToUserRef('foo@bar.com'),
+        {displayName: 'foo@bar.com'});
+  });
+
+  it('throws on invalid email', () => {
+    assert.throws(() => displayNameToUserRef('foo'), UserInputError);
+  });
+});
+
+describe('userIdOrDisplayNameToUserRef', () => {
+  it('converts userId', () => {
+    assert.throws(() => displayNameToUserRef('foo'));
+    assert.deepEqual(
+        userIdOrDisplayNameToUserRef('12345678'),
+        {userId: 12345678});
+  });
+
+  it('converts displayName', () => {
+    assert.deepEqual(
+        userIdOrDisplayNameToUserRef('foo@bar.com'),
+        {displayName: 'foo@bar.com'});
+  });
+
+  it('throws if not an email or numeric id', () => {
+    assert.throws(() => userIdOrDisplayNameToUserRef('foo'), UserInputError);
+  });
+});
+
+it('userNameToId', () => {
+  assert.deepEqual(userNameToId(exampleUsers.NAME), exampleUsers.ID);
+});
+
+it('userV3ToRef', () => {
+  assert.deepEqual(userV3ToRef(exampleUsers.USER), exampleUsers.USER_REF);
+});
+
+describe('labelStringToRef', () => {
+  it('converts label', () => {
+    assert.deepEqual(labelStringToRef('foo'), {label: 'foo'});
+  });
+});
+
+describe('labelRefToString', () => {
+  it('converts labelRef', () => {
+    assert.deepEqual(labelRefToString({label: 'foo'}), 'foo');
+  });
+});
+
+describe('labelRefsToStrings', () => {
+  it('converts labelRefs', () => {
+    assert.deepEqual(labelRefsToStrings([{label: 'foo'}, {label: 'test'}]),
+        ['foo', 'test']);
+  });
+});
+
+describe('labelRefsToOneWordLabels', () => {
+  it('empty', () => {
+    assert.deepEqual(labelRefsToOneWordLabels(), []);
+    assert.deepEqual(labelRefsToOneWordLabels([]), []);
+  });
+
+  it('filters multi-word labels', () => {
+    assert.deepEqual(labelRefsToOneWordLabels([
+      {label: 'hello'},
+      {label: 'filter-me'},
+      {label: 'hello-world'},
+      {label: 'world'},
+      {label: 'this-label-has-so-many-words'},
+    ]), [
+      {label: 'hello'},
+      {label: 'world'},
+    ]);
+  });
+});
+
+describe('isOneWordLabel', () => {
+  it('true only for one word labels', () => {
+    assert.isTrue(isOneWordLabel('test'));
+    assert.isTrue(isOneWordLabel('LABEL'));
+    assert.isTrue(isOneWordLabel('Security'));
+
+    assert.isFalse(isOneWordLabel('Restrict-View-EditIssue'));
+    assert.isFalse(isOneWordLabel('Type-Feature'));
+  });
+});
+
+describe('_makeRestrictionLabel', () => {
+  it('creates label', () => {
+    assert.deepEqual(_makeRestrictionLabel('View', 'Google'), {
+      label: `Restrict-View-Google`,
+      docstring: `Permission Google needed to use View`,
+    });
+  });
+
+  it('capitalizes permission name', () => {
+    assert.deepEqual(_makeRestrictionLabel('EditIssue', 'security'), {
+      label: `Restrict-EditIssue-Security`,
+      docstring: `Permission Security needed to use EditIssue`,
+    });
+  });
+});
+
+describe('restrictionLabelsForPermissions', () => {
+  it('creates labels for permissions and actions', () => {
+    assert.deepEqual(restrictionLabelsForPermissions(['google', 'security'],
+        ['View', 'EditIssue'], []), [
+      {
+        label: 'Restrict-View-Google',
+        docstring: 'Permission Google needed to use View',
+      }, {
+        label: 'Restrict-View-Security',
+        docstring: 'Permission Security needed to use View',
+      }, {
+        label: 'Restrict-EditIssue-Google',
+        docstring: 'Permission Google needed to use EditIssue',
+      }, {
+        label: 'Restrict-EditIssue-Security',
+        docstring: 'Permission Security needed to use EditIssue',
+      },
+    ]);
+  });
+
+  it('appends default labels when specified', () => {
+    assert.deepEqual(restrictionLabelsForPermissions(['Google'], ['View'], [
+      {label: 'Restrict-Hello-World', docstring: 'description of label'},
+    ]), [
+      {
+        label: 'Restrict-View-Google',
+        docstring: 'Permission Google needed to use View',
+      },
+      {label: 'Restrict-Hello-World', docstring: 'description of label'},
+    ]);
+  });
+});
+
+describe('fieldNameToLabelPrefix', () => {
+  it('converts fieldName', () => {
+    assert.deepEqual(fieldNameToLabelPrefix('test'), 'test-');
+    assert.deepEqual(fieldNameToLabelPrefix('test-hello'), 'test-hello-');
+    assert.deepEqual(fieldNameToLabelPrefix('WHATEVER'), 'whatever-');
+  });
+});
+
+describe('labelNameToLabelPrefixes', () => {
+  it('converts labelName', () => {
+    assert.deepEqual(labelNameToLabelPrefixes('test'), []);
+    assert.deepEqual(labelNameToLabelPrefixes('test-hello'), ['test']);
+    assert.deepEqual(labelNameToLabelPrefixes('WHATEVER-this-label-is'),
+        ['WHATEVER', 'WHATEVER-this', 'WHATEVER-this-label']);
+  });
+});
+
+describe('labelNameToLabelValue', () => {
+  it('returns null when no matching value found in label', () => {
+    assert.isNull(labelNameToLabelValue('test-hello', ''));
+    assert.isNull(labelNameToLabelValue('', 'test'));
+    assert.isNull(labelNameToLabelValue('test-hello', 'hello'));
+    assert.isNull(labelNameToLabelValue('test-hello', 'tes'));
+    assert.isNull(labelNameToLabelValue('test', 'test'));
+  });
+
+  it('converts labelName', () => {
+    assert.deepEqual(labelNameToLabelValue('test-hello', 'test'), 'hello');
+    assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+        'WHATEVER'), 'this-label-is');
+    assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+        'WHATEVER-this'), 'label-is');
+    assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+        'WHATEVER-this-label'), 'is');
+  });
+
+  it('fieldName is case insenstitive', () => {
+    assert.deepEqual(labelNameToLabelValue('test-hello', 'TEST'), 'hello');
+    assert.deepEqual(labelNameToLabelValue('test-hello', 'tEsT'), 'hello');
+    assert.deepEqual(labelNameToLabelValue('TEST-hello', 'test'), 'hello');
+  });
+});
+
+describe('fieldDefToName', () => {
+  it('converts fieldDef', () => {
+    const fieldDef = {fieldRef: {fieldId: '1'}};
+    const actual = fieldDefToName('project-name', fieldDef);
+    assert.equal(actual, 'projects/project-name/fieldDefs/1');
+  });
+});
+
+describe('statusRefToString', () => {
+  it('converts statusRef', () => {
+    assert.deepEqual(statusRefToString({status: 'foo'}), 'foo');
+  });
+});
+
+describe('statusRefsToStrings', () => {
+  it('converts statusRefs', () => {
+    assert.deepEqual(statusRefsToStrings(
+        [{status: 'hello'}, {status: 'world'}]), ['hello', 'world']);
+  });
+});
+
+describe('componentStringToRef', () => {
+  it('converts component', () => {
+    assert.deepEqual(componentStringToRef('foo'), {path: 'foo'});
+  });
+});
+
+describe('componentRefToString', () => {
+  it('converts componentRef', () => {
+    assert.deepEqual(componentRefToString({path: 'Hello>World'}),
+        'Hello>World');
+  });
+});
+
+describe('componentRefsToStrings', () => {
+  it('converts componentRefs', () => {
+    assert.deepEqual(componentRefsToStrings(
+        [{path: 'Hello>World'}, {path: 'Test'}]), ['Hello>World', 'Test']);
+  });
+});
+
+describe('issueStringToRef', () => {
+  it('converts issue default project', () => {
+    assert.deepEqual(
+        issueStringToRef('1234', 'proj'),
+        {projectName: 'proj', localId: 1234});
+  });
+
+  it('converts issue with project', () => {
+    assert.deepEqual(
+        issueStringToRef('foo:1234', 'proj'),
+        {projectName: 'foo', localId: 1234});
+  });
+
+  it('converts external issue references', () => {
+    assert.deepEqual(
+        issueStringToRef('b/123456', 'proj'),
+        {extIdentifier: 'b/123456'});
+  });
+
+  it('throws on invalid input', () => {
+    assert.throws(() => issueStringToRef('foo', 'proj'));
+  });
+});
+
+describe('issueStringToBlockingRef', () => {
+  it('converts issue default project', () => {
+    assert.deepEqual(
+        issueStringToBlockingRef({projectName: 'proj', localId: 1}, '1234'),
+        {projectName: 'proj', localId: 1234});
+  });
+
+  it('converts issue with project', () => {
+    assert.deepEqual(
+        issueStringToBlockingRef({projectName: 'proj', localId: 1}, 'foo:1234'),
+        {projectName: 'foo', localId: 1234});
+  });
+
+  it('throws on invalid input', () => {
+    assert.throws(() => issueStringToBlockingRef(
+        {projectName: 'proj', localId: 1}, 'foo'));
+  });
+
+  it('throws when blocking an issue on itself', () => {
+    assert.throws(() => issueStringToBlockingRef(
+        {projectName: 'proj', localId: 123}, 'proj:123'));
+    assert.throws(() => issueStringToBlockingRef(
+        {projectName: 'proj', localId: 123}, '123'));
+  });
+});
+
+describe('issueRefToString', () => {
+  it('no ref', () => {
+    assert.equal(issueRefToString(), '');
+  });
+
+  it('ref with no project name', () => {
+    assert.equal(
+        'other:1234',
+        issueRefToString({projectName: 'other', localId: 1234}),
+    );
+  });
+
+  it('ref with different project name', () => {
+    assert.equal(
+        'other:1234',
+        issueRefToString({projectName: 'other', localId: 1234}, 'proj'),
+    );
+  });
+
+  it('ref with same project name', () => {
+    assert.equal(
+        '1234',
+        issueRefToString({projectName: 'proj', localId: 1234}, 'proj'),
+    );
+  });
+
+  it('external ref', () => {
+    assert.equal(
+        'b/123456',
+        issueRefToString({extIdentifier: 'b/123456'}, 'proj'),
+    );
+  });
+});
+
+describe('issueToIssueRef', () => {
+  it('creates ref', () => {
+    const issue = {'localId': 1, 'projectName': 'proj', 'starCount': 1};
+    const expectedRef = {'localId': 1,
+      'projectName': 'proj'};
+    assert.deepEqual(issueToIssueRef(issue), expectedRef);
+  });
+});
+
+describe('issueRefToUrl', () => {
+  it('no ref', () => {
+    assert.equal(issueRefToUrl(), '');
+  });
+
+  it('issue ref', () => {
+    assert.equal(issueRefToUrl({
+      projectName: 'test',
+      localId: 11,
+    }), '/p/test/issues/detail?id=11');
+  });
+
+  it('issue ref with params', () => {
+    assert.equal(issueRefToUrl({
+      projectName: 'test',
+      localId: 11,
+    }, {
+      q: 'owner:me',
+      id: 44,
+    }), '/p/test/issues/detail?id=11&q=owner%3Ame');
+  });
+
+  it('federated issue ref', () => {
+    assert.equal(issueRefToUrl({
+      extIdentifier: 'b/5678',
+    }), 'https://issuetracker.google.com/issues/5678');
+  });
+
+  it('does not mutate input queryParams', () => {
+    const queryParams = {q: 'owner:me', id: 44};
+    const EXPECTED = JSON.stringify(queryParams);
+    const ref = {projectName: 'test', localId: 11};
+    issueRefToUrl(ref, queryParams);
+    assert.equal(EXPECTED, JSON.stringify(queryParams));
+  });
+});
+
+it('issueNameToRef', () => {
+  const actual = issueNameToRef('projects/project-name/issues/2');
+  assert.deepEqual(actual, {projectName: 'project-name', localId: 2});
+});
+
+it('issueNameToRefString', () => {
+  const actual = issueNameToRefString('projects/project-name/issues/2');
+  assert.equal(actual, 'project-name:2');
+});
+
+it('issueToName', () => {
+  const actual = issueToName({projectName: 'project-name', localId: 2});
+  assert.equal(actual, 'projects/project-name/issues/2');
+});
+
+describe('commentListToDescriptionList', () => {
+  it('empty list', () => {
+    assert.deepEqual(commentListToDescriptionList(), []);
+    assert.deepEqual(commentListToDescriptionList([]), []);
+  });
+
+  it('first comment is description', () => {
+    assert.deepEqual(commentListToDescriptionList([
+      {content: 'test'},
+      {content: 'hello'},
+      {content: 'world'},
+    ]), [{content: 'test'}]);
+  });
+
+  it('some descriptions', () => {
+    assert.deepEqual(commentListToDescriptionList([
+      {content: 'test'},
+      {content: 'hello', descriptionNum: 1},
+      {content: 'world'},
+      {content: 'this'},
+      {content: 'is a'},
+      {content: 'description', descriptionNum: 2},
+    ]), [
+      {content: 'test'},
+      {content: 'hello', descriptionNum: 1},
+      {content: 'description', descriptionNum: 2},
+    ]);
+  });
+});
+
+describe('valueToFieldValue', () => {
+  it('converts field ref and value', () => {
+    assert.deepEqual(valueToFieldValue(
+        {fieldName: 'name', fieldId: 'id'},
+        'value',
+    ), {
+      fieldRef: {fieldName: 'name', fieldId: 'id'},
+      value: 'value',
+    });
+  });
+});
diff --git a/static_src/shared/cron.js b/static_src/shared/cron.js
new file mode 100644
index 0000000..bd67507
--- /dev/null
+++ b/static_src/shared/cron.js
@@ -0,0 +1,35 @@
+// 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 {store} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+// How long should we wait until asking the server status again.
+const SERVER_STATUS_DELAY_MS = 20 * 60 * 1000; // 20 minutes
+
+// CronTask is a class that supports periodically execution of tasks.
+export class CronTask {
+  constructor(task, delay) {
+    this.task = task;
+    this.delay = delay;
+    this.started = false;
+  }
+
+  start() {
+    if (this.started) return;
+    this.started = true;
+    this._execute();
+  }
+
+  _execute() {
+    this.task();
+    setTimeout(this._execute.bind(this), this.delay);
+  }
+}
+
+// getServerStatusCron requests status information from the server every 20
+// minutes.
+export const getServerStatusCron = new CronTask(
+    () => store.dispatch(sitewide.getServerStatus()),
+    SERVER_STATUS_DELAY_MS);
diff --git a/static_src/shared/cron.test.js b/static_src/shared/cron.test.js
new file mode 100644
index 0000000..e2f9a8e
--- /dev/null
+++ b/static_src/shared/cron.test.js
@@ -0,0 +1,36 @@
+// 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 {CronTask} from './cron.js';
+
+let clock;
+
+describe('cron', () => {
+  beforeEach(() => {
+    clock = sinon.useFakeTimers();
+  });
+
+  afterEach(() => {
+    clock.restore();
+  });
+
+  it('calls task periodically', () => {
+    const task = sinon.spy();
+    const cronTask = new CronTask(task, 1000);
+
+    // Make sure task is not called until the cron task has been started.
+    assert.isFalse(task.called);
+
+    cronTask.start();
+    assert.isTrue(task.calledOnce);
+
+    clock.tick(1000);
+    assert.isTrue(task.calledTwice);
+
+    clock.tick(1000);
+    assert.isTrue(task.calledThrice);
+  });
+});
diff --git a/static_src/shared/dom-helpers.js b/static_src/shared/dom-helpers.js
new file mode 100644
index 0000000..81dec80
--- /dev/null
+++ b/static_src/shared/dom-helpers.js
@@ -0,0 +1,60 @@
+// 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.
+
+
+// Prevent triggering input change handlers on key events that don't
+// edit forms.
+export const NON_EDITING_KEY_EVENTS = new Set(['Enter', 'Tab', 'Escape',
+  'ArrowUp', 'ArrowLeft', 'ArrowRight', 'ArrowDown']);
+const INPUT_TYPES_WITHOUT_TEXT_INPUT = [
+  'checkbox',
+  'radio',
+  'file',
+  'submit',
+  'button',
+  'image',
+];
+
+// TODO: Add a method to watch for property changes in one of a subset of
+// element properties.
+// Via: https://crrev.com/c/infra/infra/+/1762911/7/appengine/monorail/static_src/elements/help/mr-cue/mr-cue.js
+
+/**
+ * Checks if a keyboard event should be disabled when the user is typing.
+ *
+ * @param {HTMLElement} element is a dom node to run checks against.
+ * @return {boolean} Whether the dom node is an element that accepts key input.
+ */
+export function isTextInput(element) {
+  const tagName = element.tagName && element.tagName.toUpperCase();
+  if (tagName === 'INPUT') {
+    const type = element.type.toLowerCase();
+    if (INPUT_TYPES_WITHOUT_TEXT_INPUT.includes(type)) {
+      return false;
+    }
+    return true;
+  }
+  return tagName === 'SELECT' || tagName === 'TEXTAREA' ||
+    element.isContentEditable;
+}
+
+/**
+ * Helper to find the EventTarget that an Event originated from, even if that
+ * EventTarget is buried until multiple layers of ShadowDOM.
+ *
+ * @param {Event} event
+ * @return {EventTarget} The DOM node that the event came from. For example,
+ *   if the input was a keypress, this might be the input element the user was
+ *   typing into.
+ */
+export function findDeepEventTarget(event) {
+  /**
+   * Event.target finds the element the event came from, but only
+   * finds events that come from the highest ShadowDOM level. For
+   * example, an Event listener attached to "window" will have all
+   * Events originating from the SPA set to a target of <mr-app>.
+   */
+  const path = event.composedPath();
+  return path ? path[0] : event.target;
+}
diff --git a/static_src/shared/dom-helpers.test.js b/static_src/shared/dom-helpers.test.js
new file mode 100644
index 0000000..78d535a
--- /dev/null
+++ b/static_src/shared/dom-helpers.test.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 {assert} from 'chai';
+import {isTextInput, findDeepEventTarget} from './dom-helpers.js';
+
+describe('isTextInput', () => {
+  it('returns true for select', () => {
+    const element = document.createElement('select');
+    assert.isTrue(isTextInput(element));
+  });
+
+  it('returns true for input tags that take text input', () => {
+    const element = document.createElement('input');
+    assert.isTrue(isTextInput(element));
+
+    element.type = 'text';
+    assert.isTrue(isTextInput(element));
+
+    element.type = 'password';
+    assert.isTrue(isTextInput(element));
+
+    element.type = 'number';
+    assert.isTrue(isTextInput(element));
+
+    element.type = 'date';
+    assert.isTrue(isTextInput(element));
+  });
+
+  it('returns false for input tags without text input', () => {
+    const element = document.createElement('input');
+
+    element.type = 'button';
+    assert.isFalse(isTextInput(element));
+
+    element.type = 'submit';
+    assert.isFalse(isTextInput(element));
+
+    element.type = 'checkbox';
+    assert.isFalse(isTextInput(element));
+
+    element.type = 'radio';
+    assert.isFalse(isTextInput(element));
+  });
+
+  it('returns true for textarea', () => {
+    const element = document.createElement('textarea');
+    assert.isTrue(isTextInput(element));
+  });
+
+  it('returns true for contenteditable', () => {
+    const element = document.createElement('div');
+    element.contentEditable = 'true';
+    assert.isTrue(isTextInput(element));
+
+    element.contentEditable = 'false';
+    assert.isFalse(isTextInput(element));
+  });
+
+  it('returns false for non-input', () => {
+    assert.isFalse(isTextInput(document.createElement('div')));
+    assert.isFalse(isTextInput(document.createElement('table')));
+    assert.isFalse(isTextInput(document.createElement('tr')));
+    assert.isFalse(isTextInput(document.createElement('td')));
+    assert.isFalse(isTextInput(document.createElement('href')));
+    assert.isFalse(isTextInput(document.createElement('random-elment')));
+    assert.isFalse(isTextInput(document.createElement('p')));
+  });
+});
+
+describe('findDeepEventTarget', () => {
+  it('returns empty for event without target', () => {
+    const event = new Event('whatsup');
+    assert.isUndefined(findDeepEventTarget(event));
+  });
+
+  it('returns target for event with target', (done) => {
+    const element = document.createElement('div');
+    element.addEventListener('hello', (e) => {
+      assert.deepEqual(findDeepEventTarget(e), element);
+      done();
+    });
+    element.dispatchEvent(new Event('hello'));
+  });
+
+  it('returns target for event coming from shadowRoot', (done) => {
+    const target = document.createElement('button');
+    const parent = document.createElement('div');
+    parent.appendChild(target);
+    parent.attachShadow({mode: 'open'});
+
+    parent.addEventListener('shadow-root', (e) => {
+      assert.deepEqual(findDeepEventTarget(e), target);
+      done();
+    });
+
+    target.dispatchEvent(new Event('shadow-root', {bubbles: true}));
+  });
+});
diff --git a/static_src/shared/errors.js b/static_src/shared/errors.js
new file mode 100644
index 0000000..81c0035
--- /dev/null
+++ b/static_src/shared/errors.js
@@ -0,0 +1,9 @@
+// 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.
+
+export class UserInputError extends Error {
+  get name() {
+    return 'UserInputError';
+  }
+}
diff --git a/static_src/shared/experiments.js b/static_src/shared/experiments.js
new file mode 100644
index 0000000..1528a00
--- /dev/null
+++ b/static_src/shared/experiments.js
@@ -0,0 +1,91 @@
+// Copyright 2020 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.
+
+/**
+ * @fileoverview Manages the current user's participation in experiments (e.g.
+ * phased rollouts).
+ *
+ * This file is an early prototype serving the needs of go/monorail-slo-v0.
+ *
+ * The more mature design is under discussion:
+ * http://doc/1rtYXq68WSlTNCzVJiSttLWF14CiK5sOlEef2JWAgheg
+ */
+
+/**
+ * An Enum representing known expreriments.
+ *
+ * @typedef {string} Experiment
+ */
+
+/**
+ * @type {Experiment}
+ */
+export const SLO_EXPERIMENT = 'slo';
+
+const EXPERIMENT_QUERY_PARAM = 'e';
+
+const DISABLED_STR = '-';
+
+const _SLO_EXPERIMENT_USER_DISPLAY_NAMES = new Set([
+  'jessan@google.com',
+]);
+
+/**
+ * Checks whether the current user is in given experiment.
+ *
+ * @param {Experiment} experiment The experiment to check.
+ * @param {UserV0=} user The current user. Although any user can currently
+ *     be passed in, we only intend to support checking if the current user is
+ *     in the experiment. In the future the user parameter may be removed.
+ * @param {Object} queryParams The current query parameters, parsed by qs.
+ *     We support a string like 'e=-exp1,-exp2...' for disabling experiments.
+ *
+ *     We allow disabling so that a user in the fishfood group can work around
+ *     any bugs or undesired behaviors the experiment may introduce for them.
+ *
+ *     As of now, we don't allow enabling experiments by override params.
+ *     We may not want access shared beyond the fishfood group (e.g. if it is a
+ *     feature we are likely to change dramatically or take away).
+ * @return {boolean} Whether the experiment is enabled for the current user.
+ */
+export const isExperimentEnabled = (experiment, user, queryParams) => {
+  const experimentOverrides = parseExperimentParam(
+      queryParams[EXPERIMENT_QUERY_PARAM]);
+  if (experimentOverrides[experiment] === false) {
+    return false;
+  }
+  switch (experiment) {
+    case SLO_EXPERIMENT:
+      return !!user &&
+        _SLO_EXPERIMENT_USER_DISPLAY_NAMES.has(user.displayName);
+    default:
+      throw Error('Unknown experiment provided');
+  }
+};
+
+/**
+ * Parses a comma separated list of experiments from the query string.
+ * Experiment strings preceded by DISABLED_STR are overrode to be disabled,
+ * otherwise they are to be enabled.
+ *
+ * Does not do any validation of the experiment string provided.
+ *
+ * @param {string?} experimentParam comma separated experiements.
+ * @return {Object} Maps experiment name to whether enabled or
+ *    disabled boolean. May include invalid experiment names.
+ */
+const parseExperimentParam = (experimentParam) => {
+  const experimentOverrides = {};
+  if (experimentParam) {
+    for (const experimentOverride of experimentParam.split(',')) {
+      if (experimentOverride.startsWith(DISABLED_STR)) {
+        const experiment = experimentOverride.substr(DISABLED_STR.length);
+        experimentOverrides[experiment] = false;
+      } else {
+        experimentOverrides[experimentOverride] = true;
+      }
+    }
+  }
+  return experimentOverrides;
+};
diff --git a/static_src/shared/experiments.test.js b/static_src/shared/experiments.test.js
new file mode 100644
index 0000000..d5f96b7
--- /dev/null
+++ b/static_src/shared/experiments.test.js
@@ -0,0 +1,62 @@
+// Copyright 2020 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 {isExperimentEnabled, SLO_EXPERIMENT} from './experiments.js';
+
+
+describe('isExperimentEnabled', () => {
+  it('throws error for unknown experiment', () => {
+    assert.throws(() =>
+      isExperimentEnabled('unknown-exp', {displayName: 'jessan@google.com'}));
+  });
+
+  it('returns false if user not in experiment', () => {
+    const ineligibleUser = {displayName: 'example@example.com'};
+    assert.isFalse(isExperimentEnabled(SLO_EXPERIMENT, ineligibleUser, {}));
+  });
+
+  it('returns false if no user provided', () => {
+    assert.isFalse(isExperimentEnabled(SLO_EXPERIMENT, undefined, {}));
+  });
+
+  it('returns true if user in experiment', () => {
+    const eligibleUser = {displayName: 'jessan@google.com'};
+    assert.isTrue(isExperimentEnabled(SLO_EXPERIMENT, eligibleUser, {}));
+  });
+
+  it('is false if user in experiment has disabled it with URL', () => {
+    const eligibleUser = {displayName: 'jessan@google.com'};
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, eligibleUser, {'e': '-slo'}));
+  });
+
+  it('ignores enabling experiments with URL', () => {
+    const ineligibleUser = {displayName: 'example@example.com'};
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, ineligibleUser, {'e': 'slo'}));
+  });
+
+  it('ignores ineligible users disabling experiment with URL', () => {
+    const ineligibleUser = {displayName: 'example@example.com'};
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, ineligibleUser, {'e': '-slo'}));
+  });
+
+  it('ignores invalid experiments in URL', () => {
+    const eligibleUser = {displayName: 'jessan@google.com'};
+    // Leading comma, unknown experiment str, empty experiment str in
+    // middle, disable_str with no experiment, trailing comma
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, eligibleUser, {'e': ',unknown,-slo,,-,'}));
+  });
+
+  it('respects last instance when experiment repeated in URL', () => {
+    const eligibleUser = {displayName: 'jessan@google.com'};
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, eligibleUser, {'e': 'slo,-slo'}));
+    assert.isTrue(isExperimentEnabled(
+        SLO_EXPERIMENT, eligibleUser, {'e': '-slo,slo'}));
+  });
+});
diff --git a/static_src/shared/federated.js b/static_src/shared/federated.js
new file mode 100644
index 0000000..e5b7567
--- /dev/null
+++ b/static_src/shared/federated.js
@@ -0,0 +1,194 @@
+// 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.
+
+/**
+ * Logic for dealing with federated issue references.
+ */
+
+import loadGapi, {fetchGapiEmail} from './gapi-loader.js';
+
+const GOOGLE_ISSUE_TRACKER_REGEX = /^b\/\d+$/;
+
+const GOOGLE_ISSUE_TRACKER_API_ROOT = 'https://issuetracker.corp.googleapis.com';
+const GOOGLE_ISSUE_TRACKER_DISCOVERY_PATH = '/$discovery/rest';
+const GOOGLE_ISSUE_TRACKER_API_VERSION = 'v3';
+
+// Returns if shortlink is valid for any federated tracker.
+export function isShortlinkValid(shortlink) {
+  return FEDERATED_TRACKERS.some((TrackerClass) => {
+    try {
+      return new TrackerClass(shortlink);
+    } catch (e) {
+      if (e instanceof FederatedIssueError) {
+        return false;
+      } else {
+        throw e;
+      }
+    }
+  });
+}
+
+// Returns a issue instance for the first matching tracker.
+export function fromShortlink(shortlink) {
+  for (const key in FEDERATED_TRACKERS) {
+    if (FEDERATED_TRACKERS.hasOwnProperty(key)) {
+      const TrackerClass = FEDERATED_TRACKERS[key];
+      try {
+        return new TrackerClass(shortlink);
+      } catch (e) {
+        if (e instanceof FederatedIssueError) {
+          continue;
+        } else {
+          throw e;
+        }
+      }
+    }
+  }
+  return null;
+}
+
+// FederatedIssue is an abstract class for representing one federated issue.
+// Each supported tracker should subclass this class.
+class FederatedIssue {
+  constructor(shortlink) {
+    if (!this.isShortlinkValid(shortlink)) {
+      throw new FederatedIssueError(`Invalid tracker shortlink: ${shortlink}`);
+    }
+    this.shortlink = shortlink;
+  }
+
+  // isShortlinkValid returns whether a given shortlink is valid.
+  isShortlinkValid(shortlink) {
+    if (!(typeof shortlink === 'string')) {
+      throw new FederatedIssueError('shortlink argument must be a string.');
+    }
+    return Boolean(shortlink.match(this.shortlinkRe()));
+  }
+
+  // shortlinkRe returns the regex used to validate shortlinks.
+  shortlinkRe() {
+    throw new Error('Not implemented.');
+  }
+
+  // toURL returns the URL to this issue.
+  toURL() {
+    throw new Error('Not implemented.');
+  }
+
+  // toIssueRef converts the FedRef's information into an object having the
+  // IssueRef format everywhere on the front-end expects.
+  toIssueRef() {
+    return {
+      extIdentifier: this.shortlink,
+    };
+  }
+
+  // trackerName should return the name of the bug tracker.
+  get trackerName() {
+    throw new Error('Not implemented.');
+  }
+
+  // isOpen returns a Promise that resolves either true or false.
+  async isOpen() {
+    throw new Error('Not implemented.');
+  }
+}
+
+// Class for Google Issue Tracker (Buganizer) logic.
+export class GoogleIssueTrackerIssue extends FederatedIssue {
+  constructor(shortlink) {
+    super(shortlink);
+    this.issueID = Number(shortlink.substr(2));
+    this._federatedDetails = null;
+  }
+
+  shortlinkRe() {
+    return GOOGLE_ISSUE_TRACKER_REGEX;
+  }
+
+  toURL() {
+    return `https://issuetracker.google.com/issues/${this.issueID}`;
+  }
+
+  get trackerName() {
+    return 'Buganizer';
+  }
+
+  async getFederatedDetails() {
+    // Prevent fetching details more than once.
+    if (this._federatedDetails) {
+      return this._federatedDetails;
+    }
+
+    await loadGapi();
+    const email = await fetchGapiEmail();
+    if (!email) {
+      // Fail open.
+      return true;
+    }
+    const res = await this._loadGoogleIssueTrackerIssue(this.issueID);
+    if (!res || !res.result) {
+      // Fail open.
+      return null;
+    }
+
+    this._federatedDetails = res.result;
+    return this._federatedDetails;
+  }
+
+  // isOpen assumes getFederatedDetails has already been called, otherwise
+  // it will fail open (returning that the issue is open).
+  get isOpen() {
+    if (!this._federatedDetails) {
+      // Fail open.
+      return true;
+    }
+
+    // Open issues will not have a `resolvedTime`.
+    return !Boolean(this._federatedDetails.resolvedTime);
+  }
+
+  // summary assumes getFederatedDetails has already been called.
+  get summary() {
+    if (this._federatedDetails &&
+        this._federatedDetails.issueState &&
+        this._federatedDetails.issueState.title) {
+      return this._federatedDetails.issueState.title;
+    }
+    return null;
+  }
+
+  toIssueRef() {
+    return {
+      extIdentifier: this.shortlink,
+      summary: this.summary,
+      statusRef: {meansOpen: this.isOpen},
+    };
+  }
+
+  get _APIURL() {
+    return GOOGLE_ISSUE_TRACKER_API_ROOT + GOOGLE_ISSUE_TRACKER_DISCOVERY_PATH;
+  }
+
+  _loadGoogleIssueTrackerIssue(bugID) {
+    return new Promise((resolve, reject) => {
+      const version = GOOGLE_ISSUE_TRACKER_API_VERSION;
+      gapi.client.load(this._APIURL, version, () => {
+        const request = gapi.client.corp_issuetracker.issues.get({
+          'issueId': bugID,
+        });
+        request.execute((response) => {
+          resolve(response);
+        });
+      });
+    });
+  }
+}
+
+class FederatedIssueError extends Error {}
+
+// A list of supported tracker classes.
+const FEDERATED_TRACKERS = [
+  GoogleIssueTrackerIssue,
+];
diff --git a/static_src/shared/federated.test.js b/static_src/shared/federated.test.js
new file mode 100644
index 0000000..011b924
--- /dev/null
+++ b/static_src/shared/federated.test.js
@@ -0,0 +1,136 @@
+// 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 {
+  isShortlinkValid,
+  fromShortlink,
+  GoogleIssueTrackerIssue,
+} from './federated.js';
+import {getSigninInstance} from 'shared/gapi-loader.js';
+
+describe('isShortlinkValid', () => {
+  it('Returns true for valid links', () => {
+    assert.isTrue(isShortlinkValid('b/1'));
+    assert.isTrue(isShortlinkValid('b/12345678'));
+  });
+
+  it('Returns false for invalid links', () => {
+    assert.isFalse(isShortlinkValid('b'));
+    assert.isFalse(isShortlinkValid('b/'));
+    assert.isFalse(isShortlinkValid('b//123456'));
+    assert.isFalse(isShortlinkValid('b/123/123'));
+    assert.isFalse(isShortlinkValid('b123/123'));
+    assert.isFalse(isShortlinkValid('b/123a456'));
+  });
+});
+
+describe('fromShortlink', () => {
+  it('Returns an issue class for valid links', () => {
+    assert.instanceOf(fromShortlink('b/1'), GoogleIssueTrackerIssue);
+    assert.instanceOf(fromShortlink('b/12345678'), GoogleIssueTrackerIssue);
+  });
+
+  it('Returns null for invalid links', () => {
+    assert.isNull(fromShortlink('b'));
+    assert.isNull(fromShortlink('b/'));
+    assert.isNull(fromShortlink('b//123456'));
+    assert.isNull(fromShortlink('b/123/123'));
+    assert.isNull(fromShortlink('b123/123'));
+    assert.isNull(fromShortlink('b/123a456'));
+  });
+});
+
+describe('GoogleIssueTrackerIssue', () => {
+  describe('constructor', () => {
+    it('Sets this.shortlink and this.issueID', () => {
+      const shortlink = 'b/1234';
+      const issue = new GoogleIssueTrackerIssue(shortlink);
+      assert.equal(issue.shortlink, shortlink);
+      assert.equal(issue.issueID, 1234);
+    });
+
+    it('Throws when given an invalid shortlink.', () => {
+      assert.throws(() => {
+        new GoogleIssueTrackerIssue('b/123/123');
+      });
+    });
+  });
+
+  describe('toURL', () => {
+    it('Returns a valid URL.', () => {
+      const issue = new GoogleIssueTrackerIssue('b/1234');
+      assert.equal(issue.toURL(), 'https://issuetracker.google.com/issues/1234');
+    });
+  });
+
+  describe('federated details', () => {
+    let signinImpl;
+    beforeEach(() => {
+      window.CS_env = {gapi_client_id: 'rutabaga'};
+      signinImpl = {
+        init: sinon.stub(),
+        getUserProfileAsync: () => (
+          Promise.resolve({
+            getEmail: sinon.stub().returns('rutabaga@google.com'),
+          })
+        ),
+      };
+      // Preload signinImpl with a fake for testing.
+      getSigninInstance(signinImpl, true);
+      delete window.__gapiLoadPromise;
+    });
+
+    afterEach(() => {
+      delete window.CS_env;
+    });
+
+    describe('isOpen', () => {
+      it('Fails open', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        assert.isTrue(issue.isOpen);
+      });
+
+      it('Is based on details.resolvedTime', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        issue._federatedDetails = {resolvedTime: 12345};
+        assert.isFalse(issue.isOpen);
+
+        issue._federatedDetails = {};
+        assert.isTrue(issue.isOpen);
+      });
+    });
+
+    describe('summary', () => {
+      it('Returns null if not available', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        assert.isNull(issue.summary);
+      });
+
+      it('Returns the summary if available', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        issue._federatedDetails = {issueState: {title: 'Rutabaga title'}};
+        assert.equal(issue.summary, 'Rutabaga title');
+      });
+    });
+
+    describe('toIssueRef', () => {
+      it('Returns an issue ref object', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        issue._federatedDetails = {
+          resolvedTime: 12345,
+          issueState: {
+            title: 'A fedref issue title',
+          },
+        };
+
+        assert.deepEqual(issue.toIssueRef(), {
+          extIdentifier: 'b/1234',
+          summary: 'A fedref issue title',
+          statusRef: {meansOpen: false},
+        });
+      });
+    });
+  });
+});
diff --git a/static_src/shared/ga-helpers.js b/static_src/shared/ga-helpers.js
new file mode 100644
index 0000000..52d1176
--- /dev/null
+++ b/static_src/shared/ga-helpers.js
@@ -0,0 +1,31 @@
+// 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.
+
+const TITLE = 'title';
+const LOCATION = 'location';
+const DIMENSION1 = 'dimension1';
+const SET = 'set';
+
+/**
+ * Track page-to-page navigation via google analytics. Global window.ga
+ * is set in server rendered HTML.
+ *
+ * @param {string} page
+ * @param {string} userDisplayName
+ */
+export const trackPageChange = (page = '', userDisplayName = '') => {
+  ga(SET, TITLE, `Issue ${page}`);
+  if (page.startsWith('user')) {
+    ga(SET, TITLE, 'A user page');
+    ga(SET, LOCATION, 'A user page URL');
+  }
+
+  if (userDisplayName) {
+    ga(SET, DIMENSION1, 'Logged in');
+  } else {
+    ga(SET, DIMENSION1, 'Not logged in');
+  }
+
+  ga('send', 'pageview');
+};
diff --git a/static_src/shared/ga-helpers.test.js b/static_src/shared/ga-helpers.test.js
new file mode 100644
index 0000000..1876a27
--- /dev/null
+++ b/static_src/shared/ga-helpers.test.js
@@ -0,0 +1,45 @@
+// 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 {trackPageChange} from './ga-helpers.js';
+
+describe('trackPageChange', () => {
+  beforeEach(() => {
+    global.ga = sinon.spy();
+  });
+
+  afterEach(() => {
+    global.ga.resetHistory();
+  });
+
+  it('sets page title', () => {
+    trackPageChange('list');
+    sinon.assert.calledWith(global.ga, 'set', 'title', 'Issue list');
+  });
+
+  it('sets user page title', () => {
+    trackPageChange('user-anything');
+    sinon.assert.calledWith(global.ga, 'set', 'title', 'A user page');
+  });
+
+  it('sets user location', () => {
+    trackPageChange('user-anything');
+    sinon.assert.calledWith(global.ga, 'set', 'location', 'A user page URL');
+  });
+
+  it('defaults dimension1', () => {
+    trackPageChange('list');
+    sinon.assert.calledWith(global.ga, 'set', 'dimension1', 'Not logged in');
+  });
+
+  it('sets dimension1 based on userDisplayName', () => {
+    trackPageChange('list', 'somebody');
+    sinon.assert.calledWith(global.ga, 'set', 'dimension1', 'Logged in');
+  });
+
+  it('sends pageview', () => {
+    trackPageChange('list');
+    sinon.assert.calledWith(global.ga, 'send', 'pageview');
+  });
+});
diff --git a/static_src/shared/gapi-loader.js b/static_src/shared/gapi-loader.js
new file mode 100644
index 0000000..5249d68
--- /dev/null
+++ b/static_src/shared/gapi-loader.js
@@ -0,0 +1,66 @@
+// 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.
+
+/**
+ * gapi-loader.js provides a method for loading gapi.js asynchronously.
+ *
+ * gapi.js docs:
+ * https://developers.google.com/identity/sign-in/web/reference
+ * (we load gapi.js via the chops-signin module)
+ */
+
+import * as signin from '@chopsui/chops-signin';
+
+const BUGANIZER_SCOPE = 'https://www.googleapis.com/auth/buganizer';
+// Only allow google.com profiles through currently.
+const RESTRICT_TO_DOMAIN = '@google.com';
+
+// loadGapi loads window.gapi and returns a logged in user object or null.
+// Allows overriding signinImpl for testing.
+export default function loadGapi() {
+  const signinImpl = getSigninInstance();
+  // Validate client_id exists.
+  if (!CS_env.gapi_client_id) {
+    throw new Error('Cannot find gapi.js client id');
+  }
+
+  // Prevent gapi.js from being loaded multiple times.
+  if (window.__gapiLoadPromise) {
+    return window.__gapiLoadPromise;
+  }
+
+  window.__gapiLoadPromise = new Promise(async (resolve) => {
+    signinImpl.init(CS_env.gapi_client_id, ['client'], [BUGANIZER_SCOPE]);
+    resolve(await fetchGapiEmail(signinImpl));
+  });
+
+  return window.__gapiLoadPromise;
+}
+
+// For fetching current email. May have changed since load.
+export function fetchGapiEmail() {
+  const signinImpl = getSigninInstance();
+  return new Promise((resolve) => {
+    signinImpl.getUserProfileAsync().then((profile) => {
+      resolve(
+          (
+            profile &&
+            profile.getEmail instanceof Function &&
+            profile.getEmail().endsWith(RESTRICT_TO_DOMAIN) &&
+            profile.getEmail()
+          ) || null,
+      );
+    });
+  });
+}
+
+// Provide a singleton chops-signin instance to make testing easier.
+let signinInstance;
+export function getSigninInstance(signinImpl=signin, overwrite=false) {
+  // Assign on first run.
+  if (overwrite || !signinInstance) {
+    signinInstance = signinImpl;
+  }
+  return signinInstance;
+}
diff --git a/static_src/shared/gapi-loader.test.js b/static_src/shared/gapi-loader.test.js
new file mode 100644
index 0000000..fb98fed
--- /dev/null
+++ b/static_src/shared/gapi-loader.test.js
@@ -0,0 +1,73 @@
+// 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 loadGapi, {fetchGapiEmail, getSigninInstance} from './gapi-loader.js';
+
+describe('gapi-loader', () => {
+  let signinImpl;
+  beforeEach(() => {
+    window.CS_env = {gapi_client_id: 'rutabaga'};
+    signinImpl = {
+      init: sinon.stub(),
+      getUserProfileAsync: () => (
+        Promise.resolve({
+          getEmail: sinon.stub().returns('rutabaga@google.com'),
+        })
+      ),
+    };
+    // Preload signinImpl with a fake for testing.
+    getSigninInstance(signinImpl, true);
+    delete window.__gapiLoadPromise;
+  });
+
+  afterEach(() => {
+    delete window.CS_env;
+  });
+
+  describe('loadGapi()', () => {
+    it('errors out if no client_id', () => {
+      window.CS_env.gapi_client_id = undefined;
+      assert.throws(() => loadGapi());
+    });
+
+    it('returns the same promise when called multiple times', () => {
+      const callOne = loadGapi();
+      const callTwo = loadGapi();
+
+      assert.strictEqual(callOne, callTwo);
+      assert.strictEqual(callOne, window.__gapiLoadPromise);
+      assert.strictEqual(callTwo, window.__gapiLoadPromise);
+      assert.instanceOf(callOne, Promise);
+    });
+
+    it('calls init and returns the current email if any', async () => {
+      const response = await loadGapi();
+      sinon.assert.calledWith(signinImpl.init, window.CS_env.gapi_client_id,
+          ['client'], ['https://www.googleapis.com/auth/buganizer']);
+      assert.equal(response, 'rutabaga@google.com');
+    });
+  });
+
+  describe('fetchGapiEmail()', () => {
+    it('returns a profile for allowed domains', async () => {
+      getSigninInstance({
+        getUserProfileAsync: () => Promise.resolve({
+          getEmail: sinon.stub().returns('rutabaga@google.com'),
+        }),
+      }, true);
+      assert.deepEqual(await fetchGapiEmail(), 'rutabaga@google.com');
+    });
+
+    it('returns nothing for non-allowed domains', async () => {
+      getSigninInstance({
+        getUserProfileAsync: () => Promise.resolve({
+          getEmail: sinon.stub().returns('rutabaga@rutabaga.com'),
+        }),
+      }, true);
+      assert.deepEqual(await fetchGapiEmail(), null);
+    });
+  });
+});
diff --git a/static_src/shared/helpers.js b/static_src/shared/helpers.js
new file mode 100644
index 0000000..362b4ec
--- /dev/null
+++ b/static_src/shared/helpers.js
@@ -0,0 +1,213 @@
+// 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 qs from 'qs';
+
+
+/**
+ * With lists a and b, get the elements that are in a but not in b.
+ * result = a - b
+ * @param {Array} listA
+ * @param {Array} listB
+ * @param {function?} equals
+ * @return {Array}
+ */
+export function arrayDifference(listA, listB, equals = undefined) {
+  if (!equals) {
+    equals = (a, b) => (a === b);
+  }
+  listA = listA || [];
+  listB = listB || [];
+  return listA.filter((a) => {
+    return !listB.find((b) => (equals(a, b)));
+  });
+}
+
+/**
+ * Check to see if a Set contains any of a list of values.
+ *
+ * @param {Set} set the Set to check for values in.
+ * @param {Iterable} values checks if any of these values are included.
+ * @return {boolean} whether the Set has any of the values or not.
+ */
+export function setHasAny(set, values) {
+  for (const value of values) {
+    if (set.has(value)) {
+      return true;
+    }
+  }
+  return false;
+}
+
+/**
+ * Capitalize the first letter of a given string.
+ * @param {string} str
+ * @return {string}
+ */
+export function capitalizeFirst(str) {
+  return `${str.charAt(0).toUpperCase()}${str.substring(1)}`;
+}
+
+/**
+ * Check if a string has a prefix, ignoring case.
+ * @param {string} str
+ * @param {string} prefix
+ * @return {boolean}
+ */
+export function hasPrefix(str, prefix) {
+  return str.toLowerCase().startsWith(prefix.toLowerCase());
+}
+
+/**
+ * Returns a string without specified prefix
+ * @param {string} str
+ * @param {string} prefix
+ * @return {string}
+ */
+export function removePrefix(str, prefix) {
+  return str.substr(prefix.length);
+}
+
+// TODO(zhangtiff): Make this more grammatically correct for
+// more than two items.
+export function arrayToEnglish(arr) {
+  if (!arr) return '';
+  return arr.join(' and ');
+}
+
+export function pluralize(count, singular, pluralArg) {
+  const plural = pluralArg || singular + 's';
+  return count === 1 ? singular : plural;
+}
+
+export function objectToMap(obj = {}) {
+  const map = new Map();
+  Object.keys(obj).forEach((key) => {
+    map.set(key, obj[key]);
+  });
+  return map;
+}
+
+/**
+ * Given an Object, extract a list of values from it, based on some
+ * specified keys.
+ *
+ * @param {Object} obj the Object to read values from.
+ * @param {Array} keys the Object keys to fetch values for.
+ * @return {Array} Object values matching the given keys.
+ */
+export function objectValuesForKeys(obj, keys = []) {
+  return keys.map((key) => ((key in obj) ? obj[key] : undefined));
+}
+
+/**
+ * Checks to see if object has no keys
+ * @param {Object} obj
+ * @return {boolean}
+ */
+export function isEmptyObject(obj) {
+  return Object.keys(obj).length === 0;
+}
+
+/**
+ * Checks if two strings are equal, case-insensitive
+ * @param {string} a
+ * @param {string} b
+ * @return {boolean}
+ */
+export function equalsIgnoreCase(a, b) {
+  if (a == b) return true;
+  if (!a || !b) return false;
+  return a.toLowerCase() === b.toLowerCase();
+}
+
+export function immutableSplice(arr, index, count, ...addedItems) {
+  if (!arr) return '';
+
+  return [...arr.slice(0, index), ...addedItems, ...arr.slice(index + count)];
+}
+
+/**
+ * Computes a new URL for a page based on an exiting path and set of query
+ * params.
+ *
+ * @param {string} baseUrl the base URL without query params.
+ * @param {Object} oldParams original query params before changes.
+ * @param {Object} newParams query parameters to override existing ones.
+ * @param {Array} deletedParams list of keys to be cleared.
+ * @return {string} the new URL with the updated params.
+ */
+export function urlWithNewParams(baseUrl = '',
+    oldParams = {}, newParams = {}, deletedParams = []) {
+  const params = {...oldParams, ...newParams};
+  deletedParams.forEach((name) => {
+    delete params[name];
+  });
+
+  const queryString = qs.stringify(params);
+
+  return `${baseUrl}${queryString ? '?' : ''}${queryString}`;
+}
+
+/**
+ * Finds out whether a user is a member of a given project based on
+ * project membership info.
+ *
+ * @param {Object} userRef reference to a given user. Expects an id.
+ * @param {string} projectName name of the project being searched for.
+ * @param {Map} usersProjects all known user project memberships where
+ *  keys are userId and values are Objects with expected values
+ *  for {ownerOf, memberOf, contributorTo}.
+ * @return {boolean} whether the user is a member of the project or not.
+ */
+export function userIsMember(userRef, projectName, usersProjects = new Map()) {
+  // TODO(crbug.com/monorail/5968): Find a better place to place this function
+  if (!userRef || !userRef.userId || !projectName) return false;
+  const userProjects = usersProjects.get(userRef.userId);
+  if (!userProjects) return false;
+  const {ownerOf = [], memberOf = [], contributorTo = []} = userProjects;
+  return ownerOf.includes(projectName) ||
+    memberOf.includes(projectName) ||
+    contributorTo.includes(projectName);
+}
+
+/**
+ * Creates a function that checks two objects are not equal
+ * based on a set of property keys
+ *
+ * @param {Set<string>} props
+ * @return {function(): boolean}
+ */
+export function createObjectComparisonFunc(props) {
+  /**
+   * Computes whether set of properties have changed
+   * @param {Object<string, string>} newVal
+   * @param {Object<string, string>} oldVal
+   * @return {boolean}
+   */
+  return function(newVal, oldVal) {
+    if (oldVal === undefined && newVal === undefined) {
+      return false;
+    } else if (oldVal === undefined || newVal === undefined) {
+      return true;
+    } else if (oldVal === null && newVal === null) {
+      return false;
+    } else if (oldVal === null || newVal === null) {
+      return true;
+    }
+
+    return Array.from(props)
+        .some((propName) => newVal[propName] !== oldVal[propName]);
+  };
+}
+
+/**
+ * Calculates whether to wait for memberDefaultQuery to exist prior
+ * to fetching IssueList. Logged in users may use a default query.
+ * @param {Object} queryParams
+ * @return {boolean}
+ */
+export const shouldWaitForDefaultQuery = (queryParams) => {
+  return !queryParams.hasOwnProperty('q');
+};
diff --git a/static_src/shared/helpers.test.js b/static_src/shared/helpers.test.js
new file mode 100644
index 0000000..7c40ed5
--- /dev/null
+++ b/static_src/shared/helpers.test.js
@@ -0,0 +1,361 @@
+// 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 {arrayDifference, setHasAny, capitalizeFirst, hasPrefix, objectToMap,
+  objectValuesForKeys, equalsIgnoreCase, immutableSplice, userIsMember,
+  urlWithNewParams, createObjectComparisonFunc} from './helpers.js';
+
+
+describe('arrayDifference', () => {
+  it('empty array stays empty', () => {
+    assert.deepEqual(arrayDifference([], []), []);
+    assert.deepEqual(arrayDifference([], undefined), []);
+    assert.deepEqual(arrayDifference([], ['a']), []);
+  });
+
+  it('subtracting empty array does nothing', () => {
+    assert.deepEqual(arrayDifference(['a'], []), ['a']);
+    assert.deepEqual(arrayDifference([1, 2, 3], []), [1, 2, 3]);
+    assert.deepEqual(arrayDifference([1, 2, 'test'], []), [1, 2, 'test']);
+    assert.deepEqual(arrayDifference([1, 2, 'test'], undefined),
+        [1, 2, 'test']);
+  });
+
+  it('subtracts elements from array', () => {
+    assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['b', 'c']), ['a']);
+    assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['a']), ['b', 'c']);
+    assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['b']), ['a', 'c']);
+    assert.deepEqual(arrayDifference([1, 2, 3], [2]), [1, 3]);
+  });
+
+  it('does not subtract missing elements from array', () => {
+    assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['d']), ['a', 'b', 'c']);
+    assert.deepEqual(arrayDifference([1, 2, 3], [5]), [1, 2, 3]);
+    assert.deepEqual(arrayDifference([1, 2, 3], [-1, 2]), [1, 3]);
+  });
+
+  it('custom equals function', () => {
+    assert.deepEqual(arrayDifference(['a', 'b'], ['A']), ['a', 'b']);
+    assert.deepEqual(arrayDifference(['a', 'b'], ['A'], equalsIgnoreCase),
+        ['b']);
+  });
+});
+
+describe('setHasAny', () => {
+  it('empty set never has any values', () => {
+    assert.isFalse(setHasAny(new Set(), []));
+    assert.isFalse(setHasAny(new Set(), ['test']));
+    assert.isFalse(setHasAny(new Set(), ['nope', 'yup', 'no']));
+  });
+
+  it('false when no values found', () => {
+    assert.isFalse(setHasAny(new Set(['hello', 'world']), []));
+    assert.isFalse(setHasAny(new Set(['hello', 'world']), ['wor']));
+    assert.isFalse(setHasAny(new Set(['test']), ['other', 'values']));
+    assert.isFalse(setHasAny(new Set([1, 2, 3]), [4, 5, 6]));
+  });
+
+  it('true when values found', () => {
+    assert.isTrue(setHasAny(new Set(['hello', 'world']), ['world']));
+    assert.isTrue(setHasAny(new Set([1, 2, 3]), [3, 4, 5]));
+    assert.isTrue(setHasAny(new Set([1, 2, 3]), [1, 3]));
+  });
+});
+
+describe('capitalizeFirst', () => {
+  it('empty string', () => {
+    assert.equal(capitalizeFirst(''), '');
+  });
+
+  it('ignores non-letters', () => {
+    assert.equal(capitalizeFirst('8fcsdf'), '8fcsdf');
+  });
+
+  it('preserves existing caps', () => {
+    assert.equal(capitalizeFirst('HELLO world'), 'HELLO world');
+  });
+
+  it('capitalizes lowercase', () => {
+    assert.equal(capitalizeFirst('hello world'), 'Hello world');
+  });
+});
+
+describe('hasPrefix', () => {
+  it('only true when has prefix', () => {
+    assert.isFalse(hasPrefix('teststring', 'test-'));
+    assert.isFalse(hasPrefix('stringtest-', 'test-'));
+    assert.isFalse(hasPrefix('^test-$', 'test-'));
+    assert.isTrue(hasPrefix('test-', 'test-'));
+    assert.isTrue(hasPrefix('test-fsdfsdf', 'test-'));
+  });
+
+  it('ignores case when checking prefix', () => {
+    assert.isTrue(hasPrefix('TEST-string', 'test-'));
+    assert.isTrue(hasPrefix('test-string', 'test-'));
+    assert.isTrue(hasPrefix('tEsT-string', 'test-'));
+  });
+});
+
+describe('objectToMap', () => {
+  it('converts Object to Map with the same keys', () => {
+    assert.deepEqual(objectToMap({}), new Map());
+    assert.deepEqual(objectToMap({test: 'value'}),
+        new Map([['test', 'value']]));
+    assert.deepEqual(objectToMap({['weird:key']: 'value',
+      ['what is this key']: 'v2'}), new Map([['weird:key', 'value'],
+      ['what is this key', 'v2']]));
+  });
+});
+
+describe('objectValuesForKeys', () => {
+  it('no values when no matching keys', () => {
+    assert.deepEqual(objectValuesForKeys({}, []), []);
+    assert.deepEqual(objectValuesForKeys({}, []), []);
+    assert.deepEqual(objectValuesForKeys({key: 'value'}, []), []);
+  });
+
+  it('returns values when keys match', () => {
+    assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['a', 'b']),
+        [1, 2]);
+    assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['b', 'c']),
+        [2, 3]);
+    assert.deepEqual(objectValuesForKeys({['weird:key']: {nested: 'obj'}},
+        ['weird:key']), [{nested: 'obj'}]);
+  });
+
+  it('sets non-matching keys to undefined', () => {
+    assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['c', 'd', 'e']),
+        [3, undefined, undefined]);
+    assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, [1, 2, 3]),
+        [undefined, undefined, undefined]);
+  });
+});
+
+describe('equalsIgnoreCase', () => {
+  it('matches same case strings', () => {
+    assert.isTrue(equalsIgnoreCase('', ''));
+    assert.isTrue(equalsIgnoreCase('HelloWorld', 'HelloWorld'));
+    assert.isTrue(equalsIgnoreCase('hmm', 'hmm'));
+    assert.isTrue(equalsIgnoreCase('TEST', 'TEST'));
+  });
+
+  it('matches different case strings', () => {
+    assert.isTrue(equalsIgnoreCase('a', 'A'));
+    assert.isTrue(equalsIgnoreCase('HelloWorld', 'helloworld'));
+    assert.isTrue(equalsIgnoreCase('hmm', 'HMM'));
+    assert.isTrue(equalsIgnoreCase('TEST', 'teSt'));
+  });
+
+  it('does not match different strings', () => {
+    assert.isFalse(equalsIgnoreCase('hello', 'hello '));
+    assert.isFalse(equalsIgnoreCase('superstring', 'string'));
+    assert.isFalse(equalsIgnoreCase('aaa', 'aa'));
+  });
+});
+
+describe('immutableSplice', () => {
+  it('does not edit original array', () => {
+    const arr = ['apples', 'pears', 'oranges'];
+
+    assert.deepEqual(immutableSplice(arr, 1, 1),
+        ['apples', 'oranges']);
+
+    assert.deepEqual(arr, ['apples', 'pears', 'oranges']);
+  });
+
+  it('removes multiple items', () => {
+    const arr = [1, 2, 3, 4, 5, 6];
+
+    assert.deepEqual(immutableSplice(arr, 1, 0), [1, 2, 3, 4, 5, 6]);
+    assert.deepEqual(immutableSplice(arr, 1, 4), [1, 6]);
+    assert.deepEqual(immutableSplice(arr, 0, 6), []);
+  });
+
+  it('adds items', () => {
+    const arr = [1, 2, 3];
+
+    assert.deepEqual(immutableSplice(arr, 1, 1, 4, 5, 6), [1, 4, 5, 6, 3]);
+    assert.deepEqual(immutableSplice(arr, 2, 1, 4, 5, 6), [1, 2, 4, 5, 6]);
+    assert.deepEqual(immutableSplice(arr, 0, 0, -3, -2, -1, 0),
+        [-3, -2, -1, 0, 1, 2, 3]);
+  });
+});
+
+describe('urlWithNewParams', () => {
+  it('empty', () => {
+    assert.equal(urlWithNewParams(), '');
+    assert.equal(urlWithNewParams(''), '');
+    assert.equal(urlWithNewParams('', {}), '');
+    assert.equal(urlWithNewParams('', {}, {}), '');
+    assert.equal(urlWithNewParams('', {}, {}, []), '');
+  });
+
+  it('preserves existing URL without changes', () => {
+    assert.equal(urlWithNewParams('/p/chromium/issues/list'),
+        '/p/chromium/issues/list');
+    assert.equal(urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}),
+        '/p/chromium/issues/list?q=owner%3Ame');
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me', can: '1'}),
+        '/p/chromium/issues/list?q=owner%3Ame&can=1');
+  });
+
+  it('adds new params', () => {
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list', {}, {q: 'owner:me'}),
+        '/p/chromium/issues/list?q=owner%3Ame');
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list',
+            {can: '1'}, {q: 'owner:me'}),
+        '/p/chromium/issues/list?can=1&q=owner%3Ame');
+
+    // Override existing params.
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list',
+            {can: '1', q: 'owner:me'}, {q: 'test'}),
+        '/p/chromium/issues/list?can=1&q=test');
+  });
+
+  it('clears existing params', () => {
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}, {}, ['q']),
+        '/p/chromium/issues/list');
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list',
+            {can: '1'}, {q: 'owner:me'}, ['can']),
+        '/p/chromium/issues/list?q=owner%3Ame');
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}, {can: '2'},
+            ['q', 'can', 'fakeparam']),
+        '/p/chromium/issues/list');
+  });
+});
+
+describe('userIsMember', () => {
+  it('false when no user', () => {
+    assert.isFalse(userIsMember(undefined));
+    assert.isFalse(userIsMember({}));
+    assert.isFalse(userIsMember({}, 'chromium',
+        new Map([['123', {ownerOf: ['chromium']}]])));
+  });
+
+  it('true when user is member of project', () => {
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+        new Map([['123', {contributorTo: ['chromium']}]])));
+
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+        new Map([['123', {ownerOf: ['chromium']}]])));
+
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+        new Map([['123', {memberOf: ['chromium']}]])));
+  });
+
+  it('true when user is member of multiple projects', () => {
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {contributorTo: ['test', 'chromium', 'fakeproject']}],
+    ])));
+
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {ownerOf: ['test', 'chromium', 'fakeproject']}],
+    ])));
+
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {memberOf: ['test', 'chromium', 'fakeproject']}],
+    ])));
+  });
+
+  it('false when user is member of different project', () => {
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {contributorTo: ['test', 'fakeproject']}],
+    ])));
+
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {ownerOf: ['test', 'fakeproject']}],
+    ])));
+
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {memberOf: ['test', 'fakeproject']}],
+    ])));
+  });
+
+  it('false when no project data for user', () => {
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium'));
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map()));
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['543', {ownerOf: ['chromium']}],
+    ])));
+  });
+});
+
+describe('createObjectComparisonFunc', () => {
+  it('returns a function', () => {
+    const result = createObjectComparisonFunc(new Set());
+    assert.instanceOf(result, Function);
+  });
+
+  describe('returned function', () => {
+    it('returns false if both inputs are undefined', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const result = func(undefined, undefined);
+
+      assert.isFalse(result);
+    });
+
+    it('returns true if only one inputs is undefined', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const result = func({}, undefined);
+
+      assert.isTrue(result);
+    });
+
+    it('returns false if both inputs are null', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const result = func(null, null);
+
+      assert.isFalse(result);
+    });
+
+    it('returns true if only one inputs is null', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const result = func({}, null);
+
+      assert.isTrue(result);
+    });
+
+    it('returns true if any comparable property is different', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const a = {a: 1, b: 2, c: 3};
+      const b = {a: 1, b: 2, c: '3'};
+      const result = func(a, b);
+
+      assert.isTrue(result);
+    });
+
+    it('returns false if all comparable properties are the same', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const a = {a: 1, b: 2, c: 3};
+      const b = {a: 1, b: 2, c: 3};
+      const result = func(a, b);
+
+      assert.isFalse(result);
+    });
+
+    it('ignores non-comparable properties', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const a = {a: 1, b: 2, c: 3, d: 4};
+      const b = {a: 1, b: 2, c: 3, d: 'not four', e: 'exists'};
+      const result = func(a, b);
+
+      assert.isFalse(result);
+    });
+  });
+});
diff --git a/static_src/shared/issue-fields.js b/static_src/shared/issue-fields.js
new file mode 100644
index 0000000..09ac7d3
--- /dev/null
+++ b/static_src/shared/issue-fields.js
@@ -0,0 +1,424 @@
+// 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 {relativeTime} from
+  'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {labelRefsToStrings, issueRefsToStrings, componentRefsToStrings,
+  userRefsToDisplayNames, statusRefsToStrings, labelNameToLabelPrefixes,
+} from './convertersV0.js';
+import {removePrefix} from './helpers.js';
+import {STATUS_ENUM_TO_TEXT} from 'shared/consts/approval.js';
+import {fieldValueMapKey} from 'shared/metadata-helpers.js';
+
+// TODO(zhangtiff): Merge this file with metadata-helpers.js.
+
+
+/** @enum {string} */
+export const fieldTypes = Object.freeze({
+  APPROVAL_TYPE: 'APPROVAL_TYPE',
+  DATE_TYPE: 'DATE_TYPE',
+  ENUM_TYPE: 'ENUM_TYPE',
+  INT_TYPE: 'INT_TYPE',
+  STR_TYPE: 'STR_TYPE',
+  USER_TYPE: 'USER_TYPE',
+  URL_TYPE: 'URL_TYPE',
+
+  // Frontend types used to handle built in fields like BlockedOn.
+  // Although these are not configurable custom field types on the
+  // backend, hard-coding these fields types on the frontend allows
+  // us to inter-op custom and baked in fields more seamlessly on
+  // the frontend.
+  ISSUE_TYPE: 'ISSUE_TYPE',
+  TIME_TYPE: 'TIME_TYPE',
+  COMPONENT_TYPE: 'COMPONENT_TYPE',
+  STATUS_TYPE: 'STATUS_TYPE',
+  LABEL_TYPE: 'LABEL_TYPE',
+  PROJECT_TYPE: 'PROJECT_TYPE',
+});
+
+const GROUPABLE_FIELD_TYPES = new Set([
+  fieldTypes.DATE_TYPE,
+  fieldTypes.ENUM_TYPE,
+  fieldTypes.USER_TYPE,
+  fieldTypes.INT_TYPE,
+]);
+
+const SPEC_DELIMITER_REGEX = /[\s\+]+/;
+export const SITEWIDE_DEFAULT_COLUMNS = ['ID', 'Type', 'Status',
+  'Priority', 'Milestone', 'Owner', 'Summary'];
+
+// When no default can is configured, projects use "Open issues".
+export const SITEWIDE_DEFAULT_CAN = '2';
+
+export const PHASE_FIELD_COL_DELIMITER_REGEX = /\./;
+
+export const EMPTY_FIELD_VALUE = '----';
+
+export const APPROVER_COL_SUFFIX_REGEX = /\-approver$/i;
+
+/**
+ * Parses colspec or groupbyspec values from user input such as form fields
+ * or the URL.
+ *
+ * @param {string} spec a delimited string with spec values to parse.
+ * @return {Array} list of spec values represented by the string.
+ */
+export function parseColSpec(spec = '') {
+  return spec.split(SPEC_DELIMITER_REGEX).filter(Boolean);
+}
+
+/**
+ * Finds the type for an issue based on the issue's custom fields
+ * and labels. If there is a custom field named "Type", that field
+ * is used, otherwise labels are used.
+ * @param {!Array<FieldValue>} fieldValues
+ * @param {!Array<LabelRef>} labelRefs
+ * @return {string}
+ */
+export function extractTypeForIssue(fieldValues, labelRefs) {
+  if (fieldValues) {
+    // If there is a custom field for "Type", use that for type.
+    const typeFieldValue = fieldValues.find(
+        (f) => (f.fieldRef && f.fieldRef.fieldName.toLowerCase() === 'type'),
+    );
+    if (typeFieldValue) {
+      return typeFieldValue.value;
+    }
+  }
+
+  // Otherwise, search through labels for a "Type" label.
+  if (labelRefs) {
+    const typeLabel = labelRefs.find(
+        (l) => l.label.toLowerCase().startsWith('type-'));
+    if (typeLabel) {
+    // Strip length of prefix.
+      return typeLabel.label.substr(5);
+    }
+  }
+  return;
+}
+
+// TODO(jojwang): monorail:6397, Refactor these specific map producers into
+// selectors.
+/**
+ * Converts issue.fieldValues into a map where values can be looked up given
+ * a field value key.
+ *
+ * @param {Array} fieldValues List of values with a fieldRef attached.
+ * @return {Map} keys are a string constructed using fieldValueMapKey() and
+ *   values are an Array of value strings.
+ */
+export function fieldValuesToMap(fieldValues) {
+  if (!fieldValues) return new Map();
+  const acc = new Map();
+  for (const v of fieldValues) {
+    if (!v || !v.fieldRef || !v.fieldRef.fieldName || !v.value) continue;
+    const key = fieldValueMapKey(v.fieldRef.fieldName,
+        v.phaseRef && v.phaseRef.phaseName);
+    if (acc.has(key)) {
+      acc.get(key).push(v.value);
+    } else {
+      acc.set(key, [v.value]);
+    }
+  }
+  return acc;
+}
+
+/**
+ * Converts issue.approvalValues into a map where values can be looked up given
+  * a field value key.
+  *
+  * @param {Array} approvalValues list of approvals with a fieldRef attached.
+  * @return {Map} keys are a string constructed using approvalValueFieldMapKey()
+  *   and values are an Array of value strings.
+  */
+export function approvalValuesToMap(approvalValues) {
+  if (!approvalValues) return new Map();
+  const approvalKeysToValues = new Map();
+  for (const av of approvalValues) {
+    if (!av || !av.fieldRef || !av.fieldRef.fieldName) continue;
+    const key = fieldValueMapKey(av.fieldRef.fieldName);
+    // If there is not status for this approval, the value should show NOT_SET.
+    approvalKeysToValues.set(key, [STATUS_ENUM_TO_TEXT[av.status || '']]);
+  }
+  return approvalKeysToValues;
+}
+
+/**
+ * Converts issue.approvalValues into a map where the approvers can be looked
+ * up given a field value key.
+ *
+ * @param {Array} approvalValues list of approvals with a fieldRef attached.
+ * @return {Map} keys are a string constructed using fieldValueMapKey() and
+ *   values are an Array of
+ */
+export function approvalApproversToMap(approvalValues) {
+  if (!approvalValues) return new Map();
+  const approvalKeysToApprovers = new Map();
+  for (const av of approvalValues) {
+    if (!av || !av.fieldRef || !av.fieldRef.fieldName ||
+        !av.approverRefs) continue;
+    const key = fieldValueMapKey(av.fieldRef.fieldName);
+    const approvers = av.approverRefs.map((ref) => ref.displayName);
+    approvalKeysToApprovers.set(key, approvers);
+  }
+  return approvalKeysToApprovers;
+}
+
+
+// Helper function used for fields with only one value that can be unset.
+const wrapValueIfExists = (value) => value ? [value] : [];
+
+
+/**
+ * @typedef DefaultIssueField
+ * @property {string} fieldName
+ * @property {fieldTypes} type
+ * @property {function(*): Array<string>} extractor
+*/
+// TODO(zhangtiff): Merge this functionality with extract-grid-data.js
+// TODO(zhangtiff): Combine this functionality with mr-metadata and
+// mr-edit-metadata to allow more expressive representation of built in fields.
+/**
+ * @const {Array<DefaultIssueField>}
+ */
+const defaultIssueFields = Object.freeze([
+  {
+    fieldName: 'ID',
+    type: fieldTypes.ISSUE_TYPE,
+    extractor: ({localId, projectName}) => [{localId, projectName}],
+  }, {
+    fieldName: 'Project',
+    type: fieldTypes.PROJECT_TYPE,
+    extractor: (issue) => [issue.projectName],
+  }, {
+    fieldName: 'Attachments',
+    type: fieldTypes.INT_TYPE,
+    extractor: (issue) => [issue.attachmentCount || 0],
+  }, {
+    fieldName: 'AllLabels',
+    type: fieldTypes.LABEL_TYPE,
+    extractor: (issue) => issue.labelRefs || [],
+  }, {
+    fieldName: 'Blocked',
+    type: fieldTypes.STR_TYPE,
+    extractor: (issue) => {
+      if (issue.blockedOnIssueRefs && issue.blockedOnIssueRefs.length) {
+        return ['Yes'];
+      }
+      return ['No'];
+    },
+  }, {
+    fieldName: 'BlockedOn',
+    type: fieldTypes.ISSUE_TYPE,
+    extractor: (issue) => issue.blockedOnIssueRefs || [],
+  }, {
+    fieldName: 'Blocking',
+    type: fieldTypes.ISSUE_TYPE,
+    extractor: (issue) => issue.blockingIssueRefs || [],
+  }, {
+    fieldName: 'CC',
+    type: fieldTypes.USER_TYPE,
+    extractor: (issue) => issue.ccRefs || [],
+  }, {
+    fieldName: 'Closed',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.closedTimestamp),
+  }, {
+    fieldName: 'Component',
+    type: fieldTypes.COMPONENT_TYPE,
+    extractor: (issue) => issue.componentRefs || [],
+  }, {
+    fieldName: 'ComponentModified',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => [issue.componentModifiedTimestamp],
+  }, {
+    fieldName: 'MergedInto',
+    type: fieldTypes.ISSUE_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.mergedIntoIssueRef),
+  }, {
+    fieldName: 'Modified',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.modifiedTimestamp),
+  }, {
+    fieldName: 'Reporter',
+    type: fieldTypes.USER_TYPE,
+    extractor: (issue) => [issue.reporterRef],
+  }, {
+    fieldName: 'Stars',
+    type: fieldTypes.INT_TYPE,
+    extractor: (issue) => [issue.starCount || 0],
+  }, {
+    fieldName: 'Status',
+    type: fieldTypes.STATUS_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.statusRef),
+  }, {
+    fieldName: 'StatusModified',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => [issue.statusModifiedTimestamp],
+  }, {
+    fieldName: 'Summary',
+    type: fieldTypes.STR_TYPE,
+    extractor: (issue) => [issue.summary],
+  }, {
+    fieldName: 'Type',
+    type: fieldTypes.ENUM_TYPE,
+    extractor: (issue) => wrapValueIfExists(extractTypeForIssue(
+        issue.fieldValues, issue.labelRefs)),
+  }, {
+    fieldName: 'Owner',
+    type: fieldTypes.USER_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.ownerRef),
+  }, {
+    fieldName: 'OwnerModified',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => [issue.ownerModifiedTimestamp],
+  }, {
+    fieldName: 'Opened',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => [issue.openedTimestamp],
+  },
+]);
+
+/**
+ * Lowercase field name -> field object. This uses an Object instead of a Map
+ * so that it can be frozen.
+ * @type {Object<string, DefaultIssueField>}
+ */
+export const defaultIssueFieldMap = Object.freeze(
+    defaultIssueFields.reduce((acc, field) => {
+      acc[field.fieldName.toLowerCase()] = field;
+      return acc;
+    }, {}),
+);
+
+export const DEFAULT_ISSUE_FIELD_LIST = defaultIssueFields.map(
+    (field) => field.fieldName);
+
+/**
+ * Wrapper that extracts potentially composite field values from issue
+ * @param {Issue} issue
+ * @param {string} fieldName
+ * @param {string} projectName
+ * @return {Array<string>}
+ */
+export const stringValuesForIssueField = (issue, fieldName, projectName) => {
+  // Split composite fields into each segment
+  return fieldName.split('/').flatMap((fieldKey) => stringValuesExtractor(
+      issue, fieldKey, projectName));
+};
+
+/**
+ * Extract string values of an issue's field
+ * @param {Issue} issue
+ * @param {string} fieldName
+ * @param {string} projectName
+ * @return {Array<string>}
+ */
+const stringValuesExtractor = (issue, fieldName, projectName) => {
+  const fieldKey = fieldName.toLowerCase();
+
+  // Look at whether the field is a built in field first.
+  if (defaultIssueFieldMap.hasOwnProperty(fieldKey)) {
+    const bakedFieldDef = defaultIssueFieldMap[fieldKey];
+    const values = bakedFieldDef.extractor(issue);
+    switch (bakedFieldDef.type) {
+      case fieldTypes.ISSUE_TYPE:
+        return issueRefsToStrings(values, projectName);
+      case fieldTypes.COMPONENT_TYPE:
+        return componentRefsToStrings(values);
+      case fieldTypes.LABEL_TYPE:
+        return labelRefsToStrings(values);
+      case fieldTypes.USER_TYPE:
+        return userRefsToDisplayNames(values);
+      case fieldTypes.STATUS_TYPE:
+        return statusRefsToStrings(values);
+      case fieldTypes.TIME_TYPE:
+        // TODO(zhangtiff): Find a way to dynamically update displayed
+        // time without page reloads.
+        return values.map((time) => relativeTime(new Date(time * 1000)));
+    }
+    return values.map((value) => `${value}`);
+  }
+
+  // Handle custom approval field approver columns.
+  const found = fieldKey.match(APPROVER_COL_SUFFIX_REGEX);
+  if (found) {
+    const approvalName = fieldKey.slice(0, -found[0].length);
+    const approvalFieldKey = fieldValueMapKey(approvalName);
+    const approvalApproversMap = approvalApproversToMap(issue.approvalValues);
+    if (approvalApproversMap.has(approvalFieldKey)) {
+      return approvalApproversMap.get(approvalFieldKey);
+    }
+  }
+
+  // Handle custom approval field columns.
+  const approvalValuesMap = approvalValuesToMap(issue.approvalValues);
+  if (approvalValuesMap.has(fieldKey)) {
+    return approvalValuesMap.get(fieldKey);
+  }
+
+  // Handle custom fields.
+  let fieldValueKey = fieldKey;
+  let fieldNameKey = fieldKey;
+  if (fieldKey.match(PHASE_FIELD_COL_DELIMITER_REGEX)) {
+    let phaseName;
+    [phaseName, fieldNameKey] = fieldKey.split(
+        PHASE_FIELD_COL_DELIMITER_REGEX);
+    // key for fieldValues Map contain the phaseName, if any.
+    fieldValueKey = fieldValueMapKey(fieldNameKey, phaseName);
+  }
+  const fieldValuesMap = fieldValuesToMap(issue.fieldValues);
+  if (fieldValuesMap.has(fieldValueKey)) {
+    return fieldValuesMap.get(fieldValueKey);
+  }
+
+  // Handle custom labels and ad hoc labels last.
+  const matchingLabels = (issue.labelRefs || []).filter((labelRef) => {
+    const labelPrefixes = labelNameToLabelPrefixes(
+        labelRef.label).map((prefix) => prefix.toLowerCase());
+    return labelPrefixes.includes(fieldKey);
+  });
+  const labelPrefix = fieldKey + '-';
+  return matchingLabels.map(
+      (labelRef) => removePrefix(labelRef.label, labelPrefix));
+};
+
+/**
+ * Computes all custom fields set in a given Issue, including custom
+ * fields derived from label prefixes and approval values.
+ * @param {Issue} issue An Issue object.
+ * @param {boolean=} exclHighCardinality Whether to exclude fields with a high
+ *   cardinality, like string custom fields for example. This is useful for
+ *   features where issues are grouped by different values because grouping
+ *   by high cardinality fields is not meaningful.
+ * @return {Array<string>}
+ */
+export function fieldsForIssue(issue, exclHighCardinality = false) {
+  const approvalValues = issue.approvalValues || [];
+  let fieldValues = issue.fieldValues || [];
+  const labelRefs = issue.labelRefs || [];
+  const labelPrefixes = [];
+  labelRefs.forEach((labelRef) => {
+    labelPrefixes.push(...labelNameToLabelPrefixes(labelRef.label));
+  });
+  if (exclHighCardinality) {
+    fieldValues = fieldValues.filter(({fieldRef}) =>
+      GROUPABLE_FIELD_TYPES.has(fieldRef.type));
+  }
+  return [
+    ...approvalValues.map((approval) => approval.fieldRef.fieldName),
+    ...approvalValues.map(
+        (approval) => approval.fieldRef.fieldName + '-Approver'),
+    ...fieldValues.map((fieldValue) => {
+      if (fieldValue.phaseRef) {
+        return fieldValue.phaseRef.phaseName + '.' +
+            fieldValue.fieldRef.fieldName;
+      } else {
+        return fieldValue.fieldRef.fieldName;
+      }
+    }),
+    ...labelPrefixes,
+  ];
+}
diff --git a/static_src/shared/issue-fields.test.js b/static_src/shared/issue-fields.test.js
new file mode 100644
index 0000000..c37faa9
--- /dev/null
+++ b/static_src/shared/issue-fields.test.js
@@ -0,0 +1,491 @@
+// 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 {parseColSpec, fieldsForIssue,
+  stringValuesForIssueField} from './issue-fields.js';
+import sinon from 'sinon';
+
+let issue;
+let clock;
+
+describe('parseColSpec', () => {
+  it('empty spec produces empty list', () => {
+    assert.deepEqual(parseColSpec(),
+        []);
+    assert.deepEqual(parseColSpec(''),
+        []);
+    assert.deepEqual(parseColSpec(' + + + '),
+        []);
+    assert.deepEqual(parseColSpec('          '),
+        []);
+    assert.deepEqual(parseColSpec('+++++'),
+        []);
+  });
+
+  it('parses spec correctly', () => {
+    assert.deepEqual(parseColSpec('ID+Summary+AllLabels+Priority'),
+        ['ID', 'Summary', 'AllLabels', 'Priority']);
+  });
+
+  it('parses spaces correctly', () => {
+    assert.deepEqual(parseColSpec('ID Summary AllLabels Priority'),
+        ['ID', 'Summary', 'AllLabels', 'Priority']);
+    assert.deepEqual(parseColSpec('ID + Summary + AllLabels + Priority'),
+        ['ID', 'Summary', 'AllLabels', 'Priority']);
+    assert.deepEqual(parseColSpec('ID   Summary AllLabels     Priority'),
+        ['ID', 'Summary', 'AllLabels', 'Priority']);
+  });
+
+  it('spec parsing preserves dashed parameters', () => {
+    assert.deepEqual(parseColSpec('ID+Summary+Test-Label+Another-Label'),
+        ['ID', 'Summary', 'Test-Label', 'Another-Label']);
+  });
+});
+
+describe('fieldsForIssue', () => {
+  const issue = {
+    projectName: 'proj',
+    localId: 1,
+  };
+
+  const issueWithLabels = {
+    projectName: 'proj',
+    localId: 1,
+    labelRefs: [
+      {label: 'test'},
+      {label: 'hello-world'},
+      {label: 'multi-label-field'},
+    ],
+  };
+
+  const issueWithFieldValues = {
+    projectName: 'proj',
+    localId: 1,
+    fieldValues: [
+      {fieldRef: {fieldName: 'number', type: 'INT_TYPE'}},
+      {fieldRef: {fieldName: 'string', type: 'STR_TYPE'}},
+    ],
+  };
+
+  const issueWithPhases = {
+    projectName: 'proj',
+    localId: 1,
+    fieldValues: [
+      {fieldRef: {fieldName: 'phase-number', type: 'INT_TYPE'},
+        phaseRef: {phaseName: 'phase1'}},
+      {fieldRef: {fieldName: 'phase-string', type: 'STR_TYPE'},
+        phaseRef: {phaseName: 'phase2'}},
+    ],
+  };
+
+  const issueWithApprovals = {
+    projectName: 'proj',
+    localId: 1,
+    approvalValues: [
+      {fieldRef: {fieldName: 'approval', type: 'APPROVAL_TYPE'}},
+    ],
+  };
+
+  it('empty issue issue produces no field names', () => {
+    assert.deepEqual(fieldsForIssue(issue), []);
+    assert.deepEqual(fieldsForIssue(issue, true), []);
+  });
+
+  it('includes label prefixes', () => {
+    assert.deepEqual(fieldsForIssue(issueWithLabels), [
+      'hello',
+      'multi',
+      'multi-label',
+    ]);
+  });
+
+  it('includes field values', () => {
+    assert.deepEqual(fieldsForIssue(issueWithFieldValues), [
+      'number',
+      'string',
+    ]);
+  });
+
+  it('excludes high cardinality field values', () => {
+    assert.deepEqual(fieldsForIssue(issueWithFieldValues, true), [
+      'number',
+    ]);
+  });
+
+  it('includes phase fields', () => {
+    assert.deepEqual(fieldsForIssue(issueWithPhases), [
+      'phase1.phase-number',
+      'phase2.phase-string',
+    ]);
+  });
+
+  it('excludes high cardinality phase fields', () => {
+    assert.deepEqual(fieldsForIssue(issueWithPhases, true), [
+      'phase1.phase-number',
+    ]);
+  });
+
+  it('includes approval values', () => {
+    assert.deepEqual(fieldsForIssue(issueWithApprovals), [
+      'approval',
+      'approval-Approver',
+    ]);
+  });
+});
+
+describe('stringValuesForIssueField', () => {
+  describe('built-in fields', () => {
+    beforeEach(() => {
+      // Set clock to some specified date for relative time.
+      const initialTime = 365 * 24 * 60 * 60;
+
+      clock = sinon.useFakeTimers({
+        now: new Date(initialTime * 1000),
+        shouldAdvanceTime: false,
+      });
+
+      issue = {
+        localId: 33,
+        projectName: 'chromium',
+        summary: 'Test summary',
+        attachmentCount: 22,
+        starCount: 2,
+        componentRefs: [{path: 'Infra'}, {path: 'Monorail>UI'}],
+        blockedOnIssueRefs: [{localId: 30, projectName: 'chromium'}],
+        blockingIssueRefs: [{localId: 60, projectName: 'chromium'}],
+        labelRefs: [{label: 'Restrict-View-Google'}, {label: 'Type-Defect'}],
+        reporterRef: {displayName: 'test@example.com'},
+        ccRefs: [{displayName: 'test@example.com'}],
+        ownerRef: {displayName: 'owner@example.com'},
+        closedTimestamp: initialTime - 120, // 2 minutes ago
+        modifiedTimestamp: initialTime - 60, // a minute ago
+        openedTimestamp: initialTime - 24 * 60 * 60, // a day ago
+        componentModifiedTimestamp: initialTime - 60, // a minute ago
+        statusModifiedTimestamp: initialTime - 60, // a minute ago
+        ownerModifiedTimestamp: initialTime - 60, // a minute ago
+        statusRef: {status: 'Duplicate'},
+        mergedIntoIssueRef: {localId: 31, projectName: 'chromium'},
+      };
+    });
+
+    afterEach(() => {
+      clock.restore();
+    });
+
+    it('computes strings for ID', () => {
+      const fieldName = 'ID';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium:33']);
+    });
+
+    it('computes strings for Project', () => {
+      const fieldName = 'Project';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium']);
+    });
+
+    it('computes strings for Attachments', () => {
+      const fieldName = 'Attachments';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['22']);
+    });
+
+    it('computes strings for AllLabels', () => {
+      const fieldName = 'AllLabels';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Restrict-View-Google', 'Type-Defect']);
+    });
+
+    it('computes strings for Blocked when issue is blocked', () => {
+      const fieldName = 'Blocked';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Yes']);
+    });
+
+    it('computes strings for Blocked when issue is not blocked', () => {
+      const fieldName = 'Blocked';
+      issue.blockedOnIssueRefs = [];
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['No']);
+    });
+
+    it('computes strings for BlockedOn', () => {
+      const fieldName = 'BlockedOn';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium:30']);
+    });
+
+    it('computes strings for Blocking', () => {
+      const fieldName = 'Blocking';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium:60']);
+    });
+
+    it('computes strings for CC', () => {
+      const fieldName = 'CC';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['test@example.com']);
+    });
+
+    it('computes strings for Closed', () => {
+      const fieldName = 'Closed';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['2 minutes ago']);
+    });
+
+    it('computes strings for Component', () => {
+      const fieldName = 'Component';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Infra', 'Monorail>UI']);
+    });
+
+    it('computes strings for ComponentModified', () => {
+      const fieldName = 'ComponentModified';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a minute ago']);
+    });
+
+    it('computes strings for MergedInto', () => {
+      const fieldName = 'MergedInto';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium:31']);
+    });
+
+    it('computes strings for Modified', () => {
+      const fieldName = 'Modified';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a minute ago']);
+    });
+
+    it('computes strings for Reporter', () => {
+      const fieldName = 'Reporter';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['test@example.com']);
+    });
+
+    it('computes strings for Stars', () => {
+      const fieldName = 'Stars';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['2']);
+    });
+
+    it('computes strings for Status', () => {
+      const fieldName = 'Status';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Duplicate']);
+    });
+
+    it('computes strings for StatusModified', () => {
+      const fieldName = 'StatusModified';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a minute ago']);
+    });
+
+    it('computes strings for Summary', () => {
+      const fieldName = 'Summary';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Test summary']);
+    });
+
+    it('computes strings for Type', () => {
+      const fieldName = 'Type';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Defect']);
+    });
+
+    it('computes strings for Owner', () => {
+      const fieldName = 'Owner';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['owner@example.com']);
+    });
+
+    it('computes strings for OwnerModified', () => {
+      const fieldName = 'OwnerModified';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a minute ago']);
+    });
+
+    it('computes strings for Opened', () => {
+      const fieldName = 'Opened';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a day ago']);
+    });
+  });
+
+  describe('custom approval fields', () => {
+    beforeEach(() => {
+      issue = {
+        localId: 33,
+        projectName: 'bird',
+        approvalValues: [
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Goose-Approval'},
+            approverRefs: []},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Chicken-Approval'},
+            status: 'APPROVED'},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Dodo-Approval'},
+            status: 'NEED_INFO', approverRefs: [{displayName: 'kiwi@bird.test'},
+              {displayName: 'mini-dino@bird.test'}],
+          },
+        ],
+      };
+    });
+
+    it('handles approval approver columns', () => {
+      const projectName = 'bird';
+      assert.deepEqual(stringValuesForIssueField(
+          issue, 'goose-approval-approver',
+          projectName), []);
+      assert.deepEqual(stringValuesForIssueField(
+          issue, 'chicken-approval-approver',
+          projectName), []);
+      assert.deepEqual(stringValuesForIssueField(
+          issue, 'dodo-approval-approver',
+          projectName), ['kiwi@bird.test', 'mini-dino@bird.test']);
+    });
+
+    it('handles approval value columns', () => {
+      const projectName = 'bird';
+      assert.deepEqual(stringValuesForIssueField(issue, 'goose-approval',
+          projectName), ['NotSet']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'chicken-approval',
+          projectName), ['Approved']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'dodo-approval',
+          projectName), ['NeedInfo']);
+    });
+  });
+
+  describe('custom fields', () => {
+    beforeEach(() => {
+      issue = {
+        localId: 33,
+        projectName: 'chromium',
+        fieldValues: [
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}, value: 'test'},
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}, value: 'test2'},
+          {fieldRef: {type: 'ENUM_TYPE', fieldName: 'ENUM'}, value: 'a-value'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'Cow-Phase'},
+            value: '55'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'Cow-Phase'},
+            value: '54'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'MilkCow-Phase'},
+            value: '56'},
+        ],
+      };
+    });
+
+    it('gets values for custom fields', () => {
+      const projectName = 'chromium';
+      assert.deepEqual(stringValuesForIssueField(issue, 'aString',
+          projectName), ['test', 'test2']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'enum',
+          projectName), ['a-value']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'cow-phase.cow-number',
+          projectName), ['55', '54']);
+      assert.deepEqual(stringValuesForIssueField(issue,
+          'milkcow-phase.cow-number', projectName), ['56']);
+    });
+
+    it('custom fields get precedence over label fields', () => {
+      const projectName = 'chromium';
+      issue.labelRefs = [{label: 'aString-ignore'}];
+      assert.deepEqual(stringValuesForIssueField(issue, 'aString',
+          projectName), ['test', 'test2']);
+    });
+  });
+
+  describe('label prefix fields', () => {
+    beforeEach(() => {
+      issue = {
+        localId: 33,
+        projectName: 'chromium',
+        labelRefs: [
+          {label: 'test-label'},
+          {label: 'test-label-2'},
+          {label: 'ignore-me'},
+          {label: 'Milestone-UI'},
+          {label: 'Milestone-Goodies'},
+        ],
+      };
+    });
+
+    it('gets values for label prefixes', () => {
+      const projectName = 'chromium';
+      assert.deepEqual(stringValuesForIssueField(issue, 'test',
+          projectName), ['label', 'label-2']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'Milestone',
+          projectName), ['UI', 'Goodies']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'ignore',
+          projectName), ['me']);
+    });
+  });
+
+  describe('composite fields', () => {
+    beforeEach(() => {
+      // Set clock to some specified date for relative time.
+      const initialTime = 365 * 24 * 60 * 60;
+
+      clock = sinon.useFakeTimers({
+        now: new Date(initialTime * 1000),
+        shouldAdvanceTime: false,
+      });
+
+      issue = {
+        localId: 33,
+        projectName: 'chromium',
+        summary: 'Test summary',
+        closedTimestamp: initialTime - 120, // 2 minutes ago
+        modifiedTimestamp: initialTime - 60, // a minute ago
+        openedTimestamp: initialTime - 24 * 60 * 60, // a day ago
+        statusModifiedTimestamp: initialTime - 60, // a minute ago
+        statusRef: {status: 'Duplicate'},
+      };
+    });
+
+    afterEach(() => {
+      clock.restore();
+    });
+
+    it('computes strings for Status/Closed', () => {
+      const fieldName = 'Status/Closed';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Duplicate', '2 minutes ago']);
+    });
+
+    it('ignores nonexistant fields', () => {
+      const fieldName = 'Owner/Status';
+
+      assert.isFalse(issue.hasOwnProperty('ownerRef'));
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Duplicate']);
+    });
+  });
+});
diff --git a/static_src/shared/math.js b/static_src/shared/math.js
new file mode 100644
index 0000000..36e2d75
--- /dev/null
+++ b/static_src/shared/math.js
@@ -0,0 +1,26 @@
+// 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.
+
+// Get parameter of generated line using linear regression formula,
+// using last n data points of values.
+export function linearRegression(values, n) {
+  let sumValues = 0;
+  let indices = 0;
+  let sqIndices = 0;
+  let multiply = 0;
+  let temp;
+  for (let i = 0; i < n; i++) {
+    temp = values[values.length-n+i];
+    sumValues += temp;
+    indices += i;
+    sqIndices += i * i;
+    multiply += i * temp;
+  }
+  // Calculate linear regression formula for values.
+  const slope = (n * multiply - sumValues * indices) /
+    (n * sqIndices - indices * indices);
+  const intercept = (sumValues * sqIndices - indices * multiply) /
+    (n * sqIndices - indices * indices);
+  return [slope, intercept];
+}
diff --git a/static_src/shared/math.test.js b/static_src/shared/math.test.js
new file mode 100644
index 0000000..4b4c153
--- /dev/null
+++ b/static_src/shared/math.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 {linearRegression} from './math.js';
+
+describe('linearRegression', () => {
+  it('calculate slope and intercept using formula', () => {
+    const values = [0, 1, 2, 3, 4, 5, 6];
+    const [slope, intercept] = linearRegression(values, 7);
+    assert.equal(slope, 1);
+    assert.equal(intercept, 0);
+  });
+
+  it('calculate slope and intercept using last n data points', () => {
+    const values = [0, 1, 0, 3, 5, 7, 9];
+    const [slope, intercept] = linearRegression(values, 4);
+    assert.equal(slope, 2);
+    assert.equal(intercept, 3);
+  });
+});
diff --git a/static_src/shared/md-helper.js b/static_src/shared/md-helper.js
new file mode 100644
index 0000000..da5ac3c
--- /dev/null
+++ b/static_src/shared/md-helper.js
@@ -0,0 +1,143 @@
+import marked from 'marked';
+import DOMPurify from 'dompurify';
+
+/** @type {Set} Projects that default Markdown rendering to true. */
+export const DEFAULT_MD_PROJECTS = new Set(['monkeyrail', 'monorail', 'fuchsia']);
+
+/** @type {Set} Projects that allow users to opt into Markdown rendering. */
+export const AVAILABLE_MD_PROJECTS = new Set([...DEFAULT_MD_PROJECTS]);
+
+/** @type {Set} Authors whose comments will not be rendered as Markdown. */
+const BLOCKLIST = new Set(['sheriffbot@sheriffbot-1182.iam.gserviceaccount.com',
+                          'sheriff-o-matic@appspot.gserviceaccount.com',
+                          'sheriff-o-matic-staging@appspot.gserviceaccount.com',
+                          'bugdroid1@chromium.org',
+                          'bugdroid@chops-service-accounts.iam.gserviceaccount.com',
+                          'gitwatcher-staging.google.com@appspot.gserviceaccount.com',
+                          'gitwatcher.google.com@appspot.gserviceaccount.com']);
+
+/**
+ * Determines whether content should be rendered as Markdown.
+ * @param {string} options.project Project this content belongs to.
+ * @param {number} options.author User who authored this content.
+ * @param {boolean} options.enabled Per-issue override to force Markdown.
+ * @param {Array<string>} options.availableProjects List of opted in projects.
+ * @return {boolean} Whether this content should be rendered as Markdown.
+ */
+export const shouldRenderMarkdown = ({
+  project, author, enabled = true, availableProjects = AVAILABLE_MD_PROJECTS
+} = {}) => {
+  if (author in BLOCKLIST) {
+    return false;
+  } else if (!enabled) {
+    return false;
+  } else if (availableProjects.has(project)) {
+    return true;
+  }
+  return false;
+};
+
+/** @const {Object} Options for DOMPurify sanitizer */
+const SANITIZE_OPTIONS = Object.freeze({
+  RETURN_TRUSTED_TYPE: true,
+  FORBID_TAGS: ['style'],
+  FORBID_ATTR: ['style', 'autoplay'],
+});
+
+/**
+ * Replaces bold HTML tags in comment with Markdown equivalent.
+ * @param {string} raw Comment string as stored in database.
+ * @return {string} Comment string after b tags are placed by Markdown bolding.
+ */
+const replaceBoldTag = (raw) => {
+  return raw.replace(/<b>|<\/b>/g, '**');
+};
+
+/** @const {Object} Basic HTML character escape mapping */
+const HTML_ESCAPE_MAP = Object.freeze({
+  '&': '&amp;',
+  '<': '&lt;',
+  '>': '&gt;',
+  '"': '&quot;',
+  '\'': '&#39;',
+  '/': '&#x2F;',
+  '`': '&#x60;',
+  '=': '&#x3D;',
+});
+
+/**
+ * Escapes HTML characters, used to render HTML blocks in Markdown. This
+ * alleviates security flaws but is not the primary security barrier, that is
+ * handled by DOMPurify.
+ * @param {string} text Content that looks to Marked parser to contain HTML.
+ * @return {string} Same text content after escaping HTML characters.
+ */
+const escapeHtml = (text) => {
+  return text.replace(/[&<>"'`=\/]/g, (s) => {
+    return HTML_ESCAPE_MAP[s];
+  });
+};
+
+/**
+* Checks to see if input string is a valid HTTP link.
+ * @param {string} string
+ * @return {boolean} Whether input string is a valid HTTP(s) link.
+ */
+const isValidHttpUrl = (string) => {
+  let url;
+
+  try {
+    url = new URL(string);
+  } catch (_exception) {
+    return false;
+  }
+
+  return url.protocol === 'http:' || url.protocol === 'https:';
+};
+
+/**
+ * Renderer option for Marked.
+ * See https://marked.js.org/using_pro#renderer on how to use renderer.
+ * @type {Object}
+ */
+const renderer = {
+  html(text) {
+    // Do not render HTML, instead escape HTML and render as plaintext.
+    return escapeHtml(text);
+  },
+  link(href, title, text) {
+    // Overrides default link rendering by adding icon and destination on hover.
+    // TODO(crbug.com/monorail/9316): Add shared-styles/MD_STYLES to all
+    // components that consume the markdown renderer.
+    let linkIcon;
+    let tooltipText;
+    if (isValidHttpUrl(href)) {
+      linkIcon = `<span class="material-icons link">link</span>`;
+      tooltipText = `Link destination: ${href}`;
+    } else {
+      linkIcon = `<span class="material-icons link_off">link_off</span>`;
+      tooltipText = `Link may be malformed: ${href}`;
+    }
+    const tooltip = `<span class="tooltip">${tooltipText}</span>`;
+    return `<span class="annotated-link"><a href=${href} ` +
+        `title=${title ? title : ''}>${linkIcon}${text}</a>${tooltip}</span>`;
+  },
+};
+
+marked.use({renderer, headerIds: false});
+
+/**
+ * Renders Markdown content into HTML.
+ * @param {string} raw Content to be intepretted as Markdown.
+ * @return {string} Rendered content in HTML format.
+ */
+export const renderMarkdown = (raw) => {
+  // TODO(crbug.com/monorail/9310): Add commentReferences, projectName,
+  // and revisionUrlFormat to use in conjunction with Marked's lexer for
+  // autolinking.
+  // TODO(crbug.com/monorail/9310): Integrate autolink
+  const preprocessed = replaceBoldTag(raw);
+  const converted = marked(preprocessed);
+  const sanitized = DOMPurify.sanitize(converted, SANITIZE_OPTIONS);
+  return sanitized.toString();
+};
diff --git a/static_src/shared/md-helper.test.js b/static_src/shared/md-helper.test.js
new file mode 100644
index 0000000..9f7dba1
--- /dev/null
+++ b/static_src/shared/md-helper.test.js
@@ -0,0 +1,90 @@
+import {assert} from 'chai';
+import {renderMarkdown, shouldRenderMarkdown} from './md-helper.js';
+
+describe('shouldRenderMarkdown', () => {
+  it('defaults to false', () => {
+    const actual = shouldRenderMarkdown();
+    assert.isFalse(actual);
+  });
+
+  it('returns true for enabled projects', () => {
+    const actual = shouldRenderMarkdown({project:'astor',
+      availableProjects: new Set(['astor'])});
+    assert.isTrue(actual);
+  });
+
+  it('returns false for disabled projects', () => {
+    const actual = shouldRenderMarkdown({project:'hazelnut',
+      availableProjects: new Set(['astor'])});
+    assert.isFalse(actual);
+  });
+
+  it('user pref can disable markdown', () => {
+    const actual = shouldRenderMarkdown({project:'astor',
+      enabledProjects: new Set(['astor']), enabled: false});
+    assert.isFalse(actual);
+  });
+});
+
+describe('renderMarkdown', () => {
+  it('can render empty string', () => {
+    const actual = renderMarkdown('');
+    assert.equal(actual, '');
+  });
+
+  it('can render basic string', () => {
+    const actual = renderMarkdown('hello world');
+    assert.equal(actual, '<p>hello world</p>\n');
+  });
+
+  it('can render lists', () => {
+    const input = '* First item\n* Second item\n* Third item\n* Fourth item';
+    const actual = renderMarkdown(input);
+    const expected = '<ul>\n<li>First item</li>\n<li>Second item</li>\n' +
+        '<li>Third item</li>\n<li>Fourth item</li>\n</ul>\n';
+    assert.equal(actual, expected);
+  });
+
+  it('can render headings', () => {
+    const actual = renderMarkdown('# Heading level 1\n\n## Heading level 2');
+    assert.equal(actual,
+        '<h1>Heading level 1</h1>\n<h2>Heading level 2</h2>\n');
+  });
+
+  describe('can render links', () => {
+    it('for simple links', () => {
+      const actual = renderMarkdown('[clickme](http://google.com)');
+      const expected = `<p><span class="annotated-link"><a title="" ` +
+          `href="http://google.com"><span class="material-icons link">` +
+          `link</span>clickme</a><span class="tooltip">Link destination: ` +
+          `http://google.com</span></span></p>\n`;
+      assert.equal(actual, expected);
+    });
+
+    it('and indicates malformed link', () => {
+      const actual = renderMarkdown('[clickme](google.com)');
+      const expected = `<p><span class="annotated-link"><a title="" ` +
+          `href="google.com"><span class="material-icons link_off">link_off` +
+          `</span>clickme</a><span class="tooltip">Link may be malformed: ` +
+          `google.com</span></span></p>\n`;
+      assert.equal(actual, expected);
+    });
+  });
+
+  it('preserves bolding from description templates', () => {
+    const input = `<b>What's the problem?</b>\n<b>1.</b> A\n<b>2.</b> B`;
+    const actual = renderMarkdown(input);
+    const expected = `<p><strong>What's the problem?</strong>\n<strong>1.` +
+        `</strong> A\n<strong>2.</strong> B</p>\n`;
+    assert.equal(actual, expected);
+  });
+
+  it('escapes HTML content', () => {
+    let actual = renderMarkdown('<input></input>');
+    assert.equal(actual, '<p>&lt;input&gt;&lt;/input&gt;</p>\n');
+
+    actual = renderMarkdown('<a href="https://google.com">clickme</a>');
+    assert.equal(actual,
+        '<p>&lt;a href="https://google.com"&gt;clickme&lt;/a&gt;</p>\n');
+  });
+});
diff --git a/static_src/shared/metadata-helpers.js b/static_src/shared/metadata-helpers.js
new file mode 100644
index 0000000..5735557
--- /dev/null
+++ b/static_src/shared/metadata-helpers.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.
+
+// TODO(crbug.com/monorail/4549): Remove this hardcoded data once backend custom
+// field grouping is implemented.
+export const HARDCODED_FIELD_GROUPS = [
+  {
+    groupName: 'Feature Team',
+    fieldNames: ['PM', 'Tech Lead', 'Tech-Lead', 'TechLead', 'TL',
+      'Team', 'UX', 'TE'],
+    applicableType: 'FLT-Launch',
+  },
+  {
+    groupName: 'Docs',
+    fieldNames: ['PRD', 'DD', 'Design Doc', 'Design-Doc',
+      'DesignDoc', 'Mocks', 'Test Plan', 'Test-Plan', 'TestPlan',
+      'Metrics'],
+    applicableType: 'FLT-Launch',
+  },
+];
+
+export const fieldGroupMap = (fieldGroupsArg, issueType) => {
+  const fieldGroups = groupsForType(fieldGroupsArg, issueType);
+  return fieldGroups.reduce((acc, group) => {
+    return group.fieldNames.reduce((acc, fieldName) => {
+      acc[fieldName] = group.groupName;
+      return acc;
+    }, acc);
+  }, {});
+};
+
+/**
+ * Get all values for a field, given an issue's fieldValueMap.
+ * @param {Map.<string, Array<string>>} fieldValueMap Map where keys are
+ *   lowercase fieldNames and values are fieldValue strings.
+ * @param {string} fieldName The name of the field to look up.
+ * @param {string=} phaseName Name of the phase the field is attached to,
+ *   if applicable.
+ * @return {Array<string>} The values of the field.
+ */
+export const valuesForField = (fieldValueMap, fieldName, phaseName) => {
+  if (!fieldValueMap) return [];
+  return fieldValueMap.get(
+      fieldValueMapKey(fieldName, phaseName)) || [];
+};
+
+/**
+ * Get just one value for a field. Convenient in some cases for
+ * fields that are not multi-valued.
+ * @param {Map.<string, Array<string>>} fieldValueMap Map where keys are
+ *   lowercase fieldNames and values are fieldValue strings.
+ * @param {string} fieldName The name of the field to look up.
+ * @param {string=} phaseName Name of the phase the field is attached to,
+ *   if applicable.
+ * @return {string} The value of the field.
+ */
+export function valueForField(fieldValueMap, fieldName, phaseName) {
+  const values = valuesForField(fieldValueMap, fieldName, phaseName);
+  return values.length ? values[0] : undefined;
+}
+
+/**
+ * Helper to generate Map keys for FieldValueMaps in a standard format.
+ * @param {string} fieldName Name of the field the value is tied to.
+ * @param {string=} phaseName Name of the phase the value is tied to.
+ * @return {string}
+ */
+export const fieldValueMapKey = (fieldName, phaseName) => {
+  const key = [fieldName];
+  if (phaseName) {
+    key.push(phaseName);
+  }
+  return key.join(' ').toLowerCase();
+};
+
+export const groupsForType = (fieldGroups, issueType) => {
+  return fieldGroups.filter((group) => {
+    if (!group.applicableType) return true;
+    return issueType && group.applicableType.toLowerCase() ===
+      issueType.toLowerCase();
+  });
+};
+
+export const fieldDefsWithGroup = (fieldDefs, fieldGroupsArg, issueType) => {
+  const fieldGroups = groupsForType(fieldGroupsArg, issueType);
+  if (!fieldDefs) return [];
+  const groups = [];
+  fieldGroups.forEach((group) => {
+    const groupFields = [];
+    group.fieldNames.forEach((name) => {
+      const fd = fieldDefs.find(
+          (fd) => (fd.fieldRef.fieldName == name));
+      if (fd) {
+        groupFields.push(fd);
+      }
+    });
+    if (groupFields.length > 0) {
+      groups.push({
+        groupName: group.groupName,
+        fieldDefs: groupFields,
+      });
+    }
+  });
+  return groups;
+};
+
+export const fieldDefsWithoutGroup = (fieldDefs, fieldGroups, issueType) => {
+  if (!fieldDefs) return [];
+  const map = fieldGroupMap(fieldGroups, issueType);
+  return fieldDefs.filter((fd) => {
+    return !(fd.fieldRef.fieldName in map);
+  });
+};
diff --git a/static_src/shared/metadata-helpers.test.js b/static_src/shared/metadata-helpers.test.js
new file mode 100644
index 0000000..fd04806
--- /dev/null
+++ b/static_src/shared/metadata-helpers.test.js
@@ -0,0 +1,93 @@
+// 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 {valuesForField, valueForField, fieldDefsWithGroup, fieldValueMapKey,
+  fieldDefsWithoutGroup, HARDCODED_FIELD_GROUPS} from './metadata-helpers.js';
+
+const fieldDefs = [
+  {
+    fieldRef: {
+      fieldName: 'Ignore',
+      fieldId: 1,
+    },
+  },
+  {
+    fieldRef: {
+      fieldName: 'DesignDoc',
+      fieldId: 2,
+    },
+  },
+];
+const fieldGroups = HARDCODED_FIELD_GROUPS;
+
+const fieldValueMap = new Map([
+  ['field', ['one', 'two', 'three']],
+  ['field-two phase', ['four']],
+  ['field-three', ['five']],
+]);
+
+describe('metadata-helpers', () => {
+  it('valuesForField', () => {
+    assert.deepEqual(valuesForField(fieldValueMap, 'Field-None'), []);
+    assert.deepEqual(valuesForField(fieldValueMap, 'Field'),
+        ['one', 'two', 'three']);
+    assert.deepEqual(valuesForField(fieldValueMap, 'Field-Two', 'Phase'),
+        ['four']);
+    assert.deepEqual(valuesForField(fieldValueMap, 'Field-Three'), ['five']);
+  });
+
+  it('valueForField', () => {
+    assert.equal(valueForField(fieldValueMap, 'Field-None'),
+        undefined);
+    assert.equal(valueForField(fieldValueMap, 'Field-Two', 'Phase'), 'four');
+    assert.equal(valueForField(fieldValueMap, 'Field-Three'), 'five');
+  });
+
+  it('fieldValueMapKey', () => {
+    assert.equal(fieldValueMapKey('test', 'two'), 'test two');
+
+    assert.equal(fieldValueMapKey('noPhase'), 'nophase');
+  });
+
+  it('fieldDefsWithoutGroup ignores non applicable types', () => {
+    assert.deepEqual(fieldDefsWithoutGroup(
+        fieldDefs, fieldGroups, 'ungrouped-type'), fieldDefs);
+  });
+
+  it('fieldDefsWithoutGroup filters grouped fields', () => {
+    assert.deepEqual(fieldDefsWithoutGroup(
+        fieldDefs, fieldGroups, 'flt-launch'), [
+      {
+        fieldRef: {
+          fieldName: 'Ignore',
+          fieldId: 1,
+        },
+      },
+    ]);
+  });
+
+  it('fieldDefsWithGroup filters by type', () => {
+    const filteredGroupsList = [{
+      groupName: 'Docs',
+      fieldDefs: [
+        {
+          fieldRef: {
+            fieldName: 'DesignDoc',
+            fieldId: 2,
+          },
+        },
+      ],
+    }];
+
+    assert.deepEqual(
+        fieldDefsWithGroup(fieldDefs, fieldGroups, 'Not-FLT-Launch'), []);
+
+    assert.deepEqual(fieldDefsWithGroup(fieldDefs, fieldGroups, 'flt-launch'),
+        filteredGroupsList);
+
+    assert.deepEqual(fieldDefsWithGroup(fieldDefs, fieldGroups, 'FLT-LAUNCH'),
+        filteredGroupsList);
+  });
+});
diff --git a/static_src/shared/settings.js b/static_src/shared/settings.js
new file mode 100644
index 0000000..0b5fc3c
--- /dev/null
+++ b/static_src/shared/settings.js
@@ -0,0 +1,23 @@
+// 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.
+
+// List of content type prefixes that the user will not be warned about when
+// downloading an attachment.
+export const ALLOWED_CONTENT_TYPE_PREFIXES = [
+  'audio/', 'font/', 'image/', 'text/plain', 'video/',
+];
+
+// List of file extensions that the user will not be warned about when
+// downloading an attachment.
+export const ALLOWED_ATTACHMENT_EXTENSIONS = [
+  '.avi', '.avif', '.bmp', '.csv', '.doc', '.docx', '.email', '.eml', '.gif',
+  '.ico', '.jpeg', '.jpg', '.log', '.m4p', '.m4v', '.mkv', '.mov', '.mp2',
+  '.mp4', '.mpeg', '.mpg', '.mpv', '.odt', '.ogg', '.pdf', '.png', '.sql',
+  '.svg', '.tif', '.tiff', '.txt', '.wav', '.webm', '.wmv',
+];
+
+// The message to show the user when they attempt to download an unrecognized
+// file type.
+export const FILE_DOWNLOAD_WARNING = 'This file type is not recognized. Are' +
+  ' you sure you want to download this attachment?';
diff --git a/static_src/shared/shared-styles.js b/static_src/shared/shared-styles.js
new file mode 100644
index 0000000..c00f639
--- /dev/null
+++ b/static_src/shared/shared-styles.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 {css} from 'lit-element';
+
+export const SHARED_STYLES = css`
+  :host {
+    --mr-edit-field-padding: 0.125em 4px;
+    --mr-edit-field-width: 90%;
+    --mr-input-grid-gap: 6px;
+    font-family: var(--chops-font-family);
+    color: var(--chops-primary-font-color);
+    font-size: var(--chops-main-font-size);
+  }
+  /** Converts a <button> to look like an <a> tag. */
+  .linkify {
+    display: inline;
+    padding: 0;
+    margin: 0;
+    border: 0;
+    background: 0;
+    cursor: pointer;
+  }
+  h1, h2, h3, h4 {
+    background: none;
+  }
+  a, chops-button, a.button, .button, .linkify {
+    color: var(--chops-link-color);
+    text-decoration: none;
+    font-weight: var(--chops-link-font-weight);
+    font-family: var(--chops-font-family);
+  }
+  a:hover, .linkify:hover {
+    text-decoration: underline;
+  }
+  a.button, .button {
+    /* Links that look like buttons. */
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    text-decoration: none;
+    transition: filter 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
+  }
+  a.button:hover, .button:hover {
+    filter: brightness(95%);
+  }
+  chops-button, a.button, .button {
+    box-sizing: border-box;
+    font-size: var(--chops-main-font-size);
+    background: var(--chops-white);
+    border-radius: 6px;
+    --chops-button-padding: 0.25em 8px;
+    margin: 0;
+    margin-left: auto;
+  }
+  a.button, .button {
+    padding: var(--chops-button-padding);
+  }
+  chops-button i.material-icons, a.button i.material-icons, .button i.material-icons {
+    display: block;
+    margin-right: 4px;
+  }
+  chops-button.emphasized, a.button.emphasized, .button.emphasized {
+    background: var(--chops-primary-button-bg);
+    color: var(--chops-primary-button-color);
+    text-shadow: 1px 1px 3px hsla(0, 0%, 0%, 0.25);
+  }
+  textarea, select, input {
+    box-sizing: border-box;
+    font-size: var(--chops-main-font-size);
+  }
+  /* Note: decoupling heading levels from styles is useful for
+  * accessibility because styles will not always line up with semantically
+  * appropriate heading levels.
+  */
+  .medium-heading {
+    font-size: var(--chops-large-font-size);
+    font-weight: normal;
+    line-height: 1;
+    padding: 0.25em 0;
+    color: var(--chops-link-color);
+    margin: 0;
+    margin-top: 0.25em;
+    border-bottom: var(--chops-normal-border);
+  }
+  .medium-heading chops-button {
+    line-height: 1.6;
+  }
+  .input-grid {
+    padding: 0.5em 0;
+    display: grid;
+    max-width: 100%;
+    grid-gap: var(--mr-input-grid-gap);
+    grid-template-columns: minmax(120px, max-content) 1fr;
+    align-items: flex-start;
+  }
+  .input-grid label {
+    font-weight: bold;
+    text-align: right;
+    word-wrap: break-word;
+  }
+  @media (max-width: 600px) {
+    .input-grid label {
+      margin-top: var(--mr-input-grid-gap);
+      text-align: left;
+    }
+    .input-grid {
+      grid-gap: var(--mr-input-grid-gap);
+      grid-template-columns: 100%;
+    }
+  }
+`;
+
+/**
+ * Markdown specific styling:
+ * * render link destination on hover as a tooltip
+ * @type {CSSResult}
+ */
+export const MD_STYLES = css`
+  .markdown .annotated-link {
+    position: relative;
+  }
+  .markdown .annotated-link:hover .tooltip {
+    display: block
+  }
+  .markdown .tooltip {
+    display: none;
+    position: absolute;
+    width: auto;
+    white-space: nowrap;
+    box-shadow: rgb(170 170 170) 1px 1px 5px;
+    box-shadow: 0 4px 8px 3px rgb(0 0 0 / 10%);
+    border-radius: 8px;
+    background-color: rgb(255, 255, 255);
+    top: -32px;
+    left: 0px;
+    border: 1px solid #dadce0;
+    padding: 6px 10px;
+  }
+  .markdown .material-icons {
+    font-size: 18px;
+    vertical-align: middle;
+  }
+  .markdown .material-icons.link {
+    color: var(--chops-link-color);
+  }
+  .markdown .material-icons.link_off {
+    color: var(--chops-field-error-color);
+  }
+  .markdown table {
+    -webkit-font-smoothing: antialiased;
+    box-sizing: inherit;
+    border-collapse: collapse;
+    margin: 8px 0 8px 0;
+    box-shadow: 0 2px 2px 0 hsla(315, 3%, 26%, 0.30);
+    border: 1px solid var(--chops-gray-300);
+    line-height: 1.4;
+  }
+  .markdown th {
+      border-bottom: 1px solid var(--chops-gray-300);
+      border-right: 1px solid var(--chops-gray-300);
+      padding: 1px;
+      text-align: left;
+      font-weight: 500;
+      color: var(--chops-gray-900);
+      background-color: var(--chops-gray-50);
+  }
+  .markdown td {
+      border-bottom: 1px solid var(--chops-gray-300);
+      border-right: 1px solid var(--chops-gray-300);
+      padding: 1px;
+  }
+  .markdown pre {
+    -webkit-font-smoothing: antialiased;
+    line-height: 1.6;
+    box-sizing: inherit;
+    background-color: hsla(0, 0%, 0%, 0.05);
+    border: 2px solid hsla(0, 0%, 0%, 0.10);
+    border-radius: 2px;
+    overflow-x: auto;
+    padding: 4px;
+  }
+`;
+
+export const MD_PREVIEW_STYLES = css`
+  ${MD_STYLES}
+  .markdown-preview {
+    padding: 0.25em 1em;
+    color: var(--chops-gray-800);
+    background-color: var(--chops-gray-200);
+    border-radius: 10px;
+    margin: 0px 0px 10px;
+    overflow: auto;
+  }
+  .preview-height-description {
+    max-height: 40vh;
+  }
+  .preview-height-comment {
+    min-height: 5vh;
+    max-height: 15vh;
+  }
+`;
\ No newline at end of file
diff --git a/static_src/shared/test/constants-hotlists.js b/static_src/shared/test/constants-hotlists.js
new file mode 100644
index 0000000..a496905
--- /dev/null
+++ b/static_src/shared/test/constants-hotlists.js
@@ -0,0 +1,76 @@
+// 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 * as issueV0 from './constants-issueV0.js';
+import * as users from './constants-users.js';
+import 'shared/typedef.js';
+
+/** @type {string} */
+export const NAME = 'hotlists/1234';
+
+/** @type {Hotlist} */
+export const HOTLIST = Object.freeze({
+  name: NAME,
+  displayName: 'Hotlist-Name',
+  owner: users.NAME,
+  editors: [users.NAME_2],
+  summary: 'Summary',
+  description: 'Description',
+  defaultColumns: [{column: 'Rank'}, {column: 'ID'}, {column: 'Summary'}],
+  hotlistPrivacy: 'PUBLIC',
+});
+
+export const HOTLIST_ITEM_NAME = NAME + '/items/56';
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM = Object.freeze({
+  name: HOTLIST_ITEM_NAME,
+  issue: issueV0.NAME,
+  // rank: The API excludes the rank field if it's 0.
+  adder: users.NAME,
+  createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE = Object.freeze({
+  ...issueV0.ISSUE, ...HOTLIST_ITEM, adder: users.USER,
+});
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM_OTHER_PROJECT = Object.freeze({
+  name: NAME + '/items/78',
+  issue: issueV0.NAME_OTHER_PROJECT,
+  rank: 1,
+  adder: users.NAME,
+  createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE_OTHER_PROJECT = Object.freeze({
+  ...issueV0.ISSUE_OTHER_PROJECT,
+  ...HOTLIST_ITEM_OTHER_PROJECT,
+  adder: users.USER,
+});
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM_CLOSED = Object.freeze({
+  name: NAME + '/items/90',
+  issue: issueV0.NAME_CLOSED,
+  rank: 2,
+  adder: users.NAME,
+  createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE_CLOSED = Object.freeze({
+  ...issueV0.ISSUE_CLOSED, ...HOTLIST_ITEM_CLOSED, adder: users.USER,
+});
+
+/** @type {Object<string, Hotlist>} */
+export const BY_NAME = Object.freeze({[NAME]: HOTLIST});
+
+/** @type {Object<string, Array<HotlistItem>>} */
+export const HOTLIST_ITEMS = Object.freeze({
+  [NAME]: [HOTLIST_ITEM],
+});
diff --git a/static_src/shared/test/constants-issueV0.js b/static_src/shared/test/constants-issueV0.js
new file mode 100644
index 0000000..4f52aef
--- /dev/null
+++ b/static_src/shared/test/constants-issueV0.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 'shared/typedef.js';
+
+export const NAME = 'projects/project-name/issues/1234';
+
+export const ISSUE_REF_STRING = 'project-name:1234';
+
+/** @type {IssueRef} */
+export const ISSUE_REF = Object.freeze({
+  projectName: 'project-name',
+  localId: 1234,
+});
+
+/** @type {Issue} */
+export const ISSUE = Object.freeze({
+  projectName: 'project-name',
+  localId: 1234,
+  statusRef: {status: 'Available', meansOpen: true},
+});
+
+export const NAME_OTHER_PROJECT = 'projects/other-project-name/issues/1234';
+
+export const ISSUE_OTHER_PROJECT_REF_STRING = 'other-project-name:1234';
+
+/** @type {Issue} */
+export const ISSUE_OTHER_PROJECT = Object.freeze({
+  projectName: 'other-project-name',
+  localId: 1234,
+  statusRef: {status: 'Available', meansOpen: true},
+});
+
+export const NAME_CLOSED = 'projects/project-name/issues/5678';
+
+/** @type {Issue} */
+export const ISSUE_CLOSED = Object.freeze({
+  projectName: 'project-name',
+  localId: 5678,
+  statusRef: {status: 'Fixed', meansOpen: false},
+});
diff --git a/static_src/shared/test/constants-permissions.js b/static_src/shared/test/constants-permissions.js
new file mode 100644
index 0000000..f4b09c0
--- /dev/null
+++ b/static_src/shared/test/constants-permissions.js
@@ -0,0 +1,23 @@
+// Copyright 2020 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 * as issue from './constants-issueV0.js';
+import 'shared/typedef.js';
+
+/** @type {Permission} */
+export const PERMISSION_ISSUE_EDIT = 'ISSUE_EDIT';
+
+/** @type {PermissionSet} */
+export const PERMISSION_SET_ISSUE = {
+  resource: issue.NAME,
+  permissions: [PERMISSION_ISSUE_EDIT],
+};
+
+/** @type {Object<string, PermissionSet>} */
+export const BY_NAME = {
+  [issue.NAME]: PERMISSION_SET_ISSUE,
+};
+
+/** @type {Array<Permission>} */
+export const PERMISSION_HOTLIST_EDIT = ['HOTLIST_EDIT'];
diff --git a/static_src/shared/test/constants-projectV0.js b/static_src/shared/test/constants-projectV0.js
new file mode 100644
index 0000000..4a46af8
--- /dev/null
+++ b/static_src/shared/test/constants-projectV0.js
@@ -0,0 +1,80 @@
+// 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 {fieldTypes} from 'shared/issue-fields.js';
+import {USER_REF} from './constants-users.js';
+import 'shared/typedef.js';
+
+/** @type {string} */
+export const PROJECT_NAME = 'project-name';
+
+/** @type {FieldDef} */
+export const FIELD_DEF_INT = Object.freeze({
+  fieldRef: Object.freeze({
+    fieldId: 123,
+    fieldName: 'field-name',
+    type: fieldTypes.INT_TYPE,
+  }),
+});
+
+/** @type {FieldDef} */
+export const FIELD_DEF_ENUM = Object.freeze({
+  fieldRef: Object.freeze({
+    fieldId: 456,
+    fieldName: 'enum',
+    type: fieldTypes.ENUM_TYPE,
+  }),
+});
+
+/** @type {Array<FieldDef>} */
+export const FIELD_DEFS = [
+  FIELD_DEF_INT,
+  FIELD_DEF_ENUM,
+];
+
+/** @type {Config} */
+export const CONFIG = Object.freeze({
+  projectName: PROJECT_NAME,
+  fieldDefs: FIELD_DEFS,
+  labelDefs: [
+    {label: 'One'},
+    {label: 'EnUm'},
+    {label: 'eNuM-Options'},
+    {label: 'hello-world', docstring: 'hmmm'},
+    {label: 'hello-me', docstring: 'hmmm'},
+  ],
+});
+
+/** @type {string} */
+export const DEFAULT_QUERY = 'owner:me';
+
+/** @type {PresentationConfig} */
+export const PRESENTATION_CONFIG = Object.freeze({
+  projectThumbnailUrl: 'test.png',
+  defaultColSpec: 'ID+Summary+AllLabels',
+  defaultQuery: DEFAULT_QUERY,
+});
+
+/** @type {Array<string>} */
+export const CUSTOM_PERMISSIONS = ['google', 'security'];
+
+/** @type {{userRefs: Array<UserRef>, groupRefs: Array<UserRef>}} */
+export const VISIBLE_MEMBERS = Object.freeze({
+  userRefs: [USER_REF],
+  groupRefs: [],
+});
+
+/** @type {TemplateDef} */
+export const TEMPLATE_DEF = Object.freeze({
+  templateName: 'Template Name',
+});
+
+export const STATE = Object.freeze({projectV0: {
+  name: PROJECT_NAME,
+  configs: {[PROJECT_NAME]: CONFIG},
+  presentationConfigs: {[PROJECT_NAME]: PRESENTATION_CONFIG},
+  customPermissions: {[PROJECT_NAME]: CUSTOM_PERMISSIONS},
+  visibleMembers: {[PROJECT_NAME]: VISIBLE_MEMBERS},
+  templates: {[PROJECT_NAME]: TEMPLATE_DEF},
+}});
diff --git a/static_src/shared/test/constants-projects.js b/static_src/shared/test/constants-projects.js
new file mode 100644
index 0000000..c25f46b
--- /dev/null
+++ b/static_src/shared/test/constants-projects.js
@@ -0,0 +1,27 @@
+// 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 'shared/typedef.js';
+
+/** @type {ProjectName} */
+export const NAME = 'projects/chromium';
+
+/** @type {Project} */
+export const PROJECT = Object.freeze({
+  name: NAME,
+  displayName: 'Chromium',
+  summary: 'A great open source project.',
+  thumbnailUrl: 'chromium.png',
+});
+
+/** @type {ProjectName} */
+export const NAME_2 = 'projects/monorail';
+
+/** @type {Project} */
+export const PROJECT_2 = Object.freeze({
+  name: NAME_2,
+  displayName: 'mOnOrAiL',
+  summary: 'Best issue tracker.',
+  thumbnailUrl: 'dogtrain.gif',
+});
diff --git a/static_src/shared/test/constants-stars.js b/static_src/shared/test/constants-stars.js
new file mode 100644
index 0000000..42e7012
--- /dev/null
+++ b/static_src/shared/test/constants-stars.js
@@ -0,0 +1,18 @@
+// 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 'shared/typedef.js';
+
+export const PROJECT_STAR_NAME = 'users/1234/projectStars/monorail';
+export const PROJECT_STAR_NAME_2 = 'users/1234/projectStars/chromium';
+
+/** @type {ProjectStar} */
+export const PROJECT_STAR = Object.freeze({
+  name: PROJECT_STAR_NAME,
+});
+
+/** @type {ProjectStar} */
+export const PROJECT_STAR_2 = Object.freeze({
+  name: PROJECT_STAR_NAME_2,
+});
diff --git a/static_src/shared/test/constants-users.js b/static_src/shared/test/constants-users.js
new file mode 100644
index 0000000..0a9bbf8
--- /dev/null
+++ b/static_src/shared/test/constants-users.js
@@ -0,0 +1,43 @@
+// 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 'shared/typedef.js';
+
+export const NAME = 'users/1234';
+
+export const DISPLAY_NAME = 'example@example.com';
+
+export const ID = 1234;
+
+/** @type {UserRef} */
+export const USER_REF = Object.freeze({
+  userId: ID,
+  displayName: DISPLAY_NAME,
+});
+
+/** @type {User} */
+export const USER = Object.freeze({
+  name: NAME,
+  displayName: DISPLAY_NAME,
+});
+
+export const NAME_2 = 'users/5678';
+
+export const DISPLAY_NAME_2 = 'other_user@example.com';
+
+/** @type {User} */
+export const USER_2 = Object.freeze({
+  name: NAME_2,
+  displayName: DISPLAY_NAME_2,
+});
+
+/** @type {Object<string, User>} */
+export const BY_NAME = Object.freeze({[NAME]: USER, [NAME_2]: USER_2});
+
+/** @type {ProjectMember} */
+export const PROJECT_MEMBER = Object.freeze({
+  name: 'projects/proj/members/1234',
+  role: 'CONTRIBUTOR',
+});
+
diff --git a/static_src/shared/test/fakes.js b/static_src/shared/test/fakes.js
new file mode 100644
index 0000000..d506f6a
--- /dev/null
+++ b/static_src/shared/test/fakes.js
@@ -0,0 +1,12 @@
+// 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';
+
+export const clientLoggerFake = () => ({
+  logStart: sinon.stub(),
+  logEnd: sinon.stub(),
+  logPause: sinon.stub(),
+  started: sinon.stub().returns(true),
+});
diff --git a/static_src/shared/test/helpers.js b/static_src/shared/test/helpers.js
new file mode 100644
index 0000000..63a1e12
--- /dev/null
+++ b/static_src/shared/test/helpers.js
@@ -0,0 +1,57 @@
+// 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 axe from 'axe-core';
+import userEvent from '@testing-library/user-event';
+import {fireEvent} from '@testing-library/react';
+
+// TODO(seanmccullough): Move this into crdx/chopsui-npm if we decide this
+// is worth using in other projects.
+
+/**
+ * @param {HTMLElement} element The element to audit accessibility for.
+ */
+export async function auditA11y(element) {
+  // Performance tip: try restricting the analysis using
+  // https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#use-resulttypes
+  const options = {};
+
+  // Adjust this set to make tests more/less permissible.
+  const reportImpact = new Set(['critical', 'serious', 'moderate', 'minor']);
+  const results = await axe.run(element, options);
+
+  if (results.violations.length == 0) {
+    return;
+  }
+
+  const msgs = ['Accessibility violations:'];
+  results.violations.forEach((result) => {
+    if (reportImpact.has(result.impact)) {
+      msgs.push(`\n[${result.impact}] ${result.help}`);
+      for (const node of result.nodes) {
+        if (node.failureSummary) {
+          msgs.push(node.failureSummary);
+        }
+        msgs.push(node.html);
+      }
+      msgs.push('---');
+    }
+  });
+
+  throw new Error(msgs.join('\n'));
+}
+
+/**
+ * Types text into an input field and presses Enter.
+ * @param {HTMLInputElement} input The input field to enter text in.
+ * @param {string} value The text to enter in the input field.
+ */
+export function enterInput(input, value) {
+  userEvent.clear(input);
+
+  userEvent.type(input, value);
+
+  fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+}
+
diff --git a/static_src/shared/typedef.js b/static_src/shared/typedef.js
new file mode 100644
index 0000000..923e1db
--- /dev/null
+++ b/static_src/shared/typedef.js
@@ -0,0 +1,646 @@
+// 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.
+
+/**
+ * @fileoverview Shared file for specifying common types used in type
+ * annotations across Monorail.
+ */
+
+// TODO(zhangtiff): Find out if there's a way we can generate typedef's for
+// API object from .proto files.
+
+
+/**
+ * Types used in the app that don't come from any Proto files.
+ */
+
+/**
+ * A HotlistItem with the Issue flattened into the top-level,
+ * containing the intersection of the fields of HotlistItem and Issue.
+ *
+ * @typedef {Issue & HotlistItem} HotlistIssue
+ * @property {User=} adder
+ */
+
+/**
+ * A String containing the data necessary to identify an IssueRef. An IssueRef
+ * can reference either an issue in Monorail or an external issue in another
+ * tracker.
+ *
+ * Examples of valid IssueRefStrings:
+ * - monorail:1234
+ * - chromium:1
+ * - 1234
+ * - b/123456
+ *
+ * @typedef {string} IssueRefString
+ */
+
+/**
+ * An Object for specifying what to display in a single entry in the
+ * dropdown list.
+ *
+ * @typedef {Object} MenuItem
+ * @property {string=} text The text to display in the menu.
+ * @property {string=} icon A Material Design icon shown left of the text.
+ * @property {Array<MenuItem>=} items A specification for a nested submenu.
+ * @property {function=} handler An optional click handler for an item.
+ * @property {string=} url A link for the menu item to navigate to.
+ */
+
+/**
+ * An Object containing the metadata associated with tracking async requests
+ * through Redux.
+ *
+ * @typedef {Object} ReduxRequestState
+ * @property {boolean=} requesting Whether a request is in flight.
+ * @property {Error=} error An Error Object returned by the request.
+ */
+
+
+/**
+ * Resource names used in our resource-oriented API.
+ * @see https://aip.dev/122
+ */
+
+
+/**
+ * Resource name of an IssueStar.
+ *
+ * Examples of valid IssueStar resource names:
+ * - users/1234/issueStars/monorail.5556
+ * - users/1234/issueStars/test-project.4321
+ *
+ * @typedef {string} IssueStarName
+ */
+
+
+/**
+ * Resource name of a ProjectStar.
+ *
+ * Examples of valid ProjectStar resource names:
+ * - users/1234/projectStars/monorail
+ * - users/1234/projectStars/test-project
+ *
+ * @typedef {string} ProjectStarName
+ */
+
+
+/**
+ * Resource name of a Star.
+ *
+ * @typedef {ProjectStarName|IssueStarName} StarName
+ */
+
+
+/**
+ * Resource name of a Project.
+ *
+ * Examples of valid Project resource names:
+ * - projects/monorail
+ * - projects/test-project-1
+ *
+ * @typedef {string} ProjectName
+ */
+
+
+/**
+ * Resource name of a User.
+ *
+ * Examples of valid User resource names:
+ * - users/test@example.com
+ * - users/1234
+ *
+ * @typedef {string} UserName
+ */
+
+/**
+ * Resource name of a ProjectMember.
+ *
+ * Examples of valid ProjectMember resource names:
+ * - projects/monorail/members/1234
+ * - projects/test-xyz/members/5678
+ *
+ * @typedef {string} ProjectMemberName
+ */
+
+
+/**
+ * Types defined in common.proto.
+ */
+
+
+/**
+ * A ComponentRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} ComponentRef
+ * @property {string} path
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * An Enum representing the type that a custom field uses.
+ *
+ * @typedef {string} FieldType
+ */
+
+/**
+ * A FieldRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} FieldRef
+ * @property {number} fieldId
+ * @property {string} fieldName
+ * @property {FieldType} type
+ * @property {string=} approvalName
+ */
+
+/**
+ * A LabelRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} LabelRef
+ * @property {string} label
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * A StatusRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} StatusRef
+ * @property {string} status
+ * @property {boolean=} meansOpen
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * An IssueRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} IssueRef
+ * @property {string=} projectName
+ * @property {number=} localId
+ * @property {string=} extIdentifier
+ */
+
+/**
+ * A UserRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} UserRef
+ * @property {string=} displayName
+ * @property {number=} userId
+ */
+
+/**
+ * A HotlistRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} HotlistRef
+ * @property {string=} name
+ * @property {UserRef=} owner
+ */
+
+/**
+ * A SavedQuery Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} SavedQuery
+ * @property {number} queryId
+ * @property {string} name
+ * @property {string} query
+ * @property {Array<string>} projectNames
+ */
+
+
+/**
+ * Types defined in issue_objects.proto.
+ */
+
+/**
+ * An Approval Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Approval
+ * @property {FieldRef} fieldRef
+ * @property {Array<UserRef>} approverRefs
+ * @property {ApprovalStatus} status
+ * @property {number} setOn
+ * @property {UserRef} setterRef
+ * @property {PhaseRef} phaseRef
+ */
+
+/**
+ * An Enum representing the status of an Approval.
+ *
+ * @typedef {string} ApprovalStatus
+ */
+
+/**
+ * An Amendment Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Amendment
+ * @property {string} fieldName
+ * @property {string} newOrDeltaValue
+ * @property {string} oldValue
+ */
+
+/**
+ * An Attachment Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Attachment
+* @property {number} attachmentId
+* @property {string} filename
+* @property {number} size
+* @property {string} contentType
+* @property {boolean} isDeleted
+* @property {string} thumbnailUrl
+* @property {string} viewUrl
+* @property {string} downloadUrl
+*/
+
+/**
+ * A Comment Object returned by the pRPC API issue_objects.proto.
+ *
+ * Note: This Object is called "Comment" in the backend but is named
+ * "IssueComment" here to avoid a collision with an internal JSDoc Intellisense
+ * type.
+ *
+ * @typedef {Object} IssueComment
+ * @property {string} projectName
+ * @property {number} localId
+ * @property {number=} sequenceNum
+ * @property {boolean=} isDeleted
+ * @property {UserRef=} commenter
+ * @property {number=} timestamp
+ * @property {string=} content
+ * @property {string=} inboundMessage
+ * @property {Array<Amendment>=} amendments
+ * @property {Array<Attachment>=} attachments
+ * @property {FieldRef=} approvalRef
+ * @property {number=} descriptionNum
+ * @property {boolean=} isSpam
+ * @property {boolean=} canDelete
+ * @property {boolean=} canFlag
+ */
+
+/**
+ * A FieldValue Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} FieldValue
+ * @property {FieldRef} fieldRef
+ * @property {string} value
+ * @property {boolean=} isDerived
+ * @property {PhaseRef=} phaseRef
+ */
+
+/**
+ * An Issue Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Issue
+ * @property {string} projectName
+ * @property {number} localId
+ * @property {string=} summary
+ * @property {StatusRef=} statusRef
+ * @property {UserRef=} ownerRef
+ * @property {Array<UserRef>=} ccRefs
+ * @property {Array<LabelRef>=} labelRefs
+ * @property {Array<ComponentRef>=} componentRefs
+ * @property {Array<IssueRef>=} blockedOnIssueRefs
+ * @property {Array<IssueRef>=} blockingIssueRefs
+ * @property {Array<IssueRef>=} danglingBlockedOnRefs
+ * @property {Array<IssueRef>=} danglingBlockingRefs
+ * @property {IssueRef=} mergedIntoIssueRef
+ * @property {Array<FieldValue>=} fieldValues
+ * @property {boolean=} isDeleted
+ * @property {UserRef=} reporterRef
+ * @property {number=} openedTimestamp
+ * @property {number=} closedTimestamp
+ * @property {number=} modifiedTimestamp
+ * @property {number=} componentModifiedTimestamp
+ * @property {number=} statusModifiedTimestamp
+ * @property {number=} ownerModifiedTimestamp
+ * @property {number=} starCount
+ * @property {boolean=} isSpam
+ * @property {number=} attachmentCount
+ * @property {Array<Approval>=} approvalValues
+ * @property {Array<PhaseDef>=} phases
+ */
+
+/**
+ * A IssueDelta Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} IssueDelta
+ * @property {string=} status
+ * @property {UserRef=} ownerRef
+ * @property {Array<UserRef>=} ccRefsAdd
+ * @property {Array<UserRef>=} ccRefsRemove
+ * @property {Array<ComponentRef>=} compRefsAdd
+ * @property {Array<ComponentRef>=} compRefsRemove
+ * @property {Array<LabelRef>=} labelRefsAdd
+ * @property {Array<LabelRef>=} labelRefsRemove
+ * @property {Array<FieldValue>=} fieldValsAdd
+ * @property {Array<FieldValue>=} fieldValsRemove
+ * @property {Array<FieldRef>=} fieldsClear
+ * @property {Array<IssueRef>=} blockedOnRefsAdd
+ * @property {Array<IssueRef>=} blockedOnRefsRemove
+ * @property {Array<IssueRef>=} blockingRefsAdd
+ * @property {Array<IssueRef>=} blockingRefsRemove
+ * @property {IssueRef=} mergedIntoRef
+ * @property {string=} summary
+ */
+
+/**
+ * An PhaseDef Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} PhaseDef
+ * @property {PhaseRef} phaseRef
+ * @property {number} rank
+ */
+
+/**
+ * An PhaseRef Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} PhaseRef
+ * @property {string} phaseName
+ */
+
+/**
+ * An Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} IssuesListColumn
+ * @property {string} column
+ */
+
+
+/**
+ * Types defined in permission_objects.proto.
+ */
+
+/**
+ * A Permission string returned by the pRPC API permission_objects.proto.
+ *
+ * @typedef {string} Permission
+ */
+
+/**
+ * A PermissionSet Object returned by the pRPC API permission_objects.proto.
+ *
+ * @typedef {Object} PermissionSet
+ * @property {string} resource
+ * @property {Array<Permission>} permissions
+ */
+
+
+/**
+ * Types defined in project_objects.proto.
+ */
+
+/**
+ * An Enum representing the role a ProjectMember has.
+ *
+ * @typedef {string} ProjectRole
+ */
+
+/**
+ * An Enum representing how a ProjectMember shows up in autocomplete.
+ *
+ * @typedef {string} AutocompleteVisibility
+ */
+
+/**
+ * A ProjectMember Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ProjectMember
+ * @property {ProjectMemberName} name
+ * @property {ProjectRole} role
+ * @property {Array<Permission>=} standardPerms
+ * @property {Array<string>=} customPerms
+ * @property {string=} notes
+ * @property {AutocompleteVisibility=} includeInAutocomplete
+ */
+
+/**
+ * A Project Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} Project
+ * @property {string} name
+ * @property {string} summary
+ * @property {string=} description
+ */
+
+/**
+ * A Project Object returned by the v0 pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ProjectV0
+ * @property {string} name
+ * @property {string} summary
+ * @property {string=} description
+ */
+
+/**
+ * A StatusDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} StatusDef
+ * @property {string} status
+ * @property {boolean} meansOpen
+ * @property {number} rank
+ * @property {string} docstring
+ * @property {boolean} deprecated
+ */
+
+/**
+ * A LabelDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} LabelDef
+ * @property {string} label
+ * @property {string=} docstring
+ * @property {boolean=} deprecated
+ */
+
+/**
+ * A ComponentDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ComponentDef
+ * @property {string} path
+ * @property {string} docstring
+ * @property {Array<UserRef>} adminRefs
+ * @property {Array<UserRef>} ccRefs
+ * @property {boolean} deprecated
+ * @property {number} created
+ * @property {UserRef} creatorRef
+ * @property {number} modified
+ * @property {UserRef} modifierRef
+ * @property {Array<LabelRef>} labelRefs
+ */
+
+/**
+ * A FieldDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} FieldDef
+ * @property {FieldRef} fieldRef
+ * @property {string=} applicableType
+ * @property {boolean=} isRequired
+ * @property {boolean=} isNiche
+ * @property {boolean=} isMultivalued
+ * @property {string=} docstring
+ * @property {Array<UserRef>=} adminRefs
+ * @property {boolean=} isPhaseField
+ * @property {Array<UserRef>=} userChoices
+ * @property {Array<LabelDef>=} enumChoices
+ */
+
+/**
+ * A ApprovalDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ApprovalDef
+ * @property {FieldRef} fieldRef
+ * @property {Array<UserRef>} approverRefs
+ * @property {string} survey
+ */
+
+/**
+ * A Config Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} Config
+ * @property {string} projectName
+ * @property {Array<StatusDef>=} statusDefs
+ * @property {Array<StatusRef>=} statusesOfferMerge
+ * @property {Array<LabelDef>=} labelDefs
+ * @property {Array<string>=} exclusiveLabelPrefixes
+ * @property {Array<ComponentDef>=} componentDefs
+ * @property {Array<FieldDef>=} fieldDefs
+ * @property {Array<ApprovalDef>=} approvalDefs
+ * @property {boolean=} restrictToKnown
+ */
+
+
+/**
+ * A PresentationConfig Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} PresentationConfig
+ * @property {string=} projectThumbnailUrl
+ * @property {string=} projectSummary
+ * @property {string=} customIssueEntryUrl
+ * @property {string=} defaultQuery
+ * @property {Array<SavedQuery>=} savedQueries
+ * @property {string=} revisionUrlFormat
+ * @property {string=} defaultColSpec
+ * @property {string=} defaultSortSpec
+ * @property {string=} defaultXAttr
+ * @property {string=} defaultYAttr
+ */
+
+/**
+ * A TemplateDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} TemplateDef
+ * @property {string} templateName
+ * @property {string=} content
+ * @property {string=} summary
+ * @property {boolean=} summaryMustBeEdited
+ * @property {UserRef=} ownerRef
+ * @property {StatusRef=} statusRef
+ * @property {Array<LabelRef>=} labelRefs
+ * @property {boolean=} membersOnly
+ * @property {boolean=} ownerDefaultsToMember
+ * @property {Array<UserRef>=} adminRefs
+ * @property {Array<FieldValue>=} fieldValues
+ * @property {Array<ComponentRef>=} componentRefs
+ * @property {boolean=} componentRequired
+ * @property {Array<Approval>=} approvalValues
+ * @property {Array<PhaseDef>=} phases
+ */
+
+
+/**
+ * Types defined in features_objects.proto.
+ */
+
+/**
+ * A Hotlist Object returned by the pRPC API features_objects.proto.
+ *
+ * @typedef {Object} HotlistV0
+ * @property {UserRef=} ownerRef
+ * @property {string=} name
+ * @property {string=} summary
+ * @property {string=} description
+ * @property {string=} defaultColSpec
+ * @property {boolean=} isPrivate
+ */
+
+/**
+ * A Hotlist Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} Hotlist
+ * @property {string} name
+ * @property {string=} displayName
+ * @property {string=} owner
+ * @property {Array<string>=} editors
+ * @property {string=} summary
+ * @property {string=} description
+ * @property {Array<IssuesListColumn>=} defaultColumns
+ * @property {string=} hotlistPrivacy
+ */
+
+/**
+ * A HotlistItem Object returned by the pRPC API features_objects.proto.
+ *
+ * @typedef {Object} HotlistItemV0
+ * @property {Issue=} issue
+ * @property {number=} rank
+ * @property {UserRef=} adderRef
+ * @property {number=} addedTimestamp
+ * @property {string=} note
+ */
+
+/**
+ * A HotlistItem Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} HotlistItem
+ * @property {string=} name
+ * @property {string=} issue
+ * @property {number=} rank
+ * @property {string=} adder
+ * @property {string=} createTime
+ * @property {string=} note
+ */
+
+/**
+ * Types defined in user_objects.proto.
+ */
+
+/**
+ * A User Object returned by the pRPC API user_objects.proto.
+ *
+ * @typedef {Object} UserV0
+ * @property {string=} displayName
+ * @property {number=} userId
+ * @property {boolean=} isSiteAdmin
+ * @property {string=} availability
+ * @property {UserRef=} linkedParentRef
+ * @property {Array<UserRef>=} linkedChildRefs
+ */
+
+/**
+ * A User Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} User
+ * @property {string=} name
+ * @property {string=} displayName
+ * @property {string=} availabilityMessage
+ */
+
+/**
+ * A ProjectStar Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} ProjectStar
+ * @property {string=} name
+ */
+
+/**
+ * A IssueStar Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} IssueStar
+ * @property {string=} name
+ */
+
+/**
+ * Type alias for any Star object.
+ *
+ * @typedef {ProjectStar|IssueStar} Star
+ */
diff --git a/static_src/test/index.js b/static_src/test/index.js
new file mode 100644
index 0000000..e38c23a
--- /dev/null
+++ b/static_src/test/index.js
@@ -0,0 +1,18 @@
+// 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.
+
+/**
+ * @fileoverview Root file for running our frontend tests. Finds all files
+ * in the static_src folder that have the ".test.js" or ".test.ts" extension.
+ */
+
+import chai from 'chai';
+import chaiDom from 'chai-dom';
+import chaiString from 'chai-string';
+
+chai.use(chaiDom);
+chai.use(chaiString);
+
+const testsContext = require.context('../', true, /\.test\.(js|ts|tsx)$/);
+testsContext.keys().forEach(testsContext);
diff --git a/static_src/test/setup.js b/static_src/test/setup.js
new file mode 100644
index 0000000..7907cdd
--- /dev/null
+++ b/static_src/test/setup.js
@@ -0,0 +1,16 @@
+// 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.
+
+/**
+ * @fileoverview Test setup code that defines functionality meant to run
+ * before each test.
+ */
+
+import {resetState, store} from 'reducers/base.js';
+
+Mocha.beforeEach(() => {
+  // We reset the Redux state before each test run to prevent Redux
+  // state changes in previous tests from affecting results.
+  store.dispatch(resetState());
+});
\ No newline at end of file
diff --git a/static_src/webpacked-scripts-template.html b/static_src/webpacked-scripts-template.html
new file mode 100644
index 0000000..e90e478
--- /dev/null
+++ b/static_src/webpacked-scripts-template.html
@@ -0,0 +1,2 @@
+<!-- This is a webpack-generated ezt template for script tags. -->
+<!-- Do not edit or commit to repo. -->