Project import generated by Copybara.

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