blob: 90d465f026063272201c5b5cf6d96f88d29cd3bf [file] [log] [blame]
/**
* @license
* Copyright (C) 2016 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.
*/
(function() {
'use strict';
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 REPORT_CREATE_DRAFT = 'CreateDraftComment';
const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
Polymer({
is: 'gr-diff-comment',
/**
* Fired when the create fix comment action is triggered.
*
* @event create-fix-comment
*/
/**
* 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
*/
/**
* @event comment-mouse-over
*/
/**
* @event comment-mouse-out
*/
properties: {
changeNum: String,
/** @type {?} */
comment: {
type: Object,
notify: true,
observer: '_commentChanged',
},
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,
observer: '_toggleCollapseClass',
},
/** @type {?} */
projectConfig: Object,
robotButtonDisabled: Boolean,
_isAdmin: {
type: Boolean,
value: false,
},
_xhrPromise: Object, // Used for testing.
_messageText: {
type: String,
value: '',
observer: '_messageTextChanged',
},
commentSide: 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 Gerrit.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: () => ({}),
},
},
observers: [
'_commentMessageChanged(comment.message)',
'_loadLocalDraft(changeNum, patchNum, comment)',
'_isRobotComment(comment)',
'_calculateActionstoShow(showActions, isRobotComment)',
],
behaviors: [
Gerrit.KeyboardShortcutBehavior,
],
keyBindings: {
'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
'esc': '_handleEsc',
},
attached() {
if (this.editing) {
this.collapsed = false;
} else if (this.comment) {
this.collapsed = this.comment.collapsed;
}
this._getIsAdmin().then(isAdmin => {
this._isAdmin = isAdmin;
});
},
detached() {
this.cancelDebouncer('fire-update');
if (this.textarea) {
this.textarea.closeDropdown();
}
},
get textarea() {
return this.$$('#editTextarea');
},
get confirmDeleteOverlay() {
if (!this._overlays.confirmDelete) {
this._enableOverlay = true;
Polymer.dom.flush();
this._overlays.confirmDelete = this.$$('#confirmDeleteOverlay');
}
return this._overlays.confirmDelete;
},
get confirmDiscardOverlay() {
if (!this._overlays.confirmDiscard) {
this._enableOverlay = true;
Polymer.dom.flush();
this._overlays.confirmDiscard = this.$$('#confirmDiscardOverlay');
}
return this._overlays.confirmDiscard;
},
_computeShowHideText(collapsed) {
return collapsed ? '◀' : '▼';
},
_calculateActionstoShow(showActions, isRobotComment) {
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();
},
/**
* @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();
}
},
/**
* @param {!Object=} opt_mixin
*
* @return {!Object}
*/
_getEventPayload(opt_mixin) {
return Object.assign({}, opt_mixin, {
comment: this.comment,
patchNum: this.patchNum,
});
},
_fireSave() {
this.fire('comment-save', this._getEventPayload());
},
_fireUpdate() {
this.debounce('fire-update', () => {
this.fire('comment-update', this._getEventPayload());
});
},
_draftChanged(draft) {
this.$.container.classList.toggle('draft', draft);
},
_editingChanged(editing, previousValue) {
this.$.container.classList.toggle('editing', editing);
if (this.comment && this.comment.id) {
this.$$('.cancel').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(() => {
Polymer.dom.flush();
this.textarea.putCursorAtEnd();
}, 1);
}
},
_computeLinkToComment(comment) {
return '#' + comment.line;
},
_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.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);
},
_handleLinkTap(e) {
e.preventDefault();
const hash = this._computeLinkToComment(this.comment);
// Don't add the hash to the window history if it's already there.
// Otherwise you mess up expected back button behavior.
if (window.location.hash == hash) { return; }
// Change the URL but don’t trigger a nav event. Otherwise it will
// reload the page.
page.show(window.location.pathname + hash, null, false);
},
_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.fire('comment-discard', this._getEventPayload());
},
_handleFix() {
this.dispatchEvent(new CustomEvent('create-fix-comment', {
bubbles: true,
detail: this._getEventPayload(),
}));
},
_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) {
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');
},
_updateRequestToast(numPending) {
const message = this._getSavingMessage(numPending);
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}));
}, TOAST_DEBOUNCE_INTERVAL);
},
_saveDraft(draft) {
this._showStartRequest();
return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
.then(result => {
if (result.ok) {
this._showEndRequest();
} else {
this._handleFailedDraftRequest();
}
return result;
});
},
_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) {
// 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);
}
},
_handleMouseEnter(e) {
this.fire('comment-mouse-over', this._getEventPayload());
},
_handleMouseLeave(e) {
this.fire('comment-mouse-out', this._getEventPayload());
},
_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.fire('comment-update', payload);
if (!this.editing) {
// Save the resolved state immediately.
this.save(payload.comment);
}
},
_handleCommentDelete() {
this._openOverlay(this.confirmDeleteOverlay);
},
_handleCancelDeleteComment() {
this._closeOverlay(this.confirmDeleteOverlay);
},
_openOverlay(overlay) {
Polymer.dom(Gerrit.getRootElement()).appendChild(overlay);
return overlay.open();
},
_closeOverlay(overlay) {
Polymer.dom(Gerrit.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;
});
},
});
})();