blob: fba4b53e11c2b5b24f2b755575e5a56c22779828 [file] [log] [blame]
/**
* @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.
*/
import '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
import '../../shared/gr-button/gr-button.js';
import '../../shared/gr-dialog/gr-dialog.js';
import '../../shared/gr-dropdown/gr-dropdown.js';
import '../../shared/gr-icons/gr-icons.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 '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js';
import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js';
import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js';
import '../gr-confirm-move-dialog/gr-confirm-move-dialog.js';
import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js';
import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog.js';
import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js';
import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
import '../../../styles/shared-styles.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-change-actions_html.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {appContext} from '../../../services/app-context.js';
import {
fetchChangeUpdates,
patchNumEquals,
} from '../../../utils/patch-set-util.js';
import {
changeIsOpen,
ListChangesOption,
listChangesOptionsToHex,
} from '../../../utils/change-util.js';
import {NotifyType} from '../../../constants/constants.js';
import {TargetElement, EventType} from '../../plugins/gr-plugin-types.js';
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',
};
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: 'rebase',
REBASE_EDIT: 'rebaseEdit',
READY: 'ready',
RESTORE: 'restore',
REVERT: 'revert',
REVERT_SUBMISSION: 'revert_submission',
REVIEWED: 'reviewed',
STOP_EDIT: 'stopEdit',
SUBMIT: 'submit',
UNIGNORE: 'unignore',
UNREVIEWED: 'unreviewed',
WIP: 'wip',
};
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...',
revert_submission: 'Reverting Submission...',
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.READY,
ChangeActions.REBASE_EDIT,
ChangeActions.RESTORE,
ChangeActions.REVERT,
ChangeActions.REVERT_SUBMISSION,
ChangeActions.STOP_EDIT,
QUICK_APPROVE_ACTION.key,
RevisionActions.REBASE,
RevisionActions.SUBMIT,
]);
const AWAIT_CHANGE_ATTEMPTS = 5;
const AWAIT_CHANGE_TIMEOUT_MS = 1000;
const REVERT_TYPES = {
REVERT_SINGLE_CHANGE: 1,
REVERT_SUBMISSION: 2,
};
/* Revert submission is skipped as the normal revert dialog will now show
the user a choice between reverting single change or an entire submission.
Hence, a second button is not needed.
*/
const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
const SKIP_ACTION_KEYS_ATTENTION_SET = [
ChangeActions.REVIEWED,
ChangeActions.UNREVIEWED,
];
/**
* @extends PolymerElement
*/
class GrChangeActions extends GestureEventListeners(
LegacyElementMixin(PolymerElement)) {
static get template() { return htmlTemplate; }
static get is() { return 'gr-change-actions'; }
/**
* Fired when the change should be reloaded.
*
* @event reload
*/
/**
* Fired when an action is tapped.
*
* @event custom-tap - naming pattern: <action key>-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
*/
constructor() {
super();
this.ActionType = ActionType;
this.ChangeActions = ChangeActions;
this.RevisionActions = RevisionActions;
this.reporting = appContext.reportingService;
}
static get properties() {
return {
/**
* @type {{
* _number: number,
* branch: string,
* id: string,
* project: string,
* subject: string,
* }}
*/
change: Object,
actions: {
type: Object,
value() { return {}; },
},
primaryActionKeys: {
type: Array,
value() {
return [
ChangeActions.READY,
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,
notify: true,
value() { return {}; },
},
// If property binds directly to [[revisionActions.submit]] it is not
// updated when revisionActions doesn't contain submit action.
/** @type {?} */
_revisionSubmitAction: {
type: Object,
computed: '_getSubmitAction(revisionActions)',
},
// If property binds directly to [[revisionActions.rebase]] it is not
// updated when revisionActions doesn't contain rebase action.
/** @type {?} */
_revisionRebaseAction: {
type: Object,
computed: '_getRebaseAction(revisionActions)',
},
privateByDefault: String,
_loading: {
type: Boolean,
value: true,
},
_actionLoadingMessage: {
type: String,
value: '',
},
_allActionValues: {
type: Array,
computed: '_computeAllActions(actions.*, revisionActions.*,' +
'primaryActionKeys.*, _additionalActions.*, change, ' +
'_config, _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,
},
_config: Object,
};
}
static get observers() {
return [
'_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
'_changeChanged(change)',
'_editStatusChanged(editMode, editPatchsetLoaded, ' +
'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
];
}
/** @override */
created() {
super.created();
this.addEventListener('fullscreen-overlay-opened',
() => this._handleHideBackgroundContent());
this.addEventListener('fullscreen-overlay-closed',
() => this._handleShowBackgroundContent());
}
/** @override */
ready() {
super.ready();
this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
this.$.restAPI.getConfig().then(config => {
this._config = config;
});
this._handleLoadingComplete();
}
_getSubmitAction(revisionActions) {
return this._getRevisionAction(revisionActions, 'submit', null);
}
_getRebaseAction(revisionActions) {
return this._getRevisionAction(revisionActions, 'rebase', null);
}
_getRevisionAction(revisionActions, actionName, emptyActionValue) {
if (!revisionActions) {
return undefined;
}
if (revisionActions[actionName] === undefined) {
// Return null to fire an event when reveisionActions was loaded
// but doesn't contain actionName. undefined doesn't fire an event
return emptyActionValue;
}
return revisionActions[actionName];
}
reload() {
if (!this.changeNum || !this.latestPatchNum) {
return Promise.resolve();
}
this._loading = true;
return this._getRevisionActions()
.then(revisionActions => {
if (!revisionActions) { return; }
this.revisionActions = revisionActions;
this._sendShowRevisionActions({
change: this.change,
revisionActions,
});
this._handleLoadingComplete();
})
.catch(err => {
this.dispatchEvent(new CustomEvent('show-alert', {
detail: {message: ERR_REVISION_ACTIONS},
composed: true, bubbles: true,
}));
this._loading = false;
throw err;
});
}
_handleLoadingComplete() {
getPluginLoader().awaitPluginsLoaded()
.then(() => this._loading = false);
}
_sendShowRevisionActions(detail) {
this.$.jsAPI.handleEvent(
EventType.SHOW_REVISION_ACTIONS,
detail
);
}
_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 => 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) {
// Polymer 2: check for undefined
if ([
actionsChangeRecord,
revisionActionsChangeRecord,
additionalActionsChangeRecord,
].includes(undefined)) {
return;
}
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 && this.actions[actionName]) {
delete this.actions[actionName];
// We assign a fake value of 'false' to support Polymer 2
// see https://github.com/Polymer/polymer/issues/2631
this.notifyPath('actions.' + actionName, false);
}
}
_editStatusChanged(editMode, editPatchsetLoaded,
editBasedOnCurrentPatchSet, disableEdit) {
// Polymer 2: check for undefined
if ([
editMode,
editBasedOnCurrentPatchSet,
disableEdit,
].includes(undefined)) {
return;
}
if (disableEdit) {
this._deleteAndNotify('publishEdit');
this._deleteAndNotify('rebaseEdit');
this._deleteAndNotify('deleteEdit');
this._deleteAndNotify('stopEdit');
this._deleteAndNotify('edit');
return;
}
if (this.actions && editPatchsetLoaded) {
// Only show actions that mutate an edit if an actual edit patch set
// is loaded.
if (changeIsOpen(this.change)) {
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.actions && changeIsOpen(this.change)) {
// 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 => 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 => 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 = {...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({...actions[a]});
});
let additionalActions = (additionalActionsChangeRecord &&
additionalActionsChangeRecord.base) || [];
additionalActions = additionalActions
.filter(a => a.__type === type)
.map(a => {
a.__primary = primaryActionKeys.includes(a.__key);
// Triggers a re-render by ensuring object inequality.
return {...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 (patchNumEquals(rev._number, patchNum)) {
return rev;
}
}
return null;
}
showRevertDialog() {
// The search is still broken if there is a " in the topic.
const query = `submissionid: "${this.change.submission_id}"`;
/* A chromium plugin expects that the modifyRevertMsg hook will only
be called after the revert button is pressed, hence we populate the
revert dialog after revert button is pressed. */
this.$.restAPI.getChanges('', query)
.then(changes => {
this.$.confirmRevertDialog.populate(this.change,
this.commitMessage, changes);
this._showActionDialog(this.$.confirmRevertDialog);
});
}
showRevertSubmissionDialog() {
const query = 'submissionid:' + this.change.submission_id;
this.$.restAPI.getChanges('', query)
.then(changes => {
this.$.confirmRevertSubmissionDialog.
_populateRevertSubmissionMessage(this.change, changes);
this._showActionDialog(this.$.confirmRevertSubmissionDialog);
});
}
_handleActionTap(e) {
e.preventDefault();
let el = dom(e).localTarget;
while (el.tagName.toLowerCase() !== '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.dispatchEvent(new CustomEvent(`${key}-tap`, {
detail: {node: el},
composed: true, bubbles: true,
}));
return;
}
const type = el.getAttribute('data-action-type');
this._handleAction(type, key);
}
_handleOverflowItemTap(e) {
e.preventDefault();
const el = dom(e).localTarget;
const key = e.detail.action.__key;
if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
key.indexOf('~') !== -1) {
this.dispatchEvent(new CustomEvent(`${key}-tap`, {
detail: {node: el},
composed: true, bubbles: true,
}));
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.REVERT_SUBMISSION:
this.showRevertSubmissionDialog();
break;
case ChangeActions.ABANDON:
this._showActionDialog(this.$.confirmAbandonDialog);
break;
case QUICK_APPROVE_ACTION.key:
action = this._allActionValues.find(o => 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') {
// Rebase button is only disabled when change has no parent(s).
return hasKnownChainState === false;
}
return !action.enabled;
}
_handleConfirmDialogCancel() {
this._hideAllDialogs();
}
_hideAllDialogs() {
const dialogEls =
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) {
this.dispatchEvent(new CustomEvent('show-alert', {
detail: {message: ERR_BRANCH_EMPTY},
composed: true, bubbles: true,
}));
return;
}
if (!el.message) {
this.dispatchEvent(new CustomEvent('show-alert', {
detail: {message: ERR_COMMIT_EMPTY},
composed: true, bubbles: true,
}));
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.dispatchEvent(new CustomEvent('show-alert', {
detail: {message: ERR_BRANCH_EMPTY},
composed: true, bubbles: true,
}));
return;
}
this.$.overlay.close();
el.hidden = true;
this._fireAction(
'/move',
this.actions.move,
false,
{
destination_branch: el.branch,
message: el.message,
}
);
}
_handleRevertDialogConfirm(e) {
const revertType = e.detail.revertType;
const message = e.detail.message;
const el = this.$.confirmRevertDialog;
this.$.overlay.close();
el.hidden = true;
switch (revertType) {
case REVERT_TYPES.REVERT_SINGLE_CHANGE:
this._fireAction('/revert', this.actions.revert, false,
{message});
break;
case REVERT_TYPES.REVERT_SUBMISSION:
this._fireAction('/revert_submission', this.actions.revert_submission,
false, {message});
break;
default:
console.error('invalid revert type');
}
}
_handleRevertSubmissionDialogConfirm() {
const el = this.$.confirmRevertSubmissionDialog;
this.$.overlay.close();
el.hidden = true;
this._fireAction('/revert_submission', this.actions.revert_submission,
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 => action.type === type && action.key === key);
}
_setLoadingOnButtonWithKey(type, key) {
this._actionLoadingMessage = this._computeLoadingLabel(key);
let buttonKey = key;
// TODO(dhruvsri): clean this up later
// If key is revert-submission, then button key should be 'revert'
if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
// Revert submission button no longer exists
buttonKey = ChangeActions.REVERT;
}
// If the action appears in the overflow menu.
if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
buttonKey);
return () => {
this._actionLoadingMessage = '';
this._disabledMenuActions = [];
};
}
// Otherwise it's a top-level action.
const buttonEl = this.shadowRoot
.querySelector(`[data-action-key="${buttonKey}"]`);
buttonEl.setAttribute('loading', true);
buttonEl.disabled = true;
return () => {
this._actionLoadingMessage = '';
buttonEl.removeAttribute('loading');
buttonEl.disabled = false;
};
}
/**
* @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(res => this._handleResponse(action, res));
}
_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(() => {
GerritNav.navigateToChange(obj);
});
break;
case RevisionActions.CHERRYPICK:
this._waitForChangeReachable(obj._number).then(() => {
GerritNav.navigateToChange(obj);
});
break;
case ChangeActions.DELETE:
if (action.__type === ActionType.CHANGE) {
GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
}
break;
case ChangeActions.WIP:
case ChangeActions.DELETE_EDIT:
case ChangeActions.PUBLISH_EDIT:
case ChangeActions.REBASE_EDIT:
case ChangeActions.REBASE:
case ChangeActions.SUBMIT:
this.dispatchEvent(new CustomEvent('reload',
{
detail: {clearPatchset: true},
bubbles: false,
composed: true,
}));
break;
case ChangeActions.REVERT_SUBMISSION:
if (!obj.revert_changes || !obj.revert_changes.length) return;
/* If there is only 1 change then gerrit will automatically
redirect to that change */
GerritNav.navigateToSearchQuery('topic: ' +
obj.revert_changes[0].topic);
break;
default:
this.dispatchEvent(new CustomEvent('reload',
{
detail: {action: action.__key, clearPatchset: true},
bubbles: false,
composed: true,
}));
break;
}
});
}
_handleShowRevertSubmissionChangesConfirm() {
this._hideAllDialogs();
}
_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.dispatchEvent(new CustomEvent('show-error', {
detail: {message: `Could not perform action: ${errText}`},
composed: true, bubbles: true,
}));
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 fetchChangeUpdates(this.change, this.$.restAPI)
.then(result => {
if (!result.isLatest) {
this.dispatchEvent(new CustomEvent('show-alert', {
detail: {
message: 'Cannot set label: a newer patch has been ' +
'uploaded to this change.',
action: 'Reload',
callback: () => {
this.dispatchEvent(new CustomEvent('reload',
{
detail: {clearPatchset: true},
bubbles: false,
composed: true,
}));
},
},
composed: true, bubbles: true,
}));
// 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 = '';
const query = `topic: "${this.change.topic}"`;
const options =
listChangesOptionsToHex(ListChangesOption.MESSAGES,
ListChangesOption.ALL_REVISIONS);
this.$.restAPI.getChanges('', query, undefined, options)
.then(changes => {
this.$.confirmCherrypick.updateChanges(changes);
this._showActionDialog(this.$.confirmCherrypick);
});
}
_handleMoveTap() {
this.$.confirmMove.branch = '';
this.$.confirmMove.message = '';
this._showActionDialog(this.$.confirmMove);
}
_handleDownloadTap() {
this.dispatchEvent(new CustomEvent('download-tap', {
composed: true, 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() {
// Type of payload is PublishChangeEditInput.
const payload = {notify: NotifyType.NONE};
this._fireAction('/edit:publish', this.actions.publishEdit, false, payload);
}
_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.
* @param {!Object} config server configuration info
* @return {!Array}
*/
_computeAllActions(changeActionsRecord, revisionActionsRecord,
primariesRecord, additionalActionsRecord, change, config) {
// Polymer 2: check for undefined
if ([
changeActionsRecord,
revisionActionsRecord,
primariesRecord,
additionalActionsRecord,
change,
].includes(undefined)) {
return [];
}
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((a, b) => this._actionComparator(a, b))
.map(action => {
if (ACTIONS_WITH_ICONS.has(action.__key)) {
action.icon = action.__key;
}
// TODO(brohlfs): Temporary hack until change 269573 is live in all
// backends.
if (action.__key === ChangeActions.READY) {
action.label = 'Mark as Active';
}
// End of hack
return action;
})
.filter(action => !this._shouldSkipAction(action, config));
}
_getActionPriority(action) {
if (action.__type && action.__key) {
const overrideAction = this._actionPriorityOverrides
.find(i => 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;
}
}
_shouldSkipAction(action, config) {
const skipActionKeys = [...SKIP_ACTION_KEYS];
const isAttentionSetEnabled = !!config && !!config.change
&& config.change.enable_attention_set;
if (isAttentionSetEnabled) {
skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
}
return skipActionKeys.includes(action.__key);
}
_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,
};
});
}
_computeRebaseOnCurrent(revisionRebaseAction) {
if (revisionRebaseAction) {
return !!revisionRebaseAction.enabled;
}
return null;
}
/**
* 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<boolean>}
*/
_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';
}
}
customElements.define(GrChangeActions.is, GrChangeActions);