| // 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'; |
| |
| var STORAGE_DEBOUNCE_INTERVAL_MS = 400; |
| |
| var FocusTarget = { |
| ANY: 'any', |
| BODY: 'body', |
| CCS: 'cc', |
| REVIEWERS: 'reviewers', |
| }; |
| |
| var ReviewerTypes = { |
| REVIEWER: 'REVIEWER', |
| CC: 'CC', |
| }; |
| |
| Polymer({ |
| is: 'gr-reply-dialog', |
| |
| /** |
| * Fired when a reply is successfully sent. |
| * |
| * @event send |
| */ |
| |
| /** |
| * Fired when the user presses the cancel button. |
| * |
| * @event cancel |
| */ |
| |
| /** |
| * Fired when the main textarea's value changes, which may have triggered |
| * a change in size for the dialog. |
| * |
| * @event autogrow |
| */ |
| |
| properties: { |
| change: Object, |
| patchNum: String, |
| disabled: { |
| type: Boolean, |
| value: false, |
| reflectToAttribute: true, |
| }, |
| draft: { |
| type: String, |
| value: '', |
| observer: '_draftChanged', |
| }, |
| quote: { |
| type: String, |
| value: '', |
| }, |
| diffDrafts: Object, |
| filterReviewerSuggestion: { |
| type: Function, |
| value: function() { |
| return this._filterReviewerSuggestion.bind(this); |
| }, |
| }, |
| permittedLabels: Object, |
| serverConfig: Object, |
| projectConfig: Object, |
| |
| _account: Object, |
| _ccs: Array, |
| _ccPendingConfirmation: { |
| type: Object, |
| observer: '_reviewerPendingConfirmationUpdated', |
| }, |
| _labels: { |
| type: Array, |
| computed: '_computeLabels(change.labels.*, _account)', |
| }, |
| _owner: Object, |
| _pendingConfirmationDetails: Object, |
| _includeComments: { |
| type: Boolean, |
| value: true, |
| }, |
| _reviewers: Array, |
| _reviewerPendingConfirmation: { |
| type: Object, |
| observer: '_reviewerPendingConfirmationUpdated', |
| }, |
| _previewFormatting: { |
| type: Boolean, |
| value: false, |
| observer: '_handleHeightChanged', |
| }, |
| _reviewersPendingRemove: { |
| type: Object, |
| value: { |
| CC: [], |
| REVIEWER: [], |
| }, |
| }, |
| }, |
| |
| FocusTarget: FocusTarget, |
| |
| behaviors: [ |
| Gerrit.KeyboardShortcutBehavior, |
| Gerrit.RESTClientBehavior, |
| ], |
| |
| keyBindings: { |
| 'esc': '_handleEscKey', |
| }, |
| |
| observers: [ |
| '_changeUpdated(change.reviewers.*, change.owner, serverConfig)', |
| '_ccsChanged(_ccs.splices)', |
| '_reviewersChanged(_reviewers.splices)', |
| ], |
| |
| attached: function() { |
| this._getAccount().then(function(account) { |
| this._account = account || {}; |
| }.bind(this)); |
| }, |
| |
| ready: function() { |
| this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this); |
| }, |
| |
| open: function(opt_focusTarget) { |
| this._focusOn(opt_focusTarget); |
| if (!this.draft || !this.draft.length) { |
| this.draft = this._loadStoredDraft(); |
| } |
| }, |
| |
| focus: function() { |
| this._focusOn(FocusTarget.ANY); |
| }, |
| |
| getFocusStops: function() { |
| return { |
| start: this.$.reviewers.focusStart, |
| end: this.$.cancelButton, |
| }; |
| }, |
| |
| setLabelValue: function(label, value) { |
| var selectorEl = this.$$('iron-selector[data-label="' + label + '"]'); |
| // The selector may not be present if it’s not at the latest patch set. |
| if (!selectorEl) { return; } |
| var item = selectorEl.$$('gr-button[data-value="' + value + '"]'); |
| if (!item) { return; } |
| selectorEl.selectIndex(selectorEl.indexOf(item)); |
| }, |
| |
| _handleEscKey: function(e) { |
| this.cancel(); |
| }, |
| |
| _ccsChanged: function(splices) { |
| if (splices && splices.indexSplices) { |
| this._processReviewerChange(splices.indexSplices, ReviewerTypes.CC); |
| } |
| }, |
| |
| _reviewersChanged: function(splices) { |
| if (splices && splices.indexSplices) { |
| this._processReviewerChange(splices.indexSplices, |
| ReviewerTypes.REVIEWER); |
| } |
| }, |
| |
| _processReviewerChange: function(indexSplices, type) { |
| indexSplices.forEach(function(splice) { |
| splice.removed.forEach(function(account) { |
| if (!this._reviewersPendingRemove[type]) { |
| console.err('Invalid type ' + type + ' for reviewer.'); |
| return; |
| } |
| this._reviewersPendingRemove[type].push(account); |
| }.bind(this)); |
| }.bind(this)); |
| }, |
| |
| /** |
| * Resets the state of the _reviewersPendingRemove object, and removes |
| * accounts if necessary. |
| * |
| * @param {Boolean} isCancel true if the action is a cancel. |
| * @param {Object} opt_accountIdsTransferred map of account IDs that must |
| * not be removed, because they have been readded in another state. |
| */ |
| _purgeReviewersPendingRemove: function(isCancel, |
| opt_accountIdsTransferred) { |
| var reviewerArr; |
| var keep = opt_accountIdsTransferred || {}; |
| for (var type in this._reviewersPendingRemove) { |
| if (this._reviewersPendingRemove.hasOwnProperty(type)) { |
| if (!isCancel) { |
| reviewerArr = this._reviewersPendingRemove[type]; |
| for (var i = 0; i < reviewerArr.length; i++) { |
| if (!keep[reviewerArr[i]._account_id]) { |
| this._removeAccount(reviewerArr[i], type); |
| } |
| } |
| } |
| this._reviewersPendingRemove[type] = []; |
| } |
| } |
| }, |
| |
| /** |
| * Removes an account from the change, both on the backend and the client. |
| * Does nothing if the account is a pending addition. |
| * |
| * @param {Object} account |
| * @param {ReviewerTypes} type |
| */ |
| _removeAccount: function(account, type) { |
| if (account._pendingAdd) { return; } |
| |
| return this.$.restAPI.removeChangeReviewer(this.change._number, |
| account._account_id).then(function(response) { |
| if (!response.ok) { return response; } |
| |
| var reviewers = this.change.reviewers[type] || []; |
| for (var i = 0; i < reviewers.length; i++) { |
| if (reviewers[i]._account_id == account._account_id) { |
| this.splice(['change', 'reviewers', type], i, 1); |
| break; |
| } |
| } |
| }.bind(this)); |
| }, |
| |
| _mapReviewer: function(reviewer) { |
| var reviewerId; |
| var confirmed; |
| if (reviewer.account) { |
| reviewerId = reviewer.account._account_id || reviewer.account.email; |
| } else if (reviewer.group) { |
| reviewerId = reviewer.group.id; |
| confirmed = reviewer.group.confirmed; |
| } |
| return {reviewer: reviewerId, confirmed: confirmed}; |
| }, |
| |
| send: function(includeComments) { |
| var obj = { |
| drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP', |
| labels: {}, |
| }; |
| |
| for (var label in this.permittedLabels) { |
| if (!this.permittedLabels.hasOwnProperty(label)) { continue; } |
| |
| var selectorEl = this.$$('iron-selector[data-label="' + label + '"]'); |
| |
| // The user may have not voted on this label. |
| if (!selectorEl || !selectorEl.selectedItem) { continue; } |
| |
| var selectedVal = selectorEl.selectedItem.getAttribute('data-value'); |
| selectedVal = parseInt(selectedVal, 10); |
| |
| // Only send the selection if the user changed it. |
| var prevVal = this._getVoteForAccount(this.change.labels, label, |
| this._account); |
| if (prevVal !== null) { |
| prevVal = parseInt(prevVal, 10); |
| } |
| if (selectedVal !== prevVal) { |
| obj.labels[label] = selectedVal; |
| } |
| } |
| if (this.draft != null) { |
| obj.message = this.draft; |
| } |
| |
| var accountAdditions = {}; |
| obj.reviewers = this.$.reviewers.additions().map(function(reviewer) { |
| if (reviewer.account) { |
| accountAdditions[reviewer.account._account_id] = true; |
| } |
| return this._mapReviewer(reviewer); |
| }.bind(this)); |
| var ccsEl = this.$$('#ccs'); |
| if (ccsEl) { |
| ccsEl.additions().forEach(function(reviewer) { |
| if (reviewer.account) { |
| accountAdditions[reviewer.account._account_id] = true; |
| } |
| reviewer = this._mapReviewer(reviewer); |
| reviewer.state = 'CC'; |
| obj.reviewers.push(reviewer); |
| }.bind(this)); |
| } |
| |
| this.disabled = true; |
| |
| var errFn = this._handle400Error.bind(this); |
| return this._saveReview(obj, errFn).then(function(response) { |
| if (!response || !response.ok) { |
| return response; |
| } |
| this.disabled = false; |
| this.draft = ''; |
| this._includeComments = true; |
| this.fire('send', null, {bubbles: false}); |
| return accountAdditions; |
| }.bind(this)).catch(function(err) { |
| this.disabled = false; |
| throw err; |
| }.bind(this)); |
| }, |
| |
| _focusOn: function(section) { |
| if (section === FocusTarget.ANY) { |
| section = this._chooseFocusTarget(); |
| } |
| if (section === FocusTarget.BODY) { |
| var textarea = this.$.textarea; |
| textarea.async(textarea.textarea.focus.bind(textarea.textarea)); |
| } else if (section === FocusTarget.REVIEWERS) { |
| var reviewerEntry = this.$.reviewers.focusStart; |
| reviewerEntry.async(reviewerEntry.focus); |
| } else if (section === FocusTarget.CCS) { |
| var ccEntry = this.$$('#ccs').focusStart; |
| ccEntry.async(ccEntry.focus); |
| } |
| }, |
| |
| _chooseFocusTarget: function() { |
| // If we are the owner and the reviewers field is empty, focus on that. |
| if (this._account && this.change && this.change.owner && |
| this._account._account_id === this.change.owner._account_id && |
| (!this._reviewers || this._reviewers.length === 0)) { |
| return FocusTarget.REVIEWERS; |
| } |
| |
| // Default to BODY. |
| return FocusTarget.BODY; |
| }, |
| |
| _handle400Error: function(response) { |
| // A call to _saveReview could fail with a server error if erroneous |
| // reviewers were requested. This is signalled with a 400 Bad Request |
| // status. The default gr-rest-api-interface error handling would |
| // result in a large JSON response body being displayed to the user in |
| // the gr-error-manager toast. |
| // |
| // We can modify the error handling behavior by passing this function |
| // through to restAPI as a custom error handling function. Since we're |
| // short-circuiting restAPI we can do our own response parsing and fire |
| // the server-error ourselves. |
| // |
| this.disabled = false; |
| |
| if (response.status !== 400) { |
| // This is all restAPI does when there is no custom error handling. |
| this.fire('server-error', {response: response}); |
| return response; |
| } |
| |
| // Process the response body, format a better error message, and fire |
| // an event for gr-event-manager to display. |
| var jsonPromise = this.$.restAPI.getResponseObject(response); |
| return jsonPromise.then(function(result) { |
| var errors = []; |
| ['reviewers', 'ccs'].forEach(function(state) { |
| for (var input in result[state]) { |
| var reviewer = result[state][input]; |
| if (!!reviewer.error) { |
| errors.push(reviewer.error); |
| } |
| } |
| }); |
| response = { |
| ok: false, |
| status: response.status, |
| text: function() { return Promise.resolve(errors.join(', ')); }, |
| }; |
| this.fire('server-error', {response: response}); |
| }.bind(this)); |
| }, |
| |
| _computeHideDraftList: function(drafts) { |
| return Object.keys(drafts || {}).length == 0; |
| }, |
| |
| _computeDraftsTitle: function(drafts) { |
| var total = 0; |
| for (var file in drafts) { |
| total += drafts[file].length; |
| } |
| if (total == 0) { return ''; } |
| if (total == 1) { return '1 Draft'; } |
| if (total > 1) { return total + ' Drafts'; } |
| }, |
| |
| _computeLabelValueTitle: function(labels, label, value) { |
| return labels[label] && labels[label].values[value]; |
| }, |
| |
| _computeLabels: function(labelRecord) { |
| var labelsObj = labelRecord.base; |
| if (!labelsObj) { return []; } |
| return Object.keys(labelsObj).sort().map(function(key) { |
| return { |
| name: key, |
| value: this._getVoteForAccount(labelsObj, key, this._account), |
| }; |
| }.bind(this)); |
| }, |
| |
| _getVoteForAccount: function(labels, labelName, account) { |
| var votes = labels[labelName]; |
| if (votes.all && votes.all.length > 0) { |
| for (var i = 0; i < votes.all.length; i++) { |
| if (votes.all[i]._account_id == account._account_id) { |
| return votes.all[i].value; |
| } |
| } |
| } |
| return null; |
| }, |
| |
| _computeIndexOfLabelValue: function(labels, permittedLabels, label) { |
| if (!labels[label.name]) { return null; } |
| var labelValue = label.value; |
| var len = permittedLabels[label.name] != null ? |
| permittedLabels[label.name].length : 0; |
| for (var i = 0; i < len; i++) { |
| var val = parseInt(permittedLabels[label.name][i], 10); |
| if (val == labelValue) { |
| return i; |
| } |
| } |
| return null; |
| }, |
| |
| _computePermittedLabelValues: function(permittedLabels, label) { |
| return permittedLabels[label]; |
| }, |
| |
| _computeAnyPermittedLabelValues: function(permittedLabels, label) { |
| return permittedLabels.hasOwnProperty(label); |
| }, |
| |
| _changeUpdated: function(changeRecord, owner, serverConfig) { |
| this._rebuildReviewerArrays(changeRecord.base, owner, serverConfig); |
| }, |
| |
| _rebuildReviewerArrays: function(change, owner, serverConfig) { |
| this._owner = owner; |
| |
| var reviewers = []; |
| var ccs = []; |
| |
| for (var key in change) { |
| if (key !== 'REVIEWER' && key !== 'CC') { |
| console.warn('unexpected reviewer state:', key); |
| continue; |
| } |
| change[key].forEach(function(entry) { |
| if (entry._account_id === owner._account_id) { |
| return; |
| } |
| switch (key) { |
| case 'REVIEWER': |
| reviewers.push(entry); |
| break; |
| case 'CC': |
| ccs.push(entry); |
| break; |
| } |
| }); |
| } |
| |
| if (serverConfig.note_db_enabled) { |
| this._ccs = ccs; |
| } else { |
| this._ccs = []; |
| reviewers = reviewers.concat(ccs); |
| } |
| this._reviewers = reviewers; |
| }, |
| |
| _accountOrGroupKey: function(entry) { |
| return entry.id || entry._account_id; |
| }, |
| |
| _filterReviewerSuggestion: function(suggestion) { |
| var entry; |
| if (suggestion.account) { |
| entry = suggestion.account; |
| } else if (suggestion.group) { |
| entry = suggestion.group; |
| } else { |
| console.warn('received suggestion that was neither account nor group:', |
| suggestion); |
| } |
| if (entry._account_id === this._owner._account_id) { |
| return false; |
| } |
| |
| var key = this._accountOrGroupKey(entry); |
| var finder = function(entry) { |
| return this._accountOrGroupKey(entry) === key; |
| }.bind(this); |
| |
| return this._reviewers.find(finder) === undefined && |
| this._ccs.find(finder) === undefined; |
| }, |
| |
| _getAccount: function() { |
| return this.$.restAPI.getAccount(); |
| }, |
| |
| _cancelTapHandler: function(e) { |
| e.preventDefault(); |
| this.cancel(); |
| }, |
| |
| cancel: function() { |
| this.fire('cancel', null, {bubbles: false}); |
| this._purgeReviewersPendingRemove(true); |
| this._rebuildReviewerArrays(this.change.reviewers, this._owner, |
| this.serverConfig); |
| }, |
| |
| _sendTapHandler: function(e) { |
| e.preventDefault(); |
| this.send(this._includeComments).then(function(keepReviewers) { |
| this._purgeReviewersPendingRemove(false, keepReviewers); |
| }.bind(this)); |
| }, |
| |
| _saveReview: function(review, opt_errFn) { |
| return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum, |
| review, opt_errFn); |
| }, |
| |
| _reviewerPendingConfirmationUpdated: function(reviewer) { |
| if (reviewer === null) { |
| this.$.reviewerConfirmationOverlay.close(); |
| } else { |
| this._pendingConfirmationDetails = |
| this._ccPendingConfirmation || this._reviewerPendingConfirmation; |
| this.$.reviewerConfirmationOverlay.open(); |
| } |
| }, |
| |
| _confirmPendingReviewer: function() { |
| if (this._ccPendingConfirmation) { |
| this.$$('#ccs').confirmGroup(this._ccPendingConfirmation.group); |
| this._focusOn(FocusTarget.CCS); |
| } else { |
| this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group); |
| this._focusOn(FocusTarget.REVIEWERS); |
| } |
| }, |
| |
| _cancelPendingReviewer: function() { |
| this._ccPendingConfirmation = null; |
| this._reviewerPendingConfirmation = null; |
| |
| var target = |
| this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS; |
| this._focusOn(target); |
| }, |
| |
| _getStorageLocation: function() { |
| // Tests trigger this method without setting change. |
| if (!this.change) { return {}; } |
| return { |
| changeNum: this.change._number, |
| patchNum: this.patchNum, |
| path: '@change', |
| }; |
| }, |
| |
| _loadStoredDraft: function() { |
| var draft = this.$.storage.getDraftComment(this._getStorageLocation()); |
| return draft ? draft.message : ''; |
| }, |
| |
| _draftChanged: function(newDraft, oldDraft) { |
| this.debounce('store', function() { |
| if (!newDraft.length && oldDraft) { |
| // If the draft has been modified to be empty, then erase the storage |
| // entry. |
| this.$.storage.eraseDraftComment(this._getStorageLocation()); |
| } else if (newDraft.length) { |
| this.$.storage.setDraftComment(this._getStorageLocation(), |
| this.draft); |
| } |
| }, STORAGE_DEBOUNCE_INTERVAL_MS); |
| }, |
| |
| _handleHeightChanged: function(e) { |
| // If the textarea resizes, we need to re-fit the overlay. |
| this.debounce('autogrow', function() { |
| this.fire('autogrow'); |
| }); |
| }, |
| }); |
| })(); |