| /** |
| * @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 |
| */ |
| |
| /** |
| * gr-diff-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. |
| */ |
| properties: { |
| 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, |
| }, |
| |
| 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 -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(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 = 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 = 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; |
| }); |
| }, |
| }); |
| })(); |