/** * @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_MS = 400; const FocusTarget = { ANY: 'any', BODY: 'body', CCS: 'cc', REVIEWERS: 'reviewers', }; const ReviewerTypes = { REVIEWER: 'REVIEWER', CC: 'CC', }; const LatestPatchState = { LATEST: 'latest', CHECKING: 'checking', NOT_LATEST: 'not-latest', }; const ButtonLabels = { START_REVIEW: 'Start review', SEND: 'Send', }; const ButtonTooltips = { SAVE: 'Save reply but do not send notification', START_REVIEW: 'Mark as ready for review and send reply', SEND: 'Send reply', }; // TODO(logan): Remove once the fix for issue 6841 is stable on // googlesource.com. const START_REVIEW_MESSAGE = 'This change is ready for review.'; const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.'; const SEND_REPLY_TIMING_LABEL = 'SendReply'; 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 */ /** * Fires to show an alert when a send is attempted on the non-latest patch. * * @event show-alert */ /** * Fires when the reply dialog believes that the server side diff drafts * have been updated and need to be refreshed. * * @event comment-refresh */ /** * Fires when the state of the send button (enabled/disabled) changes. * * @event send-disabled-changed */ properties: { /** * @type {{ _number: number, removable_reviewers: Array }} */ change: Object, patchNum: String, canBeStarted: { type: Boolean, value: false, }, disabled: { type: Boolean, value: false, reflectToAttribute: true, }, draft: { type: String, value: '', observer: '_draftChanged', }, quote: { type: String, value: '', }, diffDrafts: { type: Object, observer: '_handleHeightChanged', }, /** @type {!Function} */ filterReviewerSuggestion: { type: Function, value() { return this._filterReviewerSuggestionGenerator(false); }, }, /** @type {!Function} */ filterCCSuggestion: { type: Function, value() { return this._filterReviewerSuggestionGenerator(true); }, }, permittedLabels: Object, /** * @type {{ note_db_enabled: boolean }} */ serverConfig: Object, /** * @type {{ commentlinks: Array }} */ projectConfig: Object, knownLatestState: String, underReview: { type: Boolean, value: true, }, _account: Object, _ccs: Array, /** @type {?Object} */ _ccPendingConfirmation: { type: Object, observer: '_reviewerPendingConfirmationUpdated', }, _messagePlaceholder: { type: String, computed: '_computeMessagePlaceholder(canBeStarted)', }, _owner: Object, /** @type {?} */ _pendingConfirmationDetails: Object, _includeComments: { type: Boolean, value: true, }, _reviewers: Array, /** @type {?Object} */ _reviewerPendingConfirmation: { type: Object, observer: '_reviewerPendingConfirmationUpdated', }, _previewFormatting: { type: Boolean, value: false, observer: '_handleHeightChanged', }, _reviewersPendingRemove: { type: Object, value: { CC: [], REVIEWER: [], }, }, _sendButtonLabel: { type: String, computed: '_computeSendButtonLabel(canBeStarted)', }, _ccsEnabled: { type: Boolean, computed: '_computeCCsEnabled(serverConfig)', }, _savingComments: Boolean, _reviewersMutated: { type: Boolean, value: false, }, _labelsChanged: { type: Boolean, value: false, }, _saveTooltip: { type: String, value: ButtonTooltips.SAVE, readOnly: true, }, _pluginMessage: { type: String, value: '', }, _sendDisabled: { type: Boolean, computed: '_computeSendButtonDisabled(_sendButtonLabel, diffDrafts, ' + 'draft, _reviewersMutated, _labelsChanged, _includeComments, ' + 'disabled)', observer: '_sendDisabledChanged', }, }, FocusTarget, // TODO(logan): Remove once the fix for issue 6841 is stable on // googlesource.com. START_REVIEW_MESSAGE, behaviors: [ Gerrit.BaseUrlBehavior, Gerrit.KeyboardShortcutBehavior, Gerrit.PatchSetBehavior, Gerrit.RESTClientBehavior, ], keyBindings: { 'esc': '_handleEscKey', 'ctrl+enter meta+enter': '_handleEnterKey', }, observers: [ '_changeUpdated(change.reviewers.*, change.owner, serverConfig)', '_ccsChanged(_ccs.splices)', '_reviewersChanged(_reviewers.splices)', ], attached() { this._getAccount().then(account => { this._account = account || {}; }); }, ready() { this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this); }, open(opt_focusTarget) { this.knownLatestState = LatestPatchState.CHECKING; this.fetchChangeUpdates(this.change, this.$.restAPI) .then(result => { this.knownLatestState = result.isLatest ? LatestPatchState.LATEST : LatestPatchState.NOT_LATEST; }); this._focusOn(opt_focusTarget); if (this.quote && this.quote.length) { // If a reply quote has been provided, use it and clear the property. this.draft = this.quote; this.quote = ''; } else { // Otherwise, check for an unsaved draft in localstorage. this.draft = this._loadStoredDraft(); } if (this.$.restAPI.hasPendingDiffDrafts()) { this._savingComments = true; this.$.restAPI.awaitPendingDiffDrafts().then(() => { this.fire('comment-refresh'); this._savingComments = false; }); } }, focus() { this._focusOn(FocusTarget.ANY); }, getFocusStops() { const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton; return { start: this.$.reviewers.focusStart, end, }; }, setLabelValue(label, value) { const selectorEl = this.$.labelScores.$$(`gr-label-score-row[name="${label}"]`); if (!selectorEl) { return; } selectorEl.setSelectedValue(value); }, getLabelValue(label) { const selectorEl = this.$.labelScores.$$(`gr-label-score-row[name="${label}"]`); if (!selectorEl) { return null; } return selectorEl.selectedValue; }, _handleEscKey(e) { this.cancel(); }, _handleEnterKey(e) { this._submit(); }, _ccsChanged(splices) { if (splices && splices.indexSplices) { this._reviewersMutated = true; this._processReviewerChange(splices.indexSplices, ReviewerTypes.CC); } }, _reviewersChanged(splices) { if (splices && splices.indexSplices) { this._reviewersMutated = true; this._processReviewerChange(splices.indexSplices, ReviewerTypes.REVIEWER); let key; let index; let account; // Remove any accounts that already exist as a CC. for (const splice of splices.indexSplices) { for (const addedKey of splice.addedKeys) { account = this.get(`_reviewers.${addedKey}`); key = this._accountOrGroupKey(account); index = this._ccs.findIndex( account => this._accountOrGroupKey(account) === key); if (index >= 0) { this.splice('_ccs', index, 1); const message = (account.name || account.email || key) + ' moved from CC to reviewer.'; this.fire('show-alert', {message}); } } } } }, _processReviewerChange(indexSplices, type) { for (const splice of indexSplices) { for (const account of splice.removed) { if (!this._reviewersPendingRemove[type]) { console.err('Invalid type ' + type + ' for reviewer.'); return; } this._reviewersPendingRemove[type].push(account); } } }, /** * 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(isCancel, opt_accountIdsTransferred) { let reviewerArr; const keep = opt_accountIdsTransferred || {}; for (const type in this._reviewersPendingRemove) { if (this._reviewersPendingRemove.hasOwnProperty(type)) { if (!isCancel) { reviewerArr = this._reviewersPendingRemove[type]; for (let 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 {string} type * * * TODO(beckysiegel) submit Polymer PR * @suppress {checkTypes} */ _removeAccount(account, type) { if (account._pendingAdd) { return; } return this.$.restAPI.removeChangeReviewer(this.change._number, account._account_id).then(response => { if (!response.ok) { return response; } const reviewers = this.change.reviewers[type] || []; for (let i = 0; i < reviewers.length; i++) { if (reviewers[i]._account_id == account._account_id) { this.splice(['change', 'reviewers', type], i, 1); break; } } }); }, _mapReviewer(reviewer) { let reviewerId; let 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}; }, send(includeComments, startReview) { this.$.reporting.time(SEND_REPLY_TIMING_LABEL); const labels = this.$.labelScores.getLabelValues(); const obj = { drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP', labels, }; if (startReview) { obj.ready = true; } if (this.draft != null) { obj.message = this.draft; } const accountAdditions = {}; obj.reviewers = this.$.reviewers.additions().map(reviewer => { if (reviewer.account) { accountAdditions[reviewer.account._account_id] = true; } return this._mapReviewer(reviewer); }); const ccsEl = this.$$('#ccs'); if (ccsEl) { for (let reviewer of ccsEl.additions()) { if (reviewer.account) { accountAdditions[reviewer.account._account_id] = true; } reviewer = this._mapReviewer(reviewer); reviewer.state = 'CC'; obj.reviewers.push(reviewer); } } this.disabled = true; if (obj.ready && !obj.message) { // TODO(logan): The server currently doesn't send email in this case. // Insert a dummy message to force an email to be sent. Remove this // once the fix for issue 6841 is stable on googlesource.com. obj.message = START_REVIEW_MESSAGE; } const errFn = this._handle400Error.bind(this); return this._saveReview(obj, errFn).then(response => { if (!response) { // Null or undefined response indicates that an error handler // took responsibility, so just return. return {}; } if (!response.ok) { this.fire('server-error', {response}); return {}; } // TODO(logan): Remove once the required API changes are live and stable // on googlesource.com. return this._maybeSetReady(startReview, response).catch(err => { // We catch error here because we still want to treat this as a // successful review. console.error('error setting ready:', err); }).then(() => { this.draft = ''; this._includeComments = true; this.fire('send', null, {bubbles: false}); return accountAdditions; }); }).then(result => { this.disabled = false; return result; }).catch(err => { this.disabled = false; throw err; }); }, /** * Returns a promise resolving to true if review was successfully posted, * false otherwise. * * TODO(logan): Remove this once the required API changes are live and * stable on googlesource.com. */ _maybeSetReady(startReview, response) { return this.$.restAPI.getResponseObject(response).then(result => { if (!startReview || result.ready) { return Promise.resolve(); } // We don't have confirmation that review was started, so attempt to // start review explicitly. return this.$.restAPI.startReview( this.change._number, null, response => { // If we see a 409 response code, then that means the server // *does* support moving from WIP->ready when posting a // review. Only alert user for non-409 failures. if (response.status !== 409) { this.fire('server-error', {response}); } }); }); }, _focusOn(section) { // Safeguard- always want to focus on something. if (!section || section === FocusTarget.ANY) { section = this._chooseFocusTarget(); } if (section === FocusTarget.BODY) { const textarea = this.$.textarea; textarea.async(textarea.getNativeTextarea() .focus.bind(textarea.getNativeTextarea())); } else if (section === FocusTarget.REVIEWERS) { const reviewerEntry = this.$.reviewers.focusStart; reviewerEntry.async(reviewerEntry.focus); } else if (section === FocusTarget.CCS) { const ccEntry = this.$$('#ccs').focusStart; ccEntry.async(ccEntry.focus); } }, _chooseFocusTarget() { // 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(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}); return response; } // Process the response body, format a better error message, and fire // an event for gr-event-manager to display. const jsonPromise = this.$.restAPI.getResponseObject(response); return jsonPromise.then(result => { const errors = []; for (const state of ['reviewers', 'ccs']) { if (!result.hasOwnProperty(state)) { continue; } for (const reviewer of Object.values(result[state])) { if (reviewer.error) { errors.push(reviewer.error); } } } response = { ok: false, status: response.status, text() { return Promise.resolve(errors.join(', ')); }, }; this.fire('server-error', {response}); }); }, _computeHideDraftList(drafts) { return Object.keys(drafts || {}).length == 0; }, _computeDraftsTitle(drafts) { let total = 0; for (const file in drafts) { if (drafts.hasOwnProperty(file)) { total += drafts[file].length; } } if (total == 0) { return ''; } if (total == 1) { return '1 Draft'; } if (total > 1) { return total + ' Drafts'; } }, _computeMessagePlaceholder(canBeStarted) { return canBeStarted ? 'Add a note for your reviewers...' : 'Say something nice...'; }, _changeUpdated(changeRecord, owner, serverConfig) { this._rebuildReviewerArrays(changeRecord.base, owner, serverConfig); }, _rebuildReviewerArrays(change, owner, serverConfig) { this._owner = owner; let reviewers = []; const ccs = []; for (const key in change) { if (change.hasOwnProperty(key)) { if (key !== 'REVIEWER' && key !== 'CC') { console.warn('unexpected reviewer state:', key); continue; } for (const entry of change[key]) { if (entry._account_id === owner._account_id) { continue; } switch (key) { case 'REVIEWER': reviewers.push(entry); break; case 'CC': ccs.push(entry); break; } } } } if (this._ccsEnabled) { this._ccs = ccs; } else { this._ccs = []; reviewers = reviewers.concat(ccs); } this._reviewers = reviewers; }, _accountOrGroupKey(entry) { return entry.id || entry._account_id; }, /** * Generates a function to filter out reviewer/CC entries. When isCCs is * truthy, the function filters out entries that already exist in this._ccs. * When falsy, the function filters entries that exist in this._reviewers. * @param {boolean} isCCs * @return {!Function} */ _filterReviewerSuggestionGenerator(isCCs) { return suggestion => { let 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; } const key = this._accountOrGroupKey(entry); const finder = entry => this._accountOrGroupKey(entry) === key; if (isCCs) { return this._ccs.find(finder) === undefined; } return this._reviewers.find(finder) === undefined; }; }, _getAccount() { return this.$.restAPI.getAccount(); }, _cancelTapHandler(e) { e.preventDefault(); this.cancel(); }, cancel() { this.fire('cancel', null, {bubbles: false}); this.$.textarea.closeDropdown(); this._purgeReviewersPendingRemove(true); this._rebuildReviewerArrays(this.change.reviewers, this._owner, this.serverConfig); }, _saveTapHandler(e) { e.preventDefault(); if (this._ccsEnabled && !this.$$('#ccs').submitEntryText()) { // Do not proceed with the save if there is an invalid email entry in // the text field of the CC entry. return; } this.send(this._includeComments, false).then(keepReviewers => { this._purgeReviewersPendingRemove(false, keepReviewers); }); }, _sendTapHandler(e) { e.preventDefault(); this._submit(); }, _submit() { if (this._ccsEnabled && !this.$$('#ccs').submitEntryText()) { // Do not proceed with the send if there is an invalid email entry in // the text field of the CC entry. return; } if (this._sendDisabled) { this.dispatchEvent(new CustomEvent('show-alert', { bubbles: true, detail: {message: EMPTY_REPLY_MESSAGE}, })); return; } return this.send(this._includeComments, this.canBeStarted) .then(keepReviewers => { this._purgeReviewersPendingRemove(false, keepReviewers); }); }, _saveReview(review, opt_errFn) { return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum, review, opt_errFn); }, _reviewerPendingConfirmationUpdated(reviewer) { if (reviewer === null) { this.$.reviewerConfirmationOverlay.close(); } else { this._pendingConfirmationDetails = this._ccPendingConfirmation || this._reviewerPendingConfirmation; this.$.reviewerConfirmationOverlay.open(); } }, _confirmPendingReviewer() { 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() { this._ccPendingConfirmation = null; this._reviewerPendingConfirmation = null; const target = this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS; this._focusOn(target); }, _getStorageLocation() { // Tests trigger this method without setting change. if (!this.change) { return {}; } return { changeNum: this.change._number, patchNum: '@change', path: '@change', }; }, _loadStoredDraft() { const draft = this.$.storage.getDraftComment(this._getStorageLocation()); return draft ? draft.message : ''; }, _handleAccountTextEntry() { // When either of the account entries has input added to the autocomplete, // it should trigger the save button to enable/ // // Note: if the text is removed, the save button will not get disabled. this._reviewersMutated = true; }, _draftChanged(newDraft, oldDraft) { this.debounce('store', () => { 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(e) { this.fire('autogrow'); }, _handleLabelsChanged() { this._labelsChanged = Object.keys( this.$.labelScores.getLabelValues()).length !== 0; }, _isState(knownLatestState, value) { return knownLatestState === value; }, _reload() { // Load the current change without any patch range. location.href = this.getBaseUrl() + '/c/' + this.change._number; }, _computeSendButtonLabel(canBeStarted) { return canBeStarted ? ButtonLabels.START_REVIEW : ButtonLabels.SEND; }, _computeSendButtonTooltip(canBeStarted) { return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND; }, _computeCCsEnabled(serverConfig) { return serverConfig && serverConfig.note_db_enabled; }, _computeSavingLabelClass(savingComments) { return savingComments ? 'saving' : ''; }, _computeSendButtonDisabled(buttonLabel, drafts, text, reviewersMutated, labelsChanged, includeComments, disabled) { if (disabled) { return true; } if (buttonLabel === ButtonLabels.START_REVIEW) { return false; } const hasDrafts = includeComments && Object.keys(drafts).length; return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged; }, _computePatchSetWarning(patchNum, labelsChanged) { let str = `Patch ${patchNum} is not latest.`; if (labelsChanged) { str += ' Voting on a non-latest patch will have no effect.'; } return str; }, setPluginMessage(message) { this._pluginMessage = message; }, _sendDisabledChanged(sendDisabled) { this.dispatchEvent(new CustomEvent('send-disabled-changed')); }, }); })();