| /** | |
| * @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()); | |
| }, | |
| _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; | |
| }); | |
| }, | |
| }); | |
| })(); |