| /** |
| * @license |
| * Copyright (C) 2015 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. |
| */ |
| import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js'; |
| import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js'; |
| import '../../shared/gr-account-chip/gr-account-chip.js'; |
| import '../../shared/gr-textarea/gr-textarea.js'; |
| import '../../shared/gr-button/gr-button.js'; |
| import '../../shared/gr-formatted-text/gr-formatted-text.js'; |
| import '../../shared/gr-js-api-interface/gr-js-api-interface.js'; |
| import '../../shared/gr-overlay/gr-overlay.js'; |
| import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js'; |
| import '../../shared/gr-storage/gr-storage.js'; |
| import '../../shared/gr-account-list/gr-account-list.js'; |
| import '../gr-label-scores/gr-label-scores.js'; |
| import '../gr-thread-list/gr-thread-list.js'; |
| import '../../../styles/shared-styles.js'; |
| import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js'; |
| import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element.js'; |
| import {htmlTemplate} from './gr-reply-dialog_html.js'; |
| import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js'; |
| import {appContext} from '../../../services/app-context.js'; |
| import {ChangeStatus, SpecialFilePath} from '../../../constants/constants.js'; |
| import {KnownExperimentId} from '../../../services/flags/flags.js'; |
| import {fetchChangeUpdates} from '../../../utils/patch-set-util.js'; |
| import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js'; |
| import {removeServiceUsers} from '../../../utils/account-util.js'; |
| import {getDisplayName} from '../../../utils/display-name-util.js'; |
| import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer.js'; |
| import {TargetElement} from '../../plugins/gr-plugin-types.js'; |
| |
| 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 but do not send notification or change review state', |
| START_REVIEW: 'Mark as ready for review and send reply', |
| SEND: 'Send reply', |
| }; |
| |
| const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.'; |
| |
| const SEND_REPLY_TIMING_LABEL = 'SendReply'; |
| |
| /** |
| * @extends PolymerElement |
| */ |
| class GrReplyDialog extends KeyboardShortcutMixin(GestureEventListeners( |
| LegacyElementMixin(PolymerElement))) { |
| static get template() { return htmlTemplate; } |
| |
| static get is() { return '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 |
| */ |
| |
| /** |
| * Fired to reload the change page. |
| * |
| * @event reload |
| */ |
| |
| constructor() { |
| super(); |
| this.FocusTarget = FocusTarget; |
| this.reporting = appContext.reportingService; |
| this.flagsService = appContext.flagsService; |
| } |
| |
| static get properties() { |
| return { |
| /** |
| * @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: '', |
| }, |
| /** @type {!Function} */ |
| filterReviewerSuggestion: { |
| type: Function, |
| value() { |
| return this._filterReviewerSuggestionGenerator(false); |
| }, |
| }, |
| /** @type {!Function} */ |
| filterCCSuggestion: { |
| type: Function, |
| value() { |
| return this._filterReviewerSuggestionGenerator(true); |
| }, |
| }, |
| permittedLabels: Object, |
| /** |
| * @type {{ commentlinks: Array }} |
| */ |
| projectConfig: Object, |
| serverConfig: 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, |
| /** |
| * This is only set, if an uploader exists for the latest patchset, and |
| * it is NOT the owner. |
| */ |
| _uploader: { |
| type: Object, |
| computed: '_computeUploader(change)', |
| }, |
| /** @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)', |
| }, |
| _savingComments: Boolean, |
| _reviewersMutated: { |
| type: Boolean, |
| value: false, |
| }, |
| _labelsChanged: { |
| type: Boolean, |
| value: false, |
| }, |
| _saveTooltip: { |
| type: String, |
| value: ButtonTooltips.SAVE, |
| readOnly: true, |
| }, |
| _pluginMessage: { |
| type: String, |
| value: '', |
| }, |
| _commentEditing: { |
| type: Boolean, |
| value: false, |
| }, |
| /** |
| * Is the UI in the state where the user individually modifies attention |
| * set entries? |
| */ |
| _attentionModified: { |
| type: Boolean, |
| value: false, |
| }, |
| /** |
| * Set of account IDs that currently constitutes the attention set, read |
| * from change.attention_set. Will be updated by the |
| * _computeNewAttention() observer. |
| */ |
| _currentAttentionSet: { |
| type: Object, |
| value: () => new Set(), |
| }, |
| /** |
| * Set of account IDs that should constitute the attention set after |
| * publishing the votes/comments. Will be initialized with a default (that |
| * matches the default rules that the backend would also apply) by the |
| * _computeNewAttention() observer. |
| */ |
| _newAttentionSet: { |
| type: Object, |
| value: () => new Set(), |
| }, |
| _sendDisabled: { |
| type: Boolean, |
| computed: '_computeSendButtonDisabled(canBeStarted, ' + |
| 'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' + |
| '_includeComments, disabled, _commentEditing, _attentionModified)', |
| observer: '_sendDisabledChanged', |
| }, |
| draftCommentThreads: { |
| type: Array, |
| observer: '_handleHeightChanged', |
| }, |
| // Track if the message typed in the reply dialog will be created as a |
| // resolved/unresolved patchset level comment |
| _isResolvedPatchsetLevelComment: { |
| type: Boolean, |
| value: true, |
| }, |
| |
| /** |
| * A copy of added reviewers, a new copy is created when any change |
| * made to the reviewers. |
| */ |
| _allReviewers: { |
| type: Array, |
| computed: '_computeAllReviewers(_reviewers.*)', |
| }, |
| }; |
| } |
| |
| get keyBindings() { |
| return { |
| 'esc': '_handleEscKey', |
| 'ctrl+enter meta+enter': '_handleEnterKey', |
| }; |
| } |
| |
| static get observers() { |
| return [ |
| '_changeUpdated(change.reviewers.*, change.owner)', |
| '_ccsChanged(_ccs.splices)', |
| '_reviewersChanged(_reviewers.splices)', |
| '_computeNewAttention(' + |
| '_account, _reviewers.*, _ccs.*, change, draftCommentThreads)', |
| ]; |
| } |
| |
| /** @override */ |
| attached() { |
| super.attached(); |
| IronA11yAnnouncer.requestAvailability(); |
| this._getAccount().then(account => { |
| this._account = account || {}; |
| }); |
| |
| this.addEventListener('comment-editing-changed', e => { |
| this._commentEditing = e.detail; |
| }); |
| |
| // Plugins on reply-reviewers endpoint can take advantage of these |
| // events to add / remove reviewers |
| |
| this.addEventListener('add-reviewer', e => { |
| // Only support account type, see more from: |
| // elements/shared/gr-account-list/gr-account-list.js#addAccountItem |
| this.$.reviewers.addAccountItem({account: e.detail.reviewer}); |
| }); |
| |
| this.addEventListener('remove-reviewer', e => { |
| this.$.reviewers.removeAccount(e.detail.reviewer); |
| }); |
| } |
| |
| /** @override */ |
| ready() { |
| super.ready(); |
| this._isPatchsetCommentsExperimentEnabled = this.flagsService |
| .isEnabled(KnownExperimentId.PATCHSET_COMMENTS); |
| this.$.jsAPI.addElement(TargetElement.REPLY_DIALOG, this); |
| } |
| |
| open(opt_focusTarget) { |
| this.knownLatestState = LatestPatchState.CHECKING; |
| 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.dispatchEvent(new CustomEvent('comment-refresh', { |
| composed: true, bubbles: true, |
| })); |
| 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.shadowRoot |
| .querySelector(`gr-label-score-row[name="${label}"]`); |
| if (!selectorEl) { return; } |
| selectorEl.setSelectedValue(value); |
| } |
| |
| getLabelValue(label) { |
| const selectorEl = |
| this.$.labelScores.shadowRoot |
| .querySelector(`gr-label-score-row[name="${label}"]`); |
| if (!selectorEl) { return null; } |
| |
| return selectorEl.selectedValue; |
| } |
| |
| _handleEscKey(e) { |
| this.cancel(); |
| } |
| |
| _handleEnterKey(e) { |
| this._submit(); |
| } |
| |
| _ccsChanged(splices) { |
| this._reviewerTypeChanged(splices, ReviewerTypes.CC); |
| } |
| |
| _reviewersChanged(splices) { |
| this._reviewerTypeChanged(splices, ReviewerTypes.REVIEWER); |
| } |
| |
| _reviewerTypeChanged(splices, reviewerType) { |
| if (splices && splices.indexSplices) { |
| this._reviewersMutated = true; |
| this._processReviewerChange(splices.indexSplices, |
| reviewerType); |
| let key; |
| let index; |
| let account; |
| // Remove any accounts that already exist as a CC for reviewer |
| // or vice versa. |
| const isReviewer = ReviewerTypes.REVIEWER === reviewerType; |
| for (const splice of splices.indexSplices) { |
| for (let i = 0; i < splice.addedCount; i++) { |
| account = splice.object[splice.index + i]; |
| key = this._accountOrGroupKey(account); |
| const array = isReviewer ? this._ccs : this._reviewers; |
| index = array.findIndex( |
| account => this._accountOrGroupKey(account) === key); |
| if (index >= 0) { |
| this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1); |
| const moveFrom = isReviewer ? 'CC' : 'reviewer'; |
| const moveTo = isReviewer ? 'reviewer' : 'CC'; |
| const message = (account.name || account.email || key) + |
| ` moved from ${moveFrom} to ${moveTo}.`; |
| this.dispatchEvent(new CustomEvent('show-alert', { |
| detail: {message}, |
| composed: true, bubbles: true, |
| })); |
| } |
| } |
| } |
| } |
| } |
| |
| _processReviewerChange(indexSplices, type) { |
| for (const splice of indexSplices) { |
| for (const account of splice.removed) { |
| if (!this._reviewersPendingRemove[type]) { |
| console.error('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 |
| */ |
| _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 reviewInput = { |
| drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP', |
| labels, |
| }; |
| |
| if (startReview) { |
| reviewInput.ready = true; |
| } |
| |
| if (this._isAttentionSetEnabled(this.serverConfig)) { |
| const selfName = getDisplayName(this.serverConfig, this._account); |
| const reason = `${selfName} replied on the change`; |
| |
| reviewInput.ignore_automatic_attention_set_rules = true; |
| reviewInput.add_to_attention_set = []; |
| for (const user of this._newAttentionSet) { |
| if (!this._currentAttentionSet.has(user)) { |
| reviewInput.add_to_attention_set.push({user, reason}); |
| } |
| } |
| reviewInput.remove_from_attention_set = []; |
| for (const user of this._currentAttentionSet) { |
| if (!this._newAttentionSet.has(user)) { |
| reviewInput.remove_from_attention_set.push({user, reason}); |
| } |
| } |
| this.reportAttentionSetChanges(this._attentionModified, |
| reviewInput.add_to_attention_set, |
| reviewInput.remove_from_attention_set); |
| } |
| |
| if (this.draft != null) { |
| if (this._isPatchsetCommentsExperimentEnabled) { |
| const comment = { |
| message: this.draft, |
| unresolved: !this._isResolvedPatchsetLevelComment, |
| }; |
| reviewInput.comments = { |
| [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment], |
| }; |
| } else { |
| reviewInput.message = this.draft; |
| } |
| } |
| |
| const accountAdditions = {}; |
| reviewInput.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'; |
| reviewInput.reviewers.push(reviewer); |
| } |
| } |
| |
| this.disabled = true; |
| |
| const errFn = this._handle400Error.bind(this); |
| return this._saveReview(reviewInput, errFn) |
| .then(response => { |
| if (!response) { |
| // Null or undefined response indicates that an error handler |
| // took responsibility, so just return. |
| return {}; |
| } |
| if (!response.ok) { |
| this.dispatchEvent(new CustomEvent('server-error', { |
| detail: {response}, |
| composed: true, bubbles: true, |
| })); |
| return {}; |
| } |
| |
| this.draft = ''; |
| this._includeComments = true; |
| this.dispatchEvent(new CustomEvent('send', { |
| composed: true, bubbles: false, |
| })); |
| this.fire('iron-announce', {text: 'Reply sent'}, {bubbles: true} ); |
| return accountAdditions; |
| }) |
| .then(result => { |
| this.disabled = false; |
| return result; |
| }) |
| .catch(err => { |
| this.disabled = false; |
| throw err; |
| }); |
| } |
| |
| _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()); |
| } 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; |
| } |
| |
| _isOwner(account, change) { |
| if (!account || !change || !change.owner) return false; |
| return account._account_id === change.owner._account_id; |
| } |
| |
| _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; |
| |
| // Using response.clone() here, because getResponseObject() and |
| // potentially the generic error handler will want to call text() on the |
| // response object, which can only be done once per object. |
| const jsonPromise = this.$.restAPI.getResponseObject(response.clone()); |
| return jsonPromise.then(result => { |
| // Only perform custom error handling for 400s and a parseable |
| // ReviewResult response. |
| if (response.status === 400 && 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.dispatchEvent(new CustomEvent('server-error', { |
| detail: {response}, |
| composed: true, bubbles: true, |
| })); |
| return null; // Means that the error has been handled. |
| }); |
| } |
| |
| _computeHideDraftList(draftCommentThreads) { |
| return !draftCommentThreads || draftCommentThreads.length === 0; |
| } |
| |
| _computeDraftsTitle(draftCommentThreads) { |
| const total = draftCommentThreads ? draftCommentThreads.length : 0; |
| 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) { |
| // Polymer 2: check for undefined |
| if ([changeRecord, owner].includes(undefined)) { |
| return; |
| } |
| |
| this._rebuildReviewerArrays(changeRecord.base, owner); |
| } |
| |
| _rebuildReviewerArrays(change, owner) { |
| this._owner = owner; |
| |
| const 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; |
| } |
| } |
| } |
| } |
| |
| this._ccs = ccs; |
| this._reviewers = reviewers; |
| } |
| |
| _handleAttentionModify() { |
| this._attentionModified = true; |
| // If the attention-detail section is expanded without dispatching this |
| // event, then the dialog may expand beyond the screen's bottom border. |
| this.dispatchEvent(new CustomEvent( |
| 'iron-resize', {composed: true, bubbles: true})); |
| } |
| |
| _showAttentionSummary(config, attentionModified) { |
| return this._isAttentionSetEnabled(config) && !attentionModified; |
| } |
| |
| _showAttentionDetails(config, attentionModified) { |
| return this._isAttentionSetEnabled(config) && attentionModified; |
| } |
| |
| _isAttentionSetEnabled(config) { |
| return !!config && !!config.change && config.change.enable_attention_set; |
| } |
| |
| _handleAttentionClick(e) { |
| const id = e.target.account._account_id; |
| if (!id) return; |
| |
| const selfId = (this._account && this._account._account_id) || -1; |
| const ownerId = (this.change && this.change.owner |
| && this.change.owner._account_id) || -1; |
| const self = id === selfId ? '_SELF' : ''; |
| const role = id === ownerId ? '_OWNER' : '_REVIEWER'; |
| |
| if (this._newAttentionSet.has(id)) { |
| this._newAttentionSet.delete(id); |
| this.reporting.reportInteraction( |
| 'attention-set-chip', {action: `REMOVE${self}${role}`}); |
| } else { |
| this._newAttentionSet.add(id); |
| this.reporting.reportInteraction( |
| 'attention-set-chip', {action: `ADD${self}${role}`}); |
| } |
| |
| // Ensure that Polymer picks up the change. |
| this._newAttentionSet = new Set(this._newAttentionSet); |
| } |
| |
| _computeHasNewAttention(account, newAttention) { |
| return newAttention && account && newAttention.has(account._account_id); |
| } |
| |
| _computeNewAttention(currentUser, reviewers, ccs, change, |
| draftCommentThreads) { |
| if ([currentUser, reviewers, change, draftCommentThreads] |
| .includes(undefined)) { |
| return; |
| } |
| this._attentionModified = false; |
| this._currentAttentionSet = |
| new Set(Object.keys(change.attention_set || {}) |
| .map(id => parseInt(id))); |
| const newAttention = new Set(this._currentAttentionSet); |
| if (change.status === ChangeStatus.NEW) { |
| // Add everyone that the user is replying to in a comment thread. |
| this._computeCommentAccounts(draftCommentThreads).forEach( |
| id => newAttention.add(id) |
| ); |
| // Remove the current user. |
| if (currentUser) newAttention.delete(currentUser._account_id); |
| // Add all new reviewers. |
| reviewers.base.filter(r => r._pendingAdd) |
| .forEach(r => newAttention.add(r._account_id)); |
| // Add the uploader, if someone else replies. |
| if (this._uploader && currentUser && |
| this._uploader._account_id !== currentUser._account_id) { |
| newAttention.add(this._uploader._account_id); |
| } |
| // Add the owner, if someone else replies. Also add the owner, if the |
| // attention set would otherwise be empty. |
| if (change.owner) { |
| if (!this._isOwner(currentUser, change) || newAttention.size === 0) { |
| newAttention.add(change.owner._account_id); |
| } |
| } |
| } else { |
| // The only reason for adding someone to the attention set for merged or |
| // abandoned changes is that someone adds a new comment thread. |
| if (change.owner && this._containsNewCommentThread(draftCommentThreads)) { |
| newAttention.add(change.owner._account_id); |
| } |
| // Remove the current user. |
| if (currentUser) newAttention.delete(currentUser._account_id); |
| } |
| // Finally make sure that everyone in the attention set is still active as |
| // owner, reviewer or cc. |
| const allAccountIds = this._allAccounts() |
| .map(a => a._account_id) |
| .filter(id => !!id); |
| this._newAttentionSet = new Set( |
| [...newAttention].filter(id => allAccountIds.includes(id))); |
| } |
| |
| _computeCommentAccounts(threads) { |
| const accountIds = new Set(); |
| threads.forEach(thread => { |
| thread.comments.forEach(comment => { |
| if (comment.author) { |
| accountIds.add(comment.author._account_id); |
| } |
| }); |
| }); |
| return accountIds; |
| } |
| |
| _containsNewCommentThread(threads) { |
| return threads.some( |
| thread => !!thread.comments && !!thread.comments[0] |
| && !!thread.comments[0].__draft); |
| } |
| |
| _isNewAttentionEmpty(config, currentAttentionSet, newAttentionSet) { |
| return this._computeNewAttentionAccounts( |
| config, currentAttentionSet, newAttentionSet).length === 0; |
| } |
| |
| _computeNewAttentionAccounts(config, currentAttentionSet, newAttentionSet) { |
| if ([currentAttentionSet, newAttentionSet].includes(undefined)) return []; |
| return [...newAttentionSet] |
| .filter(id => !currentAttentionSet.has(id)) |
| .map(id => this._findAccountById(id)) |
| .filter(account => !!account); |
| } |
| |
| _findAccountById(accountId) { |
| return this._allAccounts().find(r => r._account_id === accountId); |
| } |
| |
| _allAccounts() { |
| let allAccounts = []; |
| if (this.change && this.change.owner) allAccounts.push(this.change.owner); |
| if (this._uploader) allAccounts.push(this._uploader); |
| if (this._reviewers) allAccounts = [...allAccounts, ...this._reviewers]; |
| if (this._ccs) allAccounts = [...allAccounts, ...this._ccs]; |
| return removeServiceUsers(allAccounts); |
| } |
| |
| /** |
| * The newAttentionSet param is only used to force re-computation. |
| */ |
| _removeServiceUsers(accounts, newAttentionSet) { |
| return removeServiceUsers(accounts); |
| } |
| |
| _computeShowAttentionCcs(ccs) { |
| return removeServiceUsers(ccs).length > 0; |
| } |
| |
| _computeUploader(change) { |
| if (!change || !change.current_revision || |
| !change.revisions[change.current_revision]) { |
| return undefined; |
| } |
| const rev = change.revisions[change.current_revision]; |
| |
| if (!rev.uploader || |
| change.owner._account_id === rev.uploader._account_id) { |
| return undefined; |
| } |
| return rev.uploader; |
| } |
| |
| _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.dispatchEvent(new CustomEvent('cancel', { |
| composed: true, bubbles: false, |
| })); |
| this.$.textarea.closeDropdown(); |
| this._purgeReviewersPendingRemove(true); |
| this._rebuildReviewerArrays(this.change.reviewers, this._owner); |
| } |
| |
| _saveClickHandler(e) { |
| e.preventDefault(); |
| if (!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.$.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, |
| composed: true, |
| detail: {message: EMPTY_REPLY_MESSAGE}, |
| })); |
| return; |
| } |
| return this.send(this._includeComments, this.canBeStarted) |
| .then(keepReviewers => { |
| this._purgeReviewersPendingRemove(false, keepReviewers); |
| }) |
| .catch(err => { |
| this.dispatchEvent(new CustomEvent('show-error', { |
| bubbles: true, |
| composed: true, |
| detail: {message: `Error submitting review ${err}`}, |
| })); |
| }); |
| } |
| |
| _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.dispatchEvent(new CustomEvent('autogrow', { |
| composed: true, bubbles: true, |
| })); |
| } |
| |
| _handleLabelsChanged() { |
| this._labelsChanged = Object.keys( |
| this.$.labelScores.getLabelValues()).length !== 0; |
| } |
| |
| _isState(knownLatestState, value) { |
| return knownLatestState === value; |
| } |
| |
| _reload() { |
| this.dispatchEvent(new CustomEvent('reload', |
| {detail: {clearPatchset: true}, bubbles: false, composed: true})); |
| this.cancel(); |
| } |
| |
| _computeSendButtonLabel(canBeStarted) { |
| return canBeStarted ? ButtonLabels.SEND + ' and ' + |
| ButtonLabels.START_REVIEW : ButtonLabels.SEND; |
| } |
| |
| _computeSendButtonTooltip(canBeStarted) { |
| return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND; |
| } |
| |
| _computeSavingLabelClass(savingComments) { |
| return savingComments ? 'saving' : ''; |
| } |
| |
| _computeSendButtonDisabled( |
| canBeStarted, draftCommentThreads, text, reviewersMutated, |
| labelsChanged, includeComments, disabled, commentEditing, |
| attentionModified) { |
| // Polymer 2: check for undefined |
| if ([ |
| canBeStarted, |
| draftCommentThreads, |
| text, |
| reviewersMutated, |
| labelsChanged, |
| includeComments, |
| disabled, |
| commentEditing, |
| attentionModified, |
| ].includes(undefined)) { |
| return undefined; |
| } |
| if (commentEditing || disabled) { return true; } |
| if (canBeStarted === true) { return false; } |
| const hasDrafts = includeComments && draftCommentThreads.length; |
| return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged && |
| !attentionModified; |
| } |
| |
| _computePatchSetWarning(patchNum, labelsChanged) { |
| let str = `Patch ${patchNum} is not latest.`; |
| if (labelsChanged) { |
| str += ' Voting will have no effect.'; |
| } |
| return str; |
| } |
| |
| setPluginMessage(message) { |
| this._pluginMessage = message; |
| } |
| |
| _sendDisabledChanged(sendDisabled) { |
| this.dispatchEvent(new CustomEvent('send-disabled-changed')); |
| } |
| |
| _getReviewerSuggestionsProvider(change) { |
| const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI, |
| change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER); |
| provider.init(); |
| return provider; |
| } |
| |
| _getCcSuggestionsProvider(change) { |
| const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI, |
| change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.CC); |
| provider.init(); |
| return provider; |
| } |
| |
| _onThreadListModified() { |
| // TODO(taoalpha): this won't propogate the changes to the files |
| // should consider replacing this with either top level events |
| // or gerrit level events |
| |
| // emit the event so change-view can also get updated with latest changes |
| this.dispatchEvent(new CustomEvent('comment-refresh', { |
| composed: true, bubbles: true, |
| })); |
| } |
| |
| reportAttentionSetChanges(modified, addedSet, removedSet) { |
| const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED']; |
| const ownerId = (this.change && this.change.owner |
| && this.change.owner._account_id) || -1; |
| const selfId = (this._account && this._account._account_id) || -1; |
| for (const added of (addedSet || [])) { |
| const addedId = added.user; |
| const self = addedId === selfId ? '_SELF' : ''; |
| const role = addedId === ownerId ? '_OWNER' : '_REVIEWER'; |
| actions.push('ADD' + self + role); |
| } |
| for (const removed of (removedSet || [])) { |
| const removedId = removed.user; |
| const self = removedId === selfId ? '_SELF' : ''; |
| const role = removedId === ownerId ? '_OWNER' : '_REVIEWER'; |
| actions.push('REMOVE' + self + role); |
| } |
| this.reporting.reportInteraction('attention-set-actions', {actions}); |
| } |
| |
| _computeAllReviewers() { |
| return [...this._reviewers]; |
| } |
| } |
| |
| customElements.define(GrReplyDialog.is, GrReplyDialog); |