Project import generated by Copybara.

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