blob: 2828cf10f820d62c7540e527cd3d673e73cccf99 [file] [log] [blame]
/**
* @license
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
import '../../../styles/shared-styles.js';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
import '../gr-button/gr-button.js';
import '../gr-dialog/gr-dialog.js';
import '../gr-date-formatter/gr-date-formatter.js';
import '../gr-formatted-text/gr-formatted-text.js';
import '../gr-icons/gr-icons.js';
import '../gr-overlay/gr-overlay.js';
import '../gr-rest-api-interface/gr-rest-api-interface.js';
import '../gr-storage/gr-storage.js';
import '../gr-textarea/gr-textarea.js';
import '../gr-tooltip-content/gr-tooltip-content.js';
import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js';
import '../gr-account-label/gr-account-label.js';
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-comment_html.js';
import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
import {getRootElement} from '../../../scripts/rootElement.js';
import {appContext} from '../../../services/app-context.js';
const STORAGE_DEBOUNCE_INTERVAL = 400;
const TOAST_DEBOUNCE_INTERVAL = 200;
const SAVING_MESSAGE = 'Saving';
const DRAFT_SINGULAR = 'draft...';
const DRAFT_PLURAL = 'drafts...';
const SAVED_MESSAGE = 'All changes saved';
const UNSAVED_MESSAGE = 'Unable to save draft';
const REPORT_CREATE_DRAFT = 'CreateDraftComment';
const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
const FILE = 'FILE';
export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
/**
* All candidates tips to show, will pick randomly.
*/
const RESPECTFUL_REVIEW_TIPS= [
'Assume competence.',
'Provide rationale or context.',
'Consider how comments may be interpreted.',
'Avoid harsh language.',
'Make your comments specific and actionable.',
'When disagreeing, explain the advantage of your approach.',
];
/**
* @extends PolymerElement
*/
class GrComment extends KeyboardShortcutMixin(GestureEventListeners(
LegacyElementMixin(PolymerElement))) {
static get template() { return htmlTemplate; }
static get is() { return 'gr-comment'; }
/**
* Fired when the create fix comment action is triggered.
*
* @event create-fix-comment
*/
/**
* Fired when the show fix preview action is triggered.
*
* @event open-fix-preview
*/
/**
* Fired when this comment is discarded.
*
* @event comment-discard
*/
/**
* Fired when this comment is saved.
*
* @event comment-save
*/
/**
* Fired when this comment is updated.
*
* @event comment-update
*/
/**
* Fired when editing status changed.
*
* @event comment-editing-changed
*/
/**
* Fired when the comment's timestamp is tapped.
*
* @event comment-anchor-tap
*/
static get properties() {
return {
changeNum: String,
/** @type {!Gerrit.Comment} */
comment: {
type: Object,
notify: true,
observer: '_commentChanged',
},
comments: {
type: Array,
},
isRobotComment: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
disabled: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
draft: {
type: Boolean,
value: false,
observer: '_draftChanged',
},
editing: {
type: Boolean,
value: false,
observer: '_editingChanged',
},
discarding: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
hasChildren: Boolean,
patchNum: String,
showActions: Boolean,
_showHumanActions: Boolean,
_showRobotActions: Boolean,
collapsed: {
type: Boolean,
value: true,
reflectToAttribute: true,
observer: '_toggleCollapseClass',
},
/** @type {?} */
projectConfig: Object,
robotButtonDisabled: Boolean,
_hasHumanReply: Boolean,
_isAdmin: {
type: Boolean,
value: false,
},
_xhrPromise: Object, // Used for testing.
_messageText: {
type: String,
value: '',
observer: '_messageTextChanged',
},
commentSide: String,
side: String,
resolved: Boolean,
_numPendingDraftRequests: {
type: Object,
value:
{number: 0}, // Intentional to share the object across instances.
},
_enableOverlay: {
type: Boolean,
value: false,
},
/**
* Property for storing references to overlay elements. When the overlays
* are moved to getRootElement() to be shown they are no-longer
* children, so they can't be queried along the tree, so they are stored
* here.
*/
_overlays: {
type: Object,
value: () => { return {}; },
},
_showRespectfulTip: {
type: Boolean,
value: false,
},
showPatchset: {
type: Boolean,
value: true,
},
_respectfulReviewTip: String,
_respectfulTipDismissed: {
type: Boolean,
value: false,
},
_unableToSave: {
type: Boolean,
value: false,
},
_selfAccount: Object,
};
}
static get observers() {
return [
'_commentMessageChanged(comment.message)',
'_loadLocalDraft(changeNum, patchNum, comment)',
'_isRobotComment(comment)',
'_calculateActionstoShow(showActions, isRobotComment)',
'_computeHasHumanReply(comment, comments.*)',
'_onEditingChange(editing)',
];
}
get keyBindings() {
return {
'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
'esc': '_handleEsc',
};
}
constructor() {
super();
this.reporting = appContext.reportingService;
}
/** @override */
attached() {
super.attached();
this.$.restAPI.getAccount().then(account => {
this._selfAccount = account;
});
if (this.editing) {
this.collapsed = false;
} else if (this.comment) {
this.collapsed = this.comment.collapsed;
}
this._getIsAdmin().then(isAdmin => {
this._isAdmin = isAdmin;
});
}
/** @override */
detached() {
super.detached();
this.cancelDebouncer('fire-update');
if (this.textarea) {
this.textarea.closeDropdown();
}
}
_getAuthor(comment) {
return comment.author || this._selfAccount;
}
_onEditingChange(editing) {
this.dispatchEvent(new CustomEvent('comment-editing-changed', {
detail: !!editing,
bubbles: true,
composed: true,
}));
if (!editing) return;
// visibility based on cache this will make sure we only and always show
// a tip once every Math.max(a day, period between creating comments)
const cachedVisibilityOfRespectfulTip =
this.$.storage.getRespectfulTipVisibility();
if (!cachedVisibilityOfRespectfulTip) {
// we still want to show the tip with a probability of 30%
if (this.getRandomNum(0, 3) >= 1) return;
this._showRespectfulTip = true;
const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
this.reporting.reportInteraction(
'respectful-tip-appeared',
{tip: this._respectfulReviewTip}
);
// update cache
this.$.storage.setRespectfulTipVisibility();
}
}
/** Set as a separate method so easy to stub. */
getRandomNum(min, max) {
return Math.floor(Math.random() * (max - min) + min);
}
_computeVisibilityOfTip(showTip, tipDismissed) {
return showTip && !tipDismissed;
}
_dismissRespectfulTip() {
this._respectfulTipDismissed = true;
this.reporting.reportInteraction(
'respectful-tip-dismissed',
{tip: this._respectfulReviewTip}
);
// add a 14-day delay to the tip cache
this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
}
_onRespectfulReadMoreClick() {
this.reporting.reportInteraction('respectful-read-more-clicked');
}
get textarea() {
return this.shadowRoot.querySelector('#editTextarea');
}
get confirmDeleteOverlay() {
if (!this._overlays.confirmDelete) {
this._enableOverlay = true;
flush();
this._overlays.confirmDelete = this.shadowRoot
.querySelector('#confirmDeleteOverlay');
}
return this._overlays.confirmDelete;
}
get confirmDiscardOverlay() {
if (!this._overlays.confirmDiscard) {
this._enableOverlay = true;
flush();
this._overlays.confirmDiscard = this.shadowRoot
.querySelector('#confirmDiscardOverlay');
}
return this._overlays.confirmDiscard;
}
_computeShowHideIcon(collapsed) {
return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
}
_computeShowHideAriaLabel(collapsed) {
return collapsed ? 'Expand' : 'Collapse';
}
_calculateActionstoShow(showActions, isRobotComment) {
// Polymer 2: check for undefined
if ([showActions, isRobotComment].includes(undefined)) {
return;
}
this._showHumanActions = showActions && !isRobotComment;
this._showRobotActions = showActions && isRobotComment;
}
_isRobotComment(comment) {
this.isRobotComment = !!comment.robot_id;
}
isOnParent() {
return this.side === 'PARENT';
}
_getIsAdmin() {
return this.$.restAPI.getIsAdmin();
}
_computeDraftTooltip(unableToSave) {
return unableToSave ? `Unable to save draft. Please try to save again.` :
`This draft is only visible to you. To publish drafts, click the 'Reply'`
+ `or 'Start review' button at the top of the change or press the 'A' key.`;
}
_computeDraftText(unableToSave) {
return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
}
/**
* @param {*=} opt_comment
*/
save(opt_comment) {
let comment = opt_comment;
if (!comment) {
comment = this.comment;
}
this.set('comment.message', this._messageText);
this.editing = false;
this.disabled = true;
if (!this._messageText) {
return this._discardDraft();
}
this._xhrPromise = this._saveDraft(comment).then(response => {
this.disabled = false;
if (!response.ok) { return response; }
this._eraseDraftComment();
return this.$.restAPI.getResponseObject(response).then(obj => {
const resComment = obj;
resComment.__draft = true;
// Maintain the ephemeral draft ID for identification by other
// elements.
if (this.comment.__draftID) {
resComment.__draftID = this.comment.__draftID;
}
resComment.__commentSide = this.commentSide;
this.comment = resComment;
this._fireSave();
return obj;
});
})
.catch(err => {
this.disabled = false;
throw err;
});
return this._xhrPromise;
}
_eraseDraftComment() {
// Prevents a race condition in which removing the draft comment occurs
// prior to it being saved.
this.cancelDebouncer('store');
this.$.storage.eraseDraftComment({
changeNum: this.changeNum,
patchNum: this._getPatchNum(),
path: this.comment.path,
line: this.comment.line,
range: this.comment.range,
});
}
_commentChanged(comment) {
this.editing = !!comment.__editing;
this.resolved = !comment.unresolved;
if (this.editing) { // It's a new draft/reply, notify.
this._fireUpdate();
}
}
_computeHasHumanReply() {
if (!this.comment || !this.comments) return;
// hide please fix button for robot comment that has human reply
this._hasHumanReply = this.comments
.some(c => c.in_reply_to && c.in_reply_to === this.comment.id &&
!c.robot_id);
}
/**
* @param {!Object=} opt_mixin
*
* @return {!Object}
*/
_getEventPayload(opt_mixin) {
return {...opt_mixin, comment: this.comment,
patchNum: this.patchNum};
}
_fireSave() {
this.dispatchEvent(new CustomEvent('comment-save', {
detail: this._getEventPayload(),
composed: true, bubbles: true,
}));
}
_fireUpdate() {
this.debounce('fire-update', () => {
this.dispatchEvent(new CustomEvent('comment-update', {
detail: this._getEventPayload(),
composed: true, bubbles: true,
}));
});
}
_computeAccountLabelClass(draft) {
return draft ? 'draft' : '';
}
_draftChanged(draft) {
this.$.container.classList.toggle('draft', draft);
}
_editingChanged(editing, previousValue) {
// Polymer 2: observer fires when at least one property is defined.
// Do nothing to prevent comment.__editing being overwritten
// if previousValue is undefined
if (previousValue === undefined) return;
this.$.container.classList.toggle('editing', editing);
if (this.comment && this.comment.id) {
const cancelButton = this.shadowRoot.querySelector('.cancel');
if (cancelButton) {
cancelButton.hidden = !editing;
}
}
if (this.comment) {
this.comment.__editing = this.editing;
}
if (editing != !!previousValue) {
// To prevent event firing on comment creation.
this._fireUpdate();
}
if (editing) {
this.async(() => {
flush();
this.textarea && this.textarea.putCursorAtEnd();
}, 1);
}
}
_computeDeleteButtonClass(isAdmin, draft) {
return isAdmin && !draft ? 'showDeleteButtons' : '';
}
_computeSaveDisabled(draft, comment, resolved) {
// If resolved state has changed and a msg exists, save should be enabled.
if (!comment || comment.unresolved === resolved && draft) {
return false;
}
return !draft || draft.trim() === '';
}
_handleSaveKey(e) {
if (!this._computeSaveDisabled(this._messageText, this.comment,
this.resolved)) {
e.preventDefault();
this._handleSave(e);
}
}
_handleEsc(e) {
if (!this._messageText.length) {
e.preventDefault();
this._handleCancel(e);
}
}
_handleToggleCollapsed() {
this.collapsed = !this.collapsed;
}
_toggleCollapseClass(collapsed) {
if (collapsed) {
this.$.container.classList.add('collapsed');
} else {
this.$.container.classList.remove('collapsed');
}
}
_commentMessageChanged(message) {
this._messageText = message || '';
}
_messageTextChanged(newValue, oldValue) {
if (!this.comment || (this.comment && this.comment.id)) {
return;
}
this.debounce('store', () => {
const message = this._messageText;
const commentLocation = {
changeNum: this.changeNum,
patchNum: this._getPatchNum(),
path: this.comment.path,
line: this.comment.line,
range: this.comment.range,
};
if ((!this._messageText || !this._messageText.length) && oldValue) {
// If the draft has been modified to be empty, then erase the storage
// entry.
this.$.storage.eraseDraftComment(commentLocation);
} else {
this.$.storage.setDraftComment(commentLocation, message);
}
}, STORAGE_DEBOUNCE_INTERVAL);
}
_handleAnchorClick(e) {
e.preventDefault();
if (!this.comment.line) {
return;
}
this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
bubbles: true,
composed: true,
detail: {
number: this.comment.line || FILE,
side: this.side,
},
}));
}
_handleEdit(e) {
e.preventDefault();
this._messageText = this.comment.message;
this.editing = true;
this.reporting.recordDraftInteraction();
}
_handleSave(e) {
e.preventDefault();
// Ignore saves started while already saving.
if (this.disabled) {
return;
}
const timingLabel = this.comment.id ?
REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
const timer = this.reporting.getTimer(timingLabel);
this.set('comment.__editing', false);
return this.save().then(() => { timer.end(); });
}
_handleCancel(e) {
e.preventDefault();
if (!this.comment.message ||
this.comment.message.trim().length === 0 ||
!this.comment.id) {
this._fireDiscard();
return;
}
this._messageText = this.comment.message;
this.editing = false;
}
_fireDiscard() {
this.cancelDebouncer('fire-update');
this.dispatchEvent(new CustomEvent('comment-discard', {
detail: this._getEventPayload(),
composed: true, bubbles: true,
}));
}
_handleFix() {
this.dispatchEvent(new CustomEvent('create-fix-comment', {
bubbles: true,
composed: true,
detail: this._getEventPayload(),
}));
}
_handleShowFix() {
this.dispatchEvent(new CustomEvent('open-fix-preview', {
bubbles: true,
composed: true,
detail: this._getEventPayload(),
}));
}
_hasNoFix(comment) {
return !comment || !comment.fix_suggestions;
}
_handleDiscard(e) {
e.preventDefault();
this.reporting.recordDraftInteraction();
if (!this._messageText) {
this._discardDraft();
return;
}
this._openOverlay(this.confirmDiscardOverlay).then(() => {
this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
.resetFocus();
});
}
_handleConfirmDiscard(e) {
e.preventDefault();
const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
this._closeConfirmDiscardOverlay();
return this._discardDraft().then(() => { timer.end(); });
}
_discardDraft() {
if (!this.comment.__draft) {
throw Error('Cannot discard a non-draft comment.');
}
this.discarding = true;
this.editing = false;
this.disabled = true;
this._eraseDraftComment();
if (!this.comment.id) {
this.disabled = false;
this._fireDiscard();
return;
}
this._xhrPromise = this._deleteDraft(this.comment).then(response => {
this.disabled = false;
if (!response.ok) {
this.discarding = false;
return response;
}
this._fireDiscard();
})
.catch(err => {
this.disabled = false;
throw err;
});
return this._xhrPromise;
}
_closeConfirmDiscardOverlay() {
this._closeOverlay(this.confirmDiscardOverlay);
}
_getSavingMessage(numPending, requestFailed) {
if (requestFailed) {
return UNSAVED_MESSAGE;
}
if (numPending === 0) {
return SAVED_MESSAGE;
}
return [
SAVING_MESSAGE,
numPending,
numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
].join(' ');
}
_showStartRequest() {
const numPending = ++this._numPendingDraftRequests.number;
this._updateRequestToast(numPending);
}
_showEndRequest() {
const numPending = --this._numPendingDraftRequests.number;
this._updateRequestToast(numPending);
}
_handleFailedDraftRequest() {
this._numPendingDraftRequests.number--;
// Cancel the debouncer so that error toasts from the error-manager will
// not be overridden.
this.cancelDebouncer('draft-toast');
this._updateRequestToast(this._numPendingDraftRequests.number,
/* requestFailed=*/true);
}
_updateRequestToast(numPending, requestFailed) {
const message = this._getSavingMessage(numPending, requestFailed);
this.debounce('draft-toast', () => {
// Note: the event is fired on the body rather than this element because
// this element may not be attached by the time this executes, in which
// case the event would not bubble.
document.body.dispatchEvent(new CustomEvent(
'show-alert', {detail: {message}, bubbles: true, composed: true}));
}, TOAST_DEBOUNCE_INTERVAL);
}
_handleDraftFailure() {
this.$.container.classList.add('unableToSave');
this._unableToSave = true;
this._handleFailedDraftRequest();
}
_saveDraft(draft) {
this._showStartRequest();
return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
.then(result => {
if (result.ok) { // remove
this._unableToSave = false;
this.$.container.classList.remove('unableToSave');
this._showEndRequest();
} else {
this._handleDraftFailure();
}
return result;
})
.catch(err => {
this._handleDraftFailure();
throw (err);
});
}
_deleteDraft(draft) {
this._showStartRequest();
return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
draft).then(result => {
if (result.ok) {
this._showEndRequest();
} else {
this._handleFailedDraftRequest();
}
return result;
});
}
_getPatchNum() {
return this.isOnParent() ? 'PARENT' : this.patchNum;
}
_loadLocalDraft(changeNum, patchNum, comment) {
// Polymer 2: check for undefined
if ([changeNum, patchNum, comment].includes(undefined)) {
return;
}
// Only apply local drafts to comments that haven't been saved
// remotely, and haven't been given a default message already.
//
// Don't get local draft if there is another comment that is currently
// in an editing state.
if (!comment || comment.id || comment.message || comment.__otherEditing) {
delete comment.__otherEditing;
return;
}
const draft = this.$.storage.getDraftComment({
changeNum,
patchNum: this._getPatchNum(),
path: comment.path,
line: comment.line,
range: comment.range,
});
if (draft) {
this.set('comment.message', draft.message);
}
}
_handleToggleResolved() {
this.reporting.recordDraftInteraction();
this.resolved = !this.resolved;
// Modify payload instead of this.comment, as this.comment is passed from
// the parent by ref.
const payload = this._getEventPayload();
payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
this.dispatchEvent(new CustomEvent('comment-update', {
detail: payload,
composed: true, bubbles: true,
}));
if (!this.editing) {
// Save the resolved state immediately.
this.save(payload.comment);
}
}
_handleCommentDelete() {
this._openOverlay(this.confirmDeleteOverlay);
}
_handleCancelDeleteComment() {
this._closeOverlay(this.confirmDeleteOverlay);
}
_openOverlay(overlay) {
getRootElement().appendChild(overlay);
return overlay.open();
}
_computeHideRunDetails(comment, collapsed) {
if (!comment) return true;
return !(comment.robot_id && comment.url && !collapsed);
}
_closeOverlay(overlay) {
getRootElement().removeChild(overlay);
overlay.close();
}
_handleConfirmDeleteComment() {
const dialog =
this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
this.$.restAPI.deleteComment(
this.changeNum, this.patchNum, this.comment.id, dialog.message)
.then(newComment => {
this._handleCancelDeleteComment();
this.comment = newComment;
});
}
}
customElements.define(GrComment.is, GrComment);