 18f0354a33
			
		
	
	18f0354a33
	
	
	
		
			
			Report the time between create, update or discard flows are initiated on diff draft comments. Change-Id: I71b2f955bfa65dde57307d67fda45069bffa02c0
		
			
				
	
	
		
			466 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			466 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | ||
|  * @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;
 | ||
|       });
 | ||
|     },
 | ||
|   });
 | ||
| })();
 |