blob: eb680243bcbd439cd1773796dcdbf6697936b262 [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 '../../../styles/shared-styles.js';
import '../gr-rest-api-interface/gr-rest-api-interface.js';
import '../gr-storage/gr-storage.js';
import '../gr-comment/gr-comment.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.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-thread_html.js';
import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
import {parseDate} from '../../../utils/date-util.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {appContext} from '../../../services/app-context.js';
import {SpecialFilePath} from '../../../constants/constants.js';
const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;
/**
* @extends PolymerElement
*/
class GrCommentThread extends mixinBehaviors( [
/**
* Not used in this element rather other elements tests
*/
KeyboardShortcutBehavior,
PathListBehavior,
], GestureEventListeners(
LegacyElementMixin(
PolymerElement))) {
static get template() { return htmlTemplate; }
static get is() { return 'gr-comment-thread'; }
/**
* Fired when the thread should be discarded.
*
* @event thread-discard
*/
/**
* Fired when a comment in the thread is permanently modified.
*
* @event thread-changed
*/
/**
* gr-comment-thread exposes the following attributes that allow a
* diff widget like gr-diff to show the thread in the right location:
*
* line-num:
* 1-based line number or undefined if it refers to the entire file.
*
* comment-side:
* "left" or "right". These indicate which of the two diffed versions
* the comment relates to. In the case of unified diff, the left
* version is the one whose line number column is further to the left.
*
* range:
* The range of text that the comment refers to (start_line,
* start_character, end_line, end_character), serialized as JSON. If
* set, range's end_line will have the same value as line-num. Line
* numbers are 1-based, char numbers are 0-based. The start position
* (start_line, start_character) is inclusive, and the end position
* (end_line, end_character) is exclusive.
*/
static get properties() {
return {
changeNum: String,
comments: {
type: Array,
value() { return []; },
},
/**
* @type {?{start_line: number, start_character: number, end_line: number,
* end_character: number}}
*/
range: {
type: Object,
reflectToAttribute: true,
},
keyEventTarget: {
type: Object,
value() { return document.body; },
},
commentSide: {
type: String,
reflectToAttribute: true,
},
patchNum: String,
path: String,
projectName: {
type: String,
observer: '_projectNameChanged',
},
hasDraft: {
type: Boolean,
notify: true,
reflectToAttribute: true,
},
isOnParent: {
type: Boolean,
value: false,
},
parentIndex: {
type: Number,
value: null,
},
rootId: {
type: String,
notify: true,
computed: '_computeRootId(comments.*)',
},
/**
* If this is true, the comment thread also needs to have the change and
* line properties property set
*/
showFilePath: {
type: Boolean,
value: false,
},
/** Necessary only if showFilePath is true or when used with gr-diff */
lineNum: {
type: Number,
reflectToAttribute: true,
},
unresolved: {
type: Boolean,
notify: true,
reflectToAttribute: true,
},
_showActions: Boolean,
_lastComment: Object,
_orderedComments: Array,
_projectConfig: Object,
isRobotComment: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
showFileName: {
type: Boolean,
value: true,
},
showPatchset: {
type: Boolean,
value: true,
},
};
}
static get observers() {
return [
'_commentsChanged(comments.*)',
];
}
get keyBindings() {
return {
'e shift+e': '_handleEKey',
};
}
constructor() {
super();
this.reporting = appContext.reportingService;
}
/** @override */
created() {
super.created();
this.addEventListener('comment-update',
e => this._handleCommentUpdate(e));
}
/** @override */
attached() {
super.attached();
this._getLoggedIn().then(loggedIn => {
this._showActions = loggedIn;
});
this._setInitialExpandedState();
}
addOrEditDraft(opt_lineNum, opt_range) {
const lastComment = this.comments[this.comments.length - 1] || {};
if (lastComment.__draft) {
const commentEl = this._commentElWithDraftID(
lastComment.id || lastComment.__draftID);
commentEl.editing = true;
// If the comment was collapsed, re-open it to make it clear which
// actions are available.
commentEl.collapsed = false;
} else {
const range = opt_range ? opt_range :
lastComment ? lastComment.range : undefined;
const unresolved = lastComment ? lastComment.unresolved : undefined;
this.addDraft(opt_lineNum, range, unresolved);
}
}
addDraft(opt_lineNum, opt_range, opt_unresolved) {
const draft = this._newDraft(opt_lineNum, opt_range);
draft.__editing = true;
draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
this.push('comments', draft);
}
fireRemoveSelf() {
this.dispatchEvent(new CustomEvent('thread-discard',
{detail: {rootId: this.rootId}, bubbles: false}));
}
_getDiffUrlForPath(path) {
return GerritNav.getUrlForDiffById(this.changeNum, this.projectName, path,
this.patchNum);
}
_getDiffUrlForComment(projectName, changeNum, path, patchNum) {
return GerritNav.getUrlForDiffById(changeNum,
projectName, path, patchNum,
null, this.lineNum);
}
_isPatchsetLevelComment(path) {
return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
}
_computeDisplayPath(path) {
const displayPath = this.computeDisplayPath(path);
if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return `Patchset`;
}
return displayPath;
}
_computeDisplayLine() {
if (this.lineNum) return `#${this.lineNum}`;
// If range is set, then lineNum equals the end line of the range.
if (!this.lineNum && !this.range) {
if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return '';
}
return 'FILE';
}
if (this.range) return `#${this.range.end_line}`;
return '';
}
_getLoggedIn() {
return this.$.restAPI.getLoggedIn();
}
_commentsChanged() {
this._orderedComments = this._sortedComments(this.comments);
this.updateThreadProperties();
}
updateThreadProperties() {
if (this._orderedComments.length) {
this._lastComment = this._getLastComment();
this.unresolved = this._lastComment.unresolved;
this.hasDraft = this._lastComment.__draft;
this.isRobotComment = !!(this._lastComment.robot_id);
}
}
_shouldDisableAction(_showActions, _lastComment) {
return !_showActions || !_lastComment || !!_lastComment.__draft;
}
_hideActions(_showActions, _lastComment) {
return this._shouldDisableAction(_showActions, _lastComment) ||
!!_lastComment.robot_id;
}
_getLastComment() {
return this._orderedComments[this._orderedComments.length - 1] || {};
}
_handleEKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
// Don’t preventDefault in this case because it will render the event
// useless for other handlers (other gr-comment-thread elements).
if (e.detail.keyboardEvent.shiftKey) {
this._expandCollapseComments(true);
} else {
if (this.modifierPressed(e)) { return; }
this._expandCollapseComments(false);
}
}
_expandCollapseComments(actionIsCollapse) {
const comments =
dom(this.root).querySelectorAll('gr-comment');
for (const comment of comments) {
comment.collapsed = actionIsCollapse;
}
}
/**
* Sets the initial state of the comment thread.
* Expands the thread if one of the following is true:
* - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
* thread is unresolved,
* - it's a robot comment.
*/
_setInitialExpandedState() {
if (this._orderedComments) {
for (let i = 0; i < this._orderedComments.length; i++) {
const comment = this._orderedComments[i];
const isRobotComment = !!comment.robot_id;
// False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
const resolvedThread = !this.unresolved ||
this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
if (comment.collapsed === undefined) {
comment.collapsed = !isRobotComment && resolvedThread;
}
}
}
}
_sortedComments(comments) {
return comments.slice().sort((c1, c2) => {
const c1Date = c1.__date || parseDate(c1.updated);
const c2Date = c2.__date || parseDate(c2.updated);
const dateCompare = c1Date - c2Date;
// Ensure drafts are at the end. There should only be one but in edge
// cases could be more. In the unlikely event two drafts are being
// compared, use the typical date compare.
if (c2.__draft && !c1.__draft ) { return -1; }
if (c1.__draft && !c2.__draft ) { return 1; }
if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
// If same date, fall back to sorting by id.
return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
});
}
_createReplyComment(content, opt_isEditing,
opt_unresolved) {
this.reporting.recordDraftInteraction();
const reply = this._newReply(
this._orderedComments[this._orderedComments.length - 1].id,
content,
opt_unresolved);
// If there is currently a comment in an editing state, add an attribute
// so that the gr-comment knows not to populate the draft text.
for (let i = 0; i < this.comments.length; i++) {
if (this.comments[i].__editing) {
reply.__otherEditing = true;
break;
}
}
if (opt_isEditing) {
reply.__editing = true;
}
this.push('comments', reply);
if (!opt_isEditing) {
// Allow the reply to render in the dom-repeat.
this.async(() => {
const commentEl = this._commentElWithDraftID(reply.__draftID);
commentEl.save();
}, 1);
}
}
_isDraft(comment) {
return !!comment.__draft;
}
/**
* @param {boolean=} opt_quote
*/
_processCommentReply(opt_quote) {
const comment = this._lastComment;
let quoteStr;
if (opt_quote) {
const msg = comment.message;
quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
}
this._createReplyComment(quoteStr, true, comment.unresolved);
}
_handleCommentReply() {
this._processCommentReply();
}
_handleCommentQuote() {
this._processCommentReply(true);
}
_handleCommentAck() {
this._createReplyComment('Ack', false, false);
}
_handleCommentDone() {
this._createReplyComment('Done', false, false);
}
_handleCommentFix(e) {
const comment = e.detail.comment;
const msg = comment.message;
const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
const response = quoteStr + 'Please fix.';
this._createReplyComment(response, false, true);
}
_commentElWithDraftID(id) {
const els = dom(this.root).querySelectorAll('gr-comment');
for (const el of els) {
if (el.comment.id === id || el.comment.__draftID === id) {
return el;
}
}
return null;
}
_newReply(inReplyTo, opt_message, opt_unresolved) {
const d = this._newDraft();
d.in_reply_to = inReplyTo;
if (opt_message != null) {
d.message = opt_message;
}
if (opt_unresolved !== undefined) {
d.unresolved = opt_unresolved;
}
return d;
}
/**
* @param {number=} opt_lineNum
* @param {!Object=} opt_range
*/
_newDraft(opt_lineNum, opt_range) {
const d = {
__draft: true,
__draftID: Math.random().toString(36),
__date: new Date(),
};
// For replies, always use same meta info as root.
if (this.comments && this.comments.length >= 1) {
const rootComment = this.comments[0];
[
'path',
'patchNum',
'side',
'__commentSide',
'line',
'range',
'parent',
].forEach(key => {
if (rootComment.hasOwnProperty(key)) {
d[key] = rootComment[key];
}
});
} else {
// Set meta info for root comment.
d.path = this.path;
d.patchNum = this.patchNum;
d.side = this._getSide(this.isOnParent);
d.__commentSide = this.commentSide;
if (opt_lineNum) {
d.line = opt_lineNum;
}
if (opt_range) {
d.range = opt_range;
}
if (this.parentIndex) {
d.parent = this.parentIndex;
}
}
return d;
}
_getSide(isOnParent) {
if (isOnParent) { return 'PARENT'; }
return 'REVISION';
}
_computeRootId(comments) {
// Keep the root ID even if the comment was removed, so that notification
// to sync will know which thread to remove.
if (!comments.base.length) { return this.rootId; }
const rootComment = comments.base[0];
return rootComment.id || rootComment.__draftID;
}
_handleCommentDiscard(e) {
const diffCommentEl = dom(e).rootTarget;
const comment = diffCommentEl.comment;
const idx = this._indexOf(comment, this.comments);
if (idx == -1) {
throw Error('Cannot find comment ' +
JSON.stringify(diffCommentEl.comment));
}
this.splice('comments', idx, 1);
if (this.comments.length === 0) {
this.fireRemoveSelf();
}
this._handleCommentSavedOrDiscarded(e);
// Check to see if there are any other open comments getting edited and
// set the local storage value to its message value.
for (const changeComment of this.comments) {
if (changeComment.__editing) {
const commentLocation = {
changeNum: this.changeNum,
patchNum: this.patchNum,
path: changeComment.path,
line: changeComment.line,
};
return this.$.storage.setDraftComment(commentLocation,
changeComment.message);
}
}
}
_handleCommentSavedOrDiscarded(e) {
this.dispatchEvent(new CustomEvent('thread-changed',
{detail: {rootId: this.rootId, path: this.path},
bubbles: false}));
}
_handleCommentUpdate(e) {
const comment = e.detail.comment;
const index = this._indexOf(comment, this.comments);
if (index === -1) {
// This should never happen: comment belongs to another thread.
console.warn('Comment update for another comment thread.');
return;
}
this.set(['comments', index], comment);
// Because of the way we pass these comment objects around by-ref, in
// combination with the fact that Polymer does dirty checking in
// observers, the this.set() call above will not cause a thread update in
// some situations.
this.updateThreadProperties();
}
_indexOf(comment, arr) {
for (let i = 0; i < arr.length; i++) {
const c = arr[i];
if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
(c.id != null && c.id == comment.id)) {
return i;
}
}
return -1;
}
_computeHostClass(unresolved) {
if (this.isRobotComment) {
return 'robotComment';
}
return unresolved ? 'unresolved' : '';
}
/**
* Load the project config when a project name has been provided.
*
* @param {string} name The project name.
*/
_projectNameChanged(name) {
if (!name) { return; }
this.$.restAPI.getProjectConfig(name).then(config => {
this._projectConfig = config;
});
}
}
customElements.define(GrCommentThread.is, GrCommentThread);