Project import generated by Copybara.

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