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);