blob: 248c7d5d549e9421330cf603d7e974cdf9fca58f [file] [log] [blame]
// 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 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 {migratedTypes} from 'shared/issue-fields.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);
}
let migratedNotice = html``;
if (this._isMigrated) {
migratedNotice = html`
<div class="migrated-banner">
<i
class="warning-icon material-icons"
icon="warning"
>warning</i>
${this._migratedLink}
</div>
<chops-button
class="legacy-edit"
@click=${this._allowLegacyEdits}
>
I want to edit the old version of this issue.
</chops-button>
`;
}
return html`
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
<style>
mr-edit-issue .migrated-banner {
width: 100%;
background-color: var(--chops-orange-50);
border: var(--chops-normal-border);
border-top: 0;
font-size: var(--chops-main-font-size);
padding: 0.25em 8px;
box-sizing: border-box;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
margin-bottom: 1em;
}
mr-edit-issue i.material-icons {
color: var(--chops-primary-icon-color);
font-size: var(--chops-icon-font-size);
}
mr-edit-issue .warning-icon {
margin-right: 4px;
}
mr-edit-issue .legacy-edit {
margin-bottom: 2em;
}
</style>
<h2 id="makechanges" class="medium-heading">
<a href="#makechanges">Add a comment and make changes</a>
</h2>
${migratedNotice}
<mr-edit-metadata
?hidden=${this._isMigrated && !this._editLegacyIssue}
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 {
/**
* ID of an Issue Tracker issue that the issue migrated to.
*/
migratedId: {
type: String,
},
/**
* Type of the issue migrated to.
*/
migratedType: {
type: migratedTypes,
},
/**
* 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,
},
_editLegacyIssue: {
type: Boolean,
},
};
}
/** @override */
constructor() {
super();
this.clientLogger = new ClientLogger('issues');
this.updateError = '';
this.presubmitDebounceTimeOut = DEBOUNCED_PRESUBMIT_TIME_OUT;
this._editLegacyIssue = false;
}
/** @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.migratedId = issueV0.migratedId(state);
this.migratedType = issueV0.migratedType(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);
}
/**
* @return {boolean} Whether this issue is migrated or not.
*/
get _isMigrated() {
return this.migratedId && this.migratedId !== '';
}
/**
* @return {string} the link of the issue in Issue Tracker or Launch.
*/
get _migratedLink() {
if (this.migratedType === migratedTypes.BUGANIZER_TYPE) {
const link =
html`<a href="https://issuetracker.google.com/issues/${this.migratedId}">b/${this.migratedId}</a>`;
return html`<p>This issue has moved to ${link}. Updates should be posted in ${link}.</p>`;
} else {
return html`<p>This issue has been migrated to Launch, see link in final comment below.</p>`;
}
}
/**
* Let the user override th edit form being hidden, in case of mistakes or
* similar.
*/
_allowLegacyEdits() {
this._editLegacyIssue = true;
}
/**
* 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);