Project import generated by Copybara.

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