/** * @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 ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.'; const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.'; const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.'; /** * @enum {string} */ const LabelStatus = { /** * This label provides what is necessary for submission. */ OK: 'OK', /** * This label prevents the change from being submitted. */ REJECT: 'REJECT', /** * The label may be set, but it's neither necessary for submission * nor does it block submission if set. */ MAY: 'MAY', /** * The label is required for submission, but has not been satisfied. */ NEED: 'NEED', /** * The label is required for submission, but is impossible to complete. * The likely cause is access has not been granted correctly by the * project owner or site administrator. */ IMPOSSIBLE: 'IMPOSSIBLE', OPTIONAL: 'OPTIONAL', }; // TODO(davido): Add the rest of the change actions. const ChangeActions = { ABANDON: 'abandon', DELETE: '/', DELETE_EDIT: 'deleteEdit', EDIT: 'edit', FOLLOW_UP: 'followup', IGNORE: 'ignore', MOVE: 'move', PRIVATE: 'private', PRIVATE_DELETE: 'private.delete', PUBLISH_EDIT: 'publishEdit', REBASE_EDIT: 'rebaseEdit', RESTORE: 'restore', REVERT: 'revert', REVIEWED: 'reviewed', STOP_EDIT: 'stopEdit', UNIGNORE: 'unignore', UNREVIEWED: 'unreviewed', WIP: 'wip', }; // TODO(andybons): Add the rest of the revision actions. const RevisionActions = { CHERRYPICK: 'cherrypick', REBASE: 'rebase', SUBMIT: 'submit', DOWNLOAD: 'download', }; const ActionLoadingLabels = { abandon: 'Abandoning...', cherrypick: 'Cherry-picking...', delete: 'Deleting...', move: 'Moving..', rebase: 'Rebasing...', restore: 'Restoring...', revert: 'Reverting...', submit: 'Submitting...', }; const ActionType = { CHANGE: 'change', REVISION: 'revision', }; const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_'; const QUICK_APPROVE_ACTION = { __key: 'review', __type: 'change', enabled: true, key: 'review', label: 'Quick approve', method: 'POST', }; const ActionPriority = { CHANGE: 2, DEFAULT: 0, PRIMARY: 3, REVIEW: -3, REVISION: 1, }; const DOWNLOAD_ACTION = { enabled: true, label: 'Download patch', title: 'Open download dialog', __key: 'download', __primary: false, __type: 'revision', }; const REBASE_EDIT = { enabled: true, label: 'Rebase edit', title: 'Rebase change edit', __key: 'rebaseEdit', __primary: false, __type: 'change', method: 'POST', }; const PUBLISH_EDIT = { enabled: true, label: 'Publish edit', title: 'Publish change edit', __key: 'publishEdit', __primary: false, __type: 'change', method: 'POST', }; const DELETE_EDIT = { enabled: true, label: 'Delete edit', title: 'Delete change edit', __key: 'deleteEdit', __primary: false, __type: 'change', method: 'DELETE', }; const EDIT = { enabled: true, label: 'Edit', title: 'Edit this change', __key: 'edit', __primary: false, __type: 'change', }; const STOP_EDIT = { enabled: true, label: 'Stop editing', title: 'Stop editing this change', __key: 'stopEdit', __primary: false, __type: 'change', }; // Set of keys that have icons. As more icons are added to gr-icons.html, this // set should be expanded. const ACTIONS_WITH_ICONS = new Set([ ChangeActions.ABANDON, ChangeActions.DELETE_EDIT, ChangeActions.EDIT, ChangeActions.PUBLISH_EDIT, ChangeActions.REBASE_EDIT, ChangeActions.RESTORE, ChangeActions.REVERT, ChangeActions.STOP_EDIT, QUICK_APPROVE_ACTION.key, RevisionActions.REBASE, RevisionActions.SUBMIT, ]); const AWAIT_CHANGE_ATTEMPTS = 5; const AWAIT_CHANGE_TIMEOUT_MS = 1000; Polymer({ is: 'gr-change-actions', /** * Fired when the change should be reloaded. * * @event reload-change */ /** * Fired when an action is tapped. * * @event -tap */ /** * Fires to show an alert when a send is attempted on the non-latest patch. * * @event show-alert */ /** * Fires when a change action fails. * * @event show-error */ properties: { /** * @type {{ * _number: number, * branch: string, * id: string, * project: string, * subject: string, * }} */ change: Object, actions: { type: Object, value() { return {}; }, }, primaryActionKeys: { type: Array, value() { return [ RevisionActions.SUBMIT, ]; }, }, disableEdit: { type: Boolean, value: false, }, _hasKnownChainState: { type: Boolean, value: false, }, _hideQuickApproveAction: { type: Boolean, value: false, }, changeNum: String, changeStatus: String, commitNum: String, hasParent: { type: Boolean, observer: '_computeChainState', }, latestPatchNum: String, commitMessage: { type: String, value: '', }, /** @type {?} */ revisionActions: { type: Object, value() { return {}; }, }, privateByDefault: String, _loading: { type: Boolean, value: true, }, _actionLoadingMessage: { type: String, value: '', }, _allActionValues: { type: Array, computed: '_computeAllActions(actions.*, revisionActions.*,' + 'primaryActionKeys.*, _additionalActions.*, change, ' + '_actionPriorityOverrides.*)', }, _topLevelActions: { type: Array, computed: '_computeTopLevelActions(_allActionValues.*, ' + '_hiddenActions.*, _overflowActions.*)', observer: '_filterPrimaryActions', }, _topLevelPrimaryActions: Array, _topLevelSecondaryActions: Array, _menuActions: { type: Array, computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*, ' + '_overflowActions.*)', }, _overflowActions: { type: Array, value() { const value = [ { type: ActionType.CHANGE, key: ChangeActions.WIP, }, { type: ActionType.CHANGE, key: ChangeActions.DELETE, }, { type: ActionType.REVISION, key: RevisionActions.CHERRYPICK, }, { type: ActionType.CHANGE, key: ChangeActions.MOVE, }, { type: ActionType.REVISION, key: RevisionActions.DOWNLOAD, }, { type: ActionType.CHANGE, key: ChangeActions.IGNORE, }, { type: ActionType.CHANGE, key: ChangeActions.UNIGNORE, }, { type: ActionType.CHANGE, key: ChangeActions.REVIEWED, }, { type: ActionType.CHANGE, key: ChangeActions.UNREVIEWED, }, { type: ActionType.CHANGE, key: ChangeActions.PRIVATE, }, { type: ActionType.CHANGE, key: ChangeActions.PRIVATE_DELETE, }, { type: ActionType.CHANGE, key: ChangeActions.FOLLOW_UP, }, ]; return value; }, }, _actionPriorityOverrides: { type: Array, value() { return []; }, }, _additionalActions: { type: Array, value() { return []; }, }, _hiddenActions: { type: Array, value() { return []; }, }, _disabledMenuActions: { type: Array, value() { return []; }, }, // editPatchsetLoaded == "does the current selected patch range have // 'edit' as one of either basePatchNum or patchNum". editPatchsetLoaded: { type: Boolean, value: false, }, // editMode == "is edit mode enabled in the file list". editMode: { type: Boolean, value: false, }, editBasedOnCurrentPatchSet: { type: Boolean, value: true, }, }, ActionType, ChangeActions, RevisionActions, behaviors: [ Gerrit.PatchSetBehavior, Gerrit.RESTClientBehavior, ], observers: [ '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)', '_changeChanged(change)', '_editStatusChanged(editMode, editPatchsetLoaded, ' + 'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)', ], listeners: { 'fullscreen-overlay-opened': '_handleHideBackgroundContent', 'fullscreen-overlay-closed': '_handleShowBackgroundContent', }, ready() { this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this); this._handleLoadingComplete(); }, reload() { if (!this.changeNum || !this.latestPatchNum) { return Promise.resolve(); } this._loading = true; return this._getRevisionActions().then(revisionActions => { if (!revisionActions) { return; } this.revisionActions = revisionActions; this._handleLoadingComplete(); }).catch(err => { this.fire('show-alert', {message: ERR_REVISION_ACTIONS}); this._loading = false; throw err; }); }, _handleLoadingComplete() { Gerrit.awaitPluginsLoaded().then(() => this._loading = false); }, _changeChanged() { this.reload(); }, addActionButton(type, label) { if (type !== ActionType.CHANGE && type !== ActionType.REVISION) { throw Error(`Invalid action type: ${type}`); } const action = { enabled: true, label, __type: type, __key: ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2), }; this.push('_additionalActions', action); return action.__key; }, removeActionButton(key) { const idx = this._indexOfActionButtonWithKey(key); if (idx === -1) { return; } this.splice('_additionalActions', idx, 1); }, setActionButtonProp(key, prop, value) { this.set([ '_additionalActions', this._indexOfActionButtonWithKey(key), prop, ], value); }, setActionOverflow(type, key, overflow) { if (type !== ActionType.CHANGE && type !== ActionType.REVISION) { throw Error(`Invalid action type given: ${type}`); } const index = this._getActionOverflowIndex(type, key); const action = { type, key, overflow, }; if (!overflow && index !== -1) { this.splice('_overflowActions', index, 1); } else if (overflow) { this.push('_overflowActions', action); } }, setActionPriority(type, key, priority) { if (type !== ActionType.CHANGE && type !== ActionType.REVISION) { throw Error(`Invalid action type given: ${type}`); } const index = this._actionPriorityOverrides.findIndex(action => { return action.type === type && action.key === key; }); const action = { type, key, priority, }; if (index !== -1) { this.set('_actionPriorityOverrides', index, action); } else { this.push('_actionPriorityOverrides', action); } }, setActionHidden(type, key, hidden) { if (type !== ActionType.CHANGE && type !== ActionType.REVISION) { throw Error(`Invalid action type given: ${type}`); } const idx = this._hiddenActions.indexOf(key); if (hidden && idx === -1) { this.push('_hiddenActions', key); } else if (!hidden && idx !== -1) { this.splice('_hiddenActions', idx, 1); } }, getActionDetails(action) { if (this.revisionActions[action]) { return this.revisionActions[action]; } else if (this.actions[action]) { return this.actions[action]; } }, _indexOfActionButtonWithKey(key) { for (let i = 0; i < this._additionalActions.length; i++) { if (this._additionalActions[i].__key === key) { return i; } } return -1; }, _getRevisionActions() { return this.$.restAPI.getChangeRevisionActions(this.changeNum, this.latestPatchNum); }, _shouldHideActions(actions, loading) { return loading || !actions || !actions.base || !actions.base.length; }, _keyCount(changeRecord) { return Object.keys((changeRecord && changeRecord.base) || {}).length; }, _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord, additionalActionsChangeRecord) { const additionalActions = (additionalActionsChangeRecord && additionalActionsChangeRecord.base) || []; this.hidden = this._keyCount(actionsChangeRecord) === 0 && this._keyCount(revisionActionsChangeRecord) === 0 && additionalActions.length === 0; this._actionLoadingMessage = ''; this._disabledMenuActions = []; const revisionActions = revisionActionsChangeRecord.base || {}; if (Object.keys(revisionActions).length !== 0) { if (!revisionActions.download) { this.set('revisionActions.download', DOWNLOAD_ACTION); } } }, /** * @param {string=} actionName */ _deleteAndNotify(actionName) { if (this.actions[actionName]) { delete this.actions[actionName]; this.notifyPath('actions.' + actionName); } }, _editStatusChanged(editMode, editPatchsetLoaded, editBasedOnCurrentPatchSet, disableEdit) { if (disableEdit) { this._deleteAndNotify('publishEdit'); this._deleteAndNotify('rebaseEdit'); this._deleteAndNotify('deleteEdit'); this._deleteAndNotify('stopEdit'); this._deleteAndNotify('edit'); return; } if (editPatchsetLoaded) { // Only show actions that mutate an edit if an actual edit patch set // is loaded. if (this.changeIsOpen(this.change.status)) { if (editBasedOnCurrentPatchSet) { if (!this.actions.publishEdit) { this.set('actions.publishEdit', PUBLISH_EDIT); } this._deleteAndNotify('rebaseEdit'); } else { if (!this.actions.rebaseEdit) { this.set('actions.rebaseEdit', REBASE_EDIT); } this._deleteAndNotify('publishEdit'); } } if (!this.actions.deleteEdit) { this.set('actions.deleteEdit', DELETE_EDIT); } } else { this._deleteAndNotify('publishEdit'); this._deleteAndNotify('rebaseEdit'); this._deleteAndNotify('deleteEdit'); } if (this.changeIsOpen(this.change.status)) { // Only show edit button if there is no edit patchset loaded and the // file list is not in edit mode. if (editPatchsetLoaded || editMode) { this._deleteAndNotify('edit'); } else { if (!this.actions.edit) { this.set('actions.edit', EDIT); } } // Only show STOP_EDIT if edit mode is enabled, but no edit patch set // is loaded. if (editMode && !editPatchsetLoaded) { if (!this.actions.stopEdit) { this.set('actions.stopEdit', STOP_EDIT); } } else { this._deleteAndNotify('stopEdit'); } } else { // Remove edit button. this._deleteAndNotify('edit'); } }, _getValuesFor(obj) { return Object.keys(obj).map(key => { return obj[key]; }); }, _getLabelStatus(label) { if (label.approved) { return LabelStatus.OK; } else if (label.rejected) { return LabelStatus.REJECT; } else if (label.optional) { return LabelStatus.OPTIONAL; } else { return LabelStatus.NEED; } }, /** * Get highest score for last missing permitted label for current change. * Returns null if no labels permitted or more than one label missing. * * @return {{label: string, score: string}|null} */ _getTopMissingApproval() { if (!this.change || !this.change.labels || !this.change.permitted_labels) { return null; } let result; for (const label in this.change.labels) { if (!(label in this.change.permitted_labels)) { continue; } if (this.change.permitted_labels[label].length === 0) { continue; } const status = this._getLabelStatus(this.change.labels[label]); if (status === LabelStatus.NEED) { if (result) { // More than one label is missing, so it's unclear which to quick // approve, return null; return null; } result = label; } else if (status === LabelStatus.REJECT || status === LabelStatus.IMPOSSIBLE) { return null; } } if (result) { const score = this.change.permitted_labels[result].slice(-1)[0]; const maxScore = Object.keys(this.change.labels[result].values).slice(-1)[0]; if (score === maxScore) { // Allow quick approve only for maximal score. return { label: result, score, }; } } return null; }, hideQuickApproveAction() { this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(sa => { return sa.key !== QUICK_APPROVE_ACTION.key; }); this._hideQuickApproveAction = true; }, _getQuickApproveAction() { if (this._hideQuickApproveAction) { return null; } const approval = this._getTopMissingApproval(); if (!approval) { return null; } const action = Object.assign({}, QUICK_APPROVE_ACTION); action.label = approval.label + approval.score; const review = { drafts: 'PUBLISH_ALL_REVISIONS', labels: {}, }; review.labels[approval.label] = approval.score; action.payload = review; return action; }, _getActionValues(actionsChangeRecord, primariesChangeRecord, additionalActionsChangeRecord, type) { if (!actionsChangeRecord || !primariesChangeRecord) { return []; } const actions = actionsChangeRecord.base || {}; const primaryActionKeys = primariesChangeRecord.base || []; const result = []; const values = this._getValuesFor( type === ActionType.CHANGE ? ChangeActions : RevisionActions); const pluginActions = []; Object.keys(actions).forEach(a => { actions[a].__key = a; actions[a].__type = type; actions[a].__primary = primaryActionKeys.includes(a); // Plugin actions always contain ~ in the key. if (a.indexOf('~') !== -1) { this._populateActionUrl(actions[a]); pluginActions.push(actions[a]); // Add server-side provided plugin actions to overflow menu. this._overflowActions.push({ type, key: a, }); return; } else if (!values.includes(a)) { return; } actions[a].label = this._getActionLabel(actions[a]); // Triggers a re-render by ensuring object inequality. result.push(Object.assign({}, actions[a])); }); let additionalActions = (additionalActionsChangeRecord && additionalActionsChangeRecord.base) || []; additionalActions = additionalActions.filter(a => { return a.__type === type; }).map(a => { a.__primary = primaryActionKeys.includes(a.__key); // Triggers a re-render by ensuring object inequality. return Object.assign({}, a); }); return result.concat(additionalActions).concat(pluginActions); }, _populateActionUrl(action) { const patchNum = action.__type === ActionType.REVISION ? this.latestPatchNum : null; this.$.restAPI.getChangeActionURL( this.changeNum, patchNum, '/' + action.__key) .then(url => action.__url = url); }, /** * Given a change action, return a display label that uses the appropriate * casing or includes explanatory details. */ _getActionLabel(action) { if (action.label === 'Delete') { // This label is common within change and revision actions. Make it more // explicit to the user. return 'Delete change'; } else if (action.label === 'WIP') { return 'Mark as work in progress'; } // Otherwise, just map the name to sentence case. return this._toSentenceCase(action.label); }, /** * Capitalize the first letter and lowecase all others. * @param {string} s * @return {string} */ _toSentenceCase(s) { if (!s.length) { return ''; } return s[0].toUpperCase() + s.slice(1).toLowerCase(); }, _computeLoadingLabel(action) { return ActionLoadingLabels[action] || 'Working...'; }, _canSubmitChange() { return this.$.jsAPI.canSubmitChange(this.change, this._getRevision(this.change, this.latestPatchNum)); }, _getRevision(change, patchNum) { for (const rev of Object.values(change.revisions)) { if (this.patchNumEquals(rev._number, patchNum)) { return rev; } } return null; }, _modifyRevertMsg() { return this.$.jsAPI.modifyRevertMsg(this.change, this.$.confirmRevertDialog.message, this.commitMessage); }, showRevertDialog() { this.$.confirmRevertDialog.populateRevertMessage( this.commitMessage, this.change.current_revision); this.$.confirmRevertDialog.message = this._modifyRevertMsg(); this._showActionDialog(this.$.confirmRevertDialog); }, _handleActionTap(e) { e.preventDefault(); let el = Polymer.dom(e).localTarget; while (el.is !== 'gr-button') { if (!el.parentElement) { return; } el = el.parentElement; } const key = el.getAttribute('data-action-key'); if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) || key.indexOf('~') !== -1) { this.fire(`${key}-tap`, {node: el}); return; } const type = el.getAttribute('data-action-type'); this._handleAction(type, key); }, _handleOveflowItemTap(e) { e.preventDefault(); const el = Polymer.dom(e).localTarget; const key = e.detail.action.__key; if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) || key.indexOf('~') !== -1) { this.fire(`${key}-tap`, {node: el}); return; } this._handleAction(e.detail.action.__type, e.detail.action.__key); }, _handleAction(type, key) { this.$.reporting.reportInteraction(`${type}-${key}`); switch (type) { case ActionType.REVISION: this._handleRevisionAction(key); break; case ActionType.CHANGE: this._handleChangeAction(key); break; default: this._fireAction(this._prependSlash(key), this.actions[key], false); } }, _handleChangeAction(key) { let action; switch (key) { case ChangeActions.REVERT: this.showRevertDialog(); break; case ChangeActions.ABANDON: this._showActionDialog(this.$.confirmAbandonDialog); break; case QUICK_APPROVE_ACTION.key: action = this._allActionValues.find(o => { return o.key === key; }); this._fireAction( this._prependSlash(key), action, true, action.payload); break; case ChangeActions.EDIT: this._handleEditTap(); break; case ChangeActions.STOP_EDIT: this._handleStopEditTap(); break; case ChangeActions.DELETE: this._handleDeleteTap(); break; case ChangeActions.DELETE_EDIT: this._handleDeleteEditTap(); break; case ChangeActions.FOLLOW_UP: this._handleFollowUpTap(); break; case ChangeActions.WIP: this._handleWipTap(); break; case ChangeActions.MOVE: this._handleMoveTap(); break; case ChangeActions.PUBLISH_EDIT: this._handlePublishEditTap(); break; case ChangeActions.REBASE_EDIT: this._handleRebaseEditTap(); break; default: this._fireAction(this._prependSlash(key), this.actions[key], false); } }, _handleRevisionAction(key) { switch (key) { case RevisionActions.REBASE: this._showActionDialog(this.$.confirmRebase); this.$.confirmRebase.fetchRecentChanges(); break; case RevisionActions.CHERRYPICK: this._handleCherrypickTap(); break; case RevisionActions.DOWNLOAD: this._handleDownloadTap(); break; case RevisionActions.SUBMIT: if (!this._canSubmitChange()) { return; } this._showActionDialog(this.$.confirmSubmitDialog); break; default: this._fireAction(this._prependSlash(key), this.revisionActions[key], true); } }, _prependSlash(key) { return key === '/' ? key : `/${key}`; }, /** * _hasKnownChainState set to true true if hasParent is defined (can be * either true or false). set to false otherwise. */ _computeChainState(hasParent) { this._hasKnownChainState = true; }, _calculateDisabled(action, hasKnownChainState) { if (action.__key === 'rebase' && hasKnownChainState === false) { return true; } return !action.enabled; }, _handleConfirmDialogCancel() { this._hideAllDialogs(); }, _hideAllDialogs() { const dialogEls = Polymer.dom(this.root).querySelectorAll('.confirmDialog'); for (const dialogEl of dialogEls) { dialogEl.hidden = true; } this.$.overlay.close(); }, _handleRebaseConfirm(e) { const el = this.$.confirmRebase; const payload = {base: e.detail.base}; this.$.overlay.close(); el.hidden = true; this._fireAction('/rebase', this.revisionActions.rebase, true, payload); }, _handleCherrypickConfirm() { this._handleCherryPickRestApi(false); }, _handleCherrypickConflictConfirm() { this._handleCherryPickRestApi(true); }, _handleCherryPickRestApi(conflicts) { const el = this.$.confirmCherrypick; if (!el.branch) { // TODO(davido): Fix error handling this.fire('show-alert', {message: ERR_BRANCH_EMPTY}); return; } if (!el.message) { this.fire('show-alert', {message: ERR_COMMIT_EMPTY}); return; } this.$.overlay.close(); el.hidden = true; this._fireAction( '/cherrypick', this.revisionActions.cherrypick, true, { destination: el.branch, base: el.baseCommit ? el.baseCommit : null, message: el.message, allow_conflicts: conflicts, } ); }, _handleMoveConfirm() { const el = this.$.confirmMove; if (!el.branch) { this.fire('show-alert', {message: ERR_BRANCH_EMPTY}); return; } this.$.overlay.close(); el.hidden = true; this._fireAction( '/move', this.actions.move, false, { destination_branch: el.branch, message: el.message, } ); }, _handleRevertDialogConfirm() { const el = this.$.confirmRevertDialog; this.$.overlay.close(); el.hidden = true; this._fireAction('/revert', this.actions.revert, false, {message: el.message}); }, _handleAbandonDialogConfirm() { const el = this.$.confirmAbandonDialog; this.$.overlay.close(); el.hidden = true; this._fireAction('/abandon', this.actions.abandon, false, {message: el.message}); }, _handleCreateFollowUpChange() { this.$.createFollowUpChange.handleCreateChange(); this._handleCloseCreateFollowUpChange(); }, _handleCloseCreateFollowUpChange() { this.$.overlay.close(); }, _handleDeleteConfirm() { this._fireAction('/', this.actions[ChangeActions.DELETE], false); }, _handleDeleteEditConfirm() { this._hideAllDialogs(); this._fireAction('/edit', this.actions.deleteEdit, false); }, _handleSubmitConfirm() { if (!this._canSubmitChange()) { return; } this._hideAllDialogs(); this._fireAction('/submit', this.revisionActions.submit, true); }, _getActionOverflowIndex(type, key) { return this._overflowActions.findIndex(action => { return action.type === type && action.key === key; }); }, _setLoadingOnButtonWithKey(type, key) { this._actionLoadingMessage = this._computeLoadingLabel(key); // If the action appears in the overflow menu. if (this._getActionOverflowIndex(type, key) !== -1) { this.push('_disabledMenuActions', key === '/' ? 'delete' : key); return function() { this._actionLoadingMessage = ''; this._disabledMenuActions = []; }.bind(this); } // Otherwise it's a top-level action. const buttonEl = this.$$(`[data-action-key="${key}"]`); buttonEl.setAttribute('loading', true); buttonEl.disabled = true; return function() { this._actionLoadingMessage = ''; buttonEl.removeAttribute('loading'); buttonEl.disabled = false; }.bind(this); }, /** * @param {string} endpoint * @param {!Object|undefined} action * @param {boolean} revAction * @param {!Object|string=} opt_payload */ _fireAction(endpoint, action, revAction, opt_payload) { const cleanupFn = this._setLoadingOnButtonWithKey(action.__type, action.__key); this._send(action.method, opt_payload, endpoint, revAction, cleanupFn, action).then(this._handleResponse.bind(this, action)); }, _showActionDialog(dialog) { this._hideAllDialogs(); dialog.hidden = false; this.$.overlay.open().then(() => { if (dialog.resetFocus) { dialog.resetFocus(); } }); }, // TODO(rmistry): Redo this after // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved. _setLabelValuesOnRevert(newChangeId) { const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change); if (!labels) { return Promise.resolve(); } return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels}); }, _handleResponse(action, response) { if (!response) { return; } return this.$.restAPI.getResponseObject(response).then(obj => { switch (action.__key) { case ChangeActions.REVERT: this._waitForChangeReachable(obj._number) .then(() => this._setLabelValuesOnRevert(obj._number)) .then(() => { Gerrit.Nav.navigateToChange(obj); }); break; case RevisionActions.CHERRYPICK: this._waitForChangeReachable(obj._number).then(() => { Gerrit.Nav.navigateToChange(obj); }); break; case ChangeActions.DELETE: if (action.__type === ActionType.CHANGE) { Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getUrlForRoot()); } break; case ChangeActions.WIP: case ChangeActions.DELETE_EDIT: case ChangeActions.PUBLISH_EDIT: case ChangeActions.REBASE_EDIT: Gerrit.Nav.navigateToChange(this.change); break; default: this.dispatchEvent(new CustomEvent('reload-change', {detail: {action: action.__key}, bubbles: false})); break; } }); }, _handleResponseError(action, response, body) { if (action && action.__key === RevisionActions.CHERRYPICK) { if (response && response.status === 409 && body && !body.allow_conflicts) { return this._showActionDialog( this.$.confirmCherrypickConflict); } } return response.text().then(errText => { this.fire('show-error', {message: `Could not perform action: ${errText}`}); if (!errText.startsWith('Change is already up to date')) { throw Error(errText); } }); }, /** * @param {string} method * @param {string|!Object|undefined} payload * @param {string} actionEndpoint * @param {boolean} revisionAction * @param {?Function} cleanupFn * @param {!Object|undefined} action */ _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) { const handleError = response => { cleanupFn.call(this); this._handleResponseError(action, response, payload); }; return this.fetchChangeUpdates(this.change, this.$.restAPI) .then(result => { if (!result.isLatest) { this.fire('show-alert', { message: 'Cannot set label: a newer patch has been ' + 'uploaded to this change.', action: 'Reload', callback: () => { // Load the current change without any patch range. Gerrit.Nav.navigateToChange(this.change); }, }); // Because this is not a network error, call the cleanup function // but not the error handler. cleanupFn(); return Promise.resolve(); } const patchNum = revisionAction ? this.latestPatchNum : null; return this.$.restAPI.executeChangeAction(this.changeNum, method, actionEndpoint, patchNum, payload, handleError) .then(response => { cleanupFn.call(this); return response; }); }); }, _handleAbandonTap() { this._showActionDialog(this.$.confirmAbandonDialog); }, _handleCherrypickTap() { this.$.confirmCherrypick.branch = ''; this._showActionDialog(this.$.confirmCherrypick); }, _handleMoveTap() { this.$.confirmMove.branch = ''; this.$.confirmMove.message = ''; this._showActionDialog(this.$.confirmMove); }, _handleDownloadTap() { this.fire('download-tap', null, {bubbles: false}); }, _handleDeleteTap() { this._showActionDialog(this.$.confirmDeleteDialog); }, _handleDeleteEditTap() { this._showActionDialog(this.$.confirmDeleteEditDialog); }, _handleFollowUpTap() { this._showActionDialog(this.$.createFollowUpDialog); }, _handleWipTap() { this._fireAction('/wip', this.actions.wip, false); }, _handlePublishEditTap() { this._fireAction('/edit:publish', this.actions.publishEdit, false); }, _handleRebaseEditTap() { this._fireAction('/edit:rebase', this.actions.rebaseEdit, false); }, _handleHideBackgroundContent() { this.$.mainContent.classList.add('overlayOpen'); }, _handleShowBackgroundContent() { this.$.mainContent.classList.remove('overlayOpen'); }, /** * Merge sources of change actions into a single ordered array of action * values. * @param {!Array} changeActionsRecord * @param {!Array} revisionActionsRecord * @param {!Array} primariesRecord * @param {!Array} additionalActionsRecord * @param {!Object} change The change object. * @return {!Array} */ _computeAllActions(changeActionsRecord, revisionActionsRecord, primariesRecord, additionalActionsRecord, change) { const revisionActionValues = this._getActionValues(revisionActionsRecord, primariesRecord, additionalActionsRecord, ActionType.REVISION); const changeActionValues = this._getActionValues(changeActionsRecord, primariesRecord, additionalActionsRecord, ActionType.CHANGE); const quickApprove = this._getQuickApproveAction(); if (quickApprove) { changeActionValues.unshift(quickApprove); } return revisionActionValues .concat(changeActionValues) .sort(this._actionComparator.bind(this)) .map(action => { if (ACTIONS_WITH_ICONS.has(action.__key)) { action.icon = action.__key; } return action; }); }, _getActionPriority(action) { if (action.__type && action.__key) { const overrideAction = this._actionPriorityOverrides.find(i => { return i.type === action.__type && i.key === action.__key; }); if (overrideAction !== undefined) { return overrideAction.priority; } } if (action.__key === 'review') { return ActionPriority.REVIEW; } else if (action.__primary) { return ActionPriority.PRIMARY; } else if (action.__type === ActionType.CHANGE) { return ActionPriority.CHANGE; } else if (action.__type === ActionType.REVISION) { return ActionPriority.REVISION; } return ActionPriority.DEFAULT; }, /** * Sort comparator to define the order of change actions. */ _actionComparator(actionA, actionB) { const priorityDelta = this._getActionPriority(actionA) - this._getActionPriority(actionB); // Sort by the button label if same priority. if (priorityDelta === 0) { return actionA.label > actionB.label ? 1 : -1; } else { return priorityDelta; } }, _computeTopLevelActions(actionRecord, hiddenActionsRecord) { const hiddenActions = hiddenActionsRecord.base || []; return actionRecord.base.filter(a => { const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1; return !(overflow || hiddenActions.includes(a.__key)); }); }, _filterPrimaryActions(_topLevelActions) { this._topLevelPrimaryActions = _topLevelActions.filter(action => action.__primary); this._topLevelSecondaryActions = _topLevelActions.filter(action => !action.__primary); }, _computeMenuActions(actionRecord, hiddenActionsRecord) { const hiddenActions = hiddenActionsRecord.base || []; return actionRecord.base.filter(a => { const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1; return overflow && !hiddenActions.includes(a.__key); }).map(action => { let key = action.__key; if (key === '/') { key = 'delete'; } return { name: action.label, id: `${key}-${action.__type}`, action, tooltip: action.title, }; }); }, /** * Occasionally, a change created by a change action is not yet knwon to the * API for a brief time. Wait for the given change number to be recognized. * * Returns a promise that resolves with true if a request is recognized, or * false if the change was never recognized after all attempts. * * @param {number} changeNum * @return {Promise} */ _waitForChangeReachable(changeNum) { let attempsRemaining = AWAIT_CHANGE_ATTEMPTS; return new Promise(resolve => { const check = () => { attempsRemaining--; // Pass a no-op error handler to avoid the "not found" error toast. this.$.restAPI.getChange(changeNum, () => {}).then(response => { // If the response is 404, the response will be undefined. if (response) { resolve(true); return; } if (attempsRemaining) { this.async(check, AWAIT_CHANGE_TIMEOUT_MS); } else { resolve(false); } }); }; check(); }); }, _handleEditTap() { this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false})); }, _handleStopEditTap() { this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false})); }, _computeHasTooltip(title) { return !!title; }, _computeHasIcon(action) { return action.icon ? '' : 'hidden'; }, }); })();