/** * @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 UNRESOLVED_EXPAND_COUNT = 5; const NEWLINE_PATTERN = /\n/g; Polymer({ is: 'gr-diff-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 */ properties: { changeNum: String, comments: { type: Array, value() { return []; }, }, range: Object, keyEventTarget: { type: Object, value() { return document.body; }, }, commentSide: String, 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 */ lineNum: Number, unresolved: { type: Boolean, notify: true, reflectToAttribute: true, }, _showActions: Boolean, _lastComment: Object, _orderedComments: Array, _projectConfig: Object, }, behaviors: [ Gerrit.KeyboardShortcutBehavior, Gerrit.PathListBehavior, ], listeners: { 'comment-update': '_handleCommentUpdate', }, observers: [ '_commentsChanged(comments.*)', ], keyBindings: { 'e shift+e': '_handleEKey', }, 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})); }, _getDiffUrlForComment(projectName, changeNum, path, patchNum) { return Gerrit.Nav.getUrlForDiffById(changeNum, projectName, path, patchNum, null, this.lineNum); }, _computeDisplayPath(path) { const lineString = this.lineNum ? `#${this.lineNum}` : ''; return this.computeDisplayPath(path) + lineString; }, _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; } }, _hideActions(_showActions, _lastComment) { return !_showActions || !_lastComment || !!_lastComment.__draft; }, _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-diff-comment-thread elements). if (e.detail.keyboardEvent.shiftKey) { this._expandCollapseComments(true); } else { if (this.modifierPressed(e)) { return; } this._expandCollapseComments(false); } }, _expandCollapseComments(actionIsCollapse) { const comments = Polymer.dom(this.root).querySelectorAll('gr-diff-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; comment.collapsed = !isRobotComment && resolvedThread; } } }, _sortedComments(comments) { return comments.slice().sort((c1, c2) => { const c1Date = c1.__date || util.parseDate(c1.updated); const c2Date = c2.__date || util.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 0; } 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(parent, content, opt_isEditing, opt_unresolved) { this.$.reporting.recordDraftInteraction(); const reply = this._newReply( this._orderedComments[this._orderedComments.length - 1].id, parent.line, content, opt_unresolved, parent.range); // If there is currently a comment in an editing state, add an attribute // so that the gr-diff-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(comment, quoteStr, true, comment.unresolved); }, _handleCommentReply(e) { this._processCommentReply(); }, _handleCommentQuote(e) { this._processCommentReply(true); }, _handleCommentAck(e) { const comment = this._lastComment; this._createReplyComment(comment, 'Ack', false, false); }, _handleCommentDone(e) { const comment = this._lastComment; this._createReplyComment(comment, '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(comment, response, false, true); }, _commentElWithDraftID(id) { const els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment'); for (const el of els) { if (el.comment.id === id || el.comment.__draftID === id) { return el; } } return null; }, _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved, opt_range) { const d = this._newDraft(opt_lineNum); d.in_reply_to = inReplyTo; d.range = opt_range; 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(), path: this.path, patchNum: this.patchNum, side: this._getSide(this.isOnParent), __commentSide: this.commentSide, }; if (opt_lineNum) { d.line = opt_lineNum; } if (opt_range) { d.range = { start_line: opt_range.startLine, start_character: opt_range.startChar, end_line: opt_range.endLine, end_character: opt_range.endChar, }; } 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 = Polymer.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) { 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; }); }, }); })();