Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js
new file mode 100644
index 0000000..69ef43f
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js
@@ -0,0 +1,360 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+import debounce from 'debounce';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as ui from 'reducers/ui.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+import './mr-edit-metadata.js';
+import 'shared/typedef.js';
+
+import ClientLogger from 'monitoring/client-logger.js';
+
+const DEBOUNCED_PRESUBMIT_TIME_OUT = 400;
+
+/**
+ * `<mr-edit-issue>`
+ *
+ * Edit form for a single issue. Wraps <mr-edit-metadata>.
+ *
+ */
+export class MrEditIssue extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ const issue = this.issue || {};
+ let blockedOnRefs = issue.blockedOnIssueRefs || [];
+ if (issue.danglingBlockedOnRefs && issue.danglingBlockedOnRefs.length) {
+ blockedOnRefs = blockedOnRefs.concat(issue.danglingBlockedOnRefs);
+ }
+
+ let blockingRefs = issue.blockingIssueRefs || [];
+ if (issue.danglingBlockingRefs && issue.danglingBlockingRefs.length) {
+ blockingRefs = blockingRefs.concat(issue.danglingBlockingRefs);
+ }
+
+ return html`
+ <h2 id="makechanges" class="medium-heading">
+ <a href="#makechanges">Add a comment and make changes</a>
+ </h2>
+ <mr-edit-metadata
+ formName="Issue Edit"
+ .ownerName=${this._ownerDisplayName(this.issue.ownerRef)}
+ .cc=${issue.ccRefs}
+ .status=${issue.statusRef && issue.statusRef.status}
+ .statuses=${this._availableStatuses(this.projectConfig.statusDefs, this.issue.statusRef)}
+ .summary=${issue.summary}
+ .components=${issue.componentRefs}
+ .fieldDefs=${this._fieldDefs}
+ .fieldValues=${issue.fieldValues}
+ .blockedOn=${blockedOnRefs}
+ .blocking=${blockingRefs}
+ .mergedInto=${issue.mergedIntoIssueRef}
+ .labelNames=${this._labelNames}
+ .derivedLabels=${this._derivedLabels}
+ .error=${this.updateError}
+ ?saving=${this.updatingIssue}
+ @save=${this.save}
+ @discard=${this.reset}
+ @change=${this._onChange}
+ ></mr-edit-metadata>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * All comments, including descriptions.
+ */
+ comments: {
+ type: Array,
+ },
+ /**
+ * The issue being updated.
+ */
+ issue: {
+ type: Object,
+ },
+ /**
+ * The issueRef for the currently viewed issue.
+ */
+ issueRef: {
+ type: Object,
+ },
+ /**
+ * The config of the currently viewed project.
+ */
+ projectConfig: {
+ type: Object,
+ },
+ /**
+ * Whether the issue is currently being updated.
+ */
+ updatingIssue: {
+ type: Boolean,
+ },
+ /**
+ * An error response, if one exists.
+ */
+ updateError: {
+ type: String,
+ },
+ /**
+ * Hash from the URL, used to support the 'r' hot key for making changes.
+ */
+ focusId: {
+ type: String,
+ },
+ _fieldDefs: {
+ type: Array,
+ },
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.clientLogger = new ClientLogger('issues');
+ this.updateError = '';
+
+ this.presubmitDebounceTimeOut = DEBOUNCED_PRESUBMIT_TIME_OUT;
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ // Prevent debounced logic from running after the component has been
+ // removed from the UI.
+ if (this._debouncedPresubmit) {
+ this._debouncedPresubmit.clear();
+ }
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.comments = issueV0.comments(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.updatingIssue = issueV0.requests(state).update.requesting;
+
+ const error = issueV0.requests(state).update.error;
+ this.updateError = error && (error.description || error.message);
+ this.focusId = ui.focusId(state);
+ this._fieldDefs = issueV0.fieldDefs(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (this.focusId && changedProperties.has('focusId')) {
+ // TODO(zhangtiff): Generalize logic to focus elements based on ID
+ // to a reuseable class mixin.
+ if (this.focusId.toLowerCase() === 'makechanges') {
+ this.focus();
+ }
+ }
+
+ if (changedProperties.has('updatingIssue')) {
+ const isUpdating = this.updatingIssue;
+ const wasUpdating = changedProperties.get('updatingIssue');
+
+ // When an issue finishes updating, we want to show a snackbar, record
+ // issue update time metrics, and reset the edit form.
+ if (!isUpdating && wasUpdating) {
+ if (!this.updateError) {
+ this._showCommentAddedSnackbar();
+ // Reset the edit form when a user's action finishes.
+ this.reset();
+ }
+
+ // Record metrics on when the issue editing event finished.
+ if (this.clientLogger.started('issue-update')) {
+ this.clientLogger.logEnd('issue-update', 'computer-time', 120 * 1000);
+ }
+ }
+ }
+ }
+
+ // TODO(crbug.com/monorail/6933): Remove the need for this wrapper.
+ /**
+ * Snows a snackbar telling the user they added a comment to the issue.
+ */
+ _showCommentAddedSnackbar() {
+ store.dispatch(ui.showSnackbar(ui.snackbarNames.ISSUE_COMMENT_ADDED,
+ 'Your comment was added.'));
+ }
+
+ /**
+ * Resets all form fields to their initial values.
+ */
+ reset() {
+ const form = this.querySelector('mr-edit-metadata');
+ if (!form) return;
+ form.reset();
+ }
+
+ /**
+ * Dispatches an action to save issue changes on the server.
+ */
+ async save() {
+ const form = this.querySelector('mr-edit-metadata');
+ if (!form) return;
+
+ const delta = form.delta;
+ if (!allowRemovedRestrictions(delta.labelRefsRemove)) {
+ return;
+ }
+
+ const message = {
+ issueRef: this.issueRef,
+ delta: delta,
+ commentContent: form.getCommentContent(),
+ sendEmail: form.sendEmail,
+ };
+
+ // Add files to message.
+ const uploads = await form.getAttachments();
+
+ if (uploads && uploads.length) {
+ message.uploads = uploads;
+ }
+
+ if (message.commentContent || message.delta || message.uploads) {
+ this.clientLogger.logStart('issue-update', 'computer-time');
+
+ store.dispatch(issueV0.update(message));
+ }
+ }
+
+ /**
+ * Focuses the edit form in response to the 'r' hotkey.
+ */
+ focus() {
+ const editHeader = this.querySelector('#makechanges');
+ editHeader.scrollIntoView();
+
+ const editForm = this.querySelector('mr-edit-metadata');
+ editForm.focus();
+ }
+
+ /**
+ * Turns all LabelRef Objects attached to an issue into an Array of strings
+ * containing only the names of those labels that aren't derived.
+ * @return {Array<string>} Array of label names.
+ */
+ get _labelNames() {
+ if (!this.issue || !this.issue.labelRefs) return [];
+ const labels = this.issue.labelRefs;
+ return labels.filter((l) => !l.isDerived).map((l) => l.label);
+ }
+
+ /**
+ * Finds only the derived labels attached to an issue and returns only
+ * their names.
+ * @return {Array<string>} Array of label names.
+ */
+ get _derivedLabels() {
+ if (!this.issue || !this.issue.labelRefs) return [];
+ const labels = this.issue.labelRefs;
+ return labels.filter((l) => l.isDerived).map((l) => l.label);
+ }
+
+ /**
+ * Gets the displayName of the owner. Only uses the displayName if a
+ * userId also exists in the ref.
+ * @param {UserRef} ownerRef The owner of the issue.
+ * @return {string} The name of the owner for the edited issue.
+ */
+ _ownerDisplayName(ownerRef) {
+ return (ownerRef && ownerRef.userId) ? ownerRef.displayName : '';
+ }
+
+ /**
+ * Dispatches an action against the server to run "issue presubmit", a feature
+ * that warns the user about issue changes that violate configured rules.
+ * @param {Object=} issueDelta Changes currently present in the edit form.
+ * @param {string} commentContent Text the user is inputting for a comment.
+ */
+ _presubmitIssue(issueDelta = {}, commentContent) {
+ // Don't run this functionality if the element has disconnected. Important
+ // for preventing debounced code from running after an element no longer
+ // exists.
+ if (!this.isConnected) return;
+
+ if (Object.keys(issueDelta).length || commentContent) {
+ // TODO(crbug.com/monorail/8638): Make filter rules actually process
+ // the text for comments on the backend.
+ store.dispatch(issueV0.presubmit(this.issueRef, issueDelta));
+ }
+ }
+
+ /**
+ * Form change handler that runs presubmit on the form.
+ * @param {CustomEvent} evt
+ */
+ _onChange(evt) {
+ const {delta, commentContent} = evt.detail || {};
+
+ if (!this._debouncedPresubmit) {
+ this._debouncedPresubmit = debounce(
+ (delta, commentContent) => this._presubmitIssue(delta, commentContent),
+ this.presubmitDebounceTimeOut);
+ }
+ this._debouncedPresubmit(delta, commentContent);
+ }
+
+ /**
+ * Creates the list of statuses that the user sees in the status dropdown.
+ * @param {Array<StatusDef>} statusDefsArg The project configured StatusDefs.
+ * @param {StatusRef} currentStatusRef The status that the issue currently
+ * uses. Note that Monorail supports free text statuses that do not exist in
+ * a project config. Because of this, currentStatusRef may not exist in
+ * statusDefsArg.
+ * @return {Array<StatusRef|StatusDef>} Array of statuses a user can edit this
+ * issue to have.
+ */
+ _availableStatuses(statusDefsArg, currentStatusRef) {
+ let statusDefs = statusDefsArg || [];
+ statusDefs = statusDefs.filter((status) => !status.deprecated);
+ if (!currentStatusRef || statusDefs.find(
+ (status) => status.status === currentStatusRef.status)) {
+ return statusDefs;
+ }
+ return [currentStatusRef, ...statusDefs];
+ }
+}
+
+/**
+ * Asks the user for confirmation when they try to remove retriction labels.
+ * eg. Restrict-View-Google.
+ * @param {Array<LabelRef>} labelRefsRemoved The labels a user is removing
+ * from this issue.
+ * @return {boolean} Whether removing these labels is okay. ie: true if there
+ * are either no restrictions being removed or if the user approved the
+ * removal of the restrictions.
+ */
+export function allowRemovedRestrictions(labelRefsRemoved) {
+ if (!labelRefsRemoved) return true;
+ const removedRestrictions = labelRefsRemoved
+ .map(({label}) => label)
+ .filter((label) => label.toLowerCase().startsWith('restrict-'));
+ const removeRestrictionsMessage =
+ 'You are removing these restrictions:\n' +
+ arrayToEnglish(removedRestrictions) + '\n' +
+ 'This might allow more people to access this issue. Are you sure?';
+ return !removedRestrictions.length || confirm(removeRestrictionsMessage);
+}
+
+customElements.define('mr-edit-issue', MrEditIssue);