| /** |
| * @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'; |
| |
| const FILE = 'FILE'; |
| |
| Polymer({ |
| is: 'gr-comment', |
| _legacyUndefinedCheck: true, |
| |
| /** |
| * 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 |
| */ |
| |
| /** |
| * Fired when the comment's timestamp is tapped. |
| * |
| * @event comment-anchor-tap |
| */ |
| |
| 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); |
| } |
| }, |
| |
| _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); |
| }, |
| |
| _handleAnchorTap(e) { |
| e.preventDefault(); |
| if (!this.comment.line) { |
| return; |
| } |
| this.dispatchEvent(new CustomEvent('comment-anchor-tap', { |
| bubbles: true, |
| composed: true, |
| detail: { |
| number: this.comment.line || FILE, |
| side: this.side, |
| }, |
| })); |
| }, |
| |
| _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()); |
| }, |
| |
| _handleFix() { |
| this.dispatchEvent(new CustomEvent('create-fix-comment', { |
| bubbles: true, |
| composed: true, |
| detail: 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, composed: 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); |
| } |
| }, |
| |
| _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; |
| }); |
| }, |
| }); |
| })(); |