// 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'); }); }, }); })();