Files
gerrit/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
Ben Rohlfs 7b71b11025 Remove all for...in loops
Change-Id: Ia14fd3730cc94f2b69ad8931b3da397483dea54f
2021-02-12 11:57:26 +01:00

2121 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-dropdown/gr-dropdown';
import '../../shared/gr-icons/gr-icons';
import '../../shared/gr-overlay/gr-overlay';
import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
import '../../../styles/shared-styles';
import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-change-actions_html';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {appContext} from '../../../services/app-context';
import {fetchChangeUpdates, CURRENT} from '../../../utils/patch-set-util';
import {
changeIsOpen,
isOwner,
ListChangesOption,
listChangesOptionsToHex,
} from '../../../utils/change-util';
import {
ChangeStatus,
DraftsAction,
HttpMethod,
NotifyType,
} from '../../../constants/constants';
import {
EventType as PluginEventType,
TargetElement,
} from '../../plugins/gr-plugin-types';
import {customElement, observe, property} from '@polymer/decorators';
import {
ActionPriority,
ActionType,
ErrorCallback,
} from '../../../services/gr-rest-api/gr-rest-api';
import {
AccountInfo,
ActionInfo,
ActionNameToActionInfoMap,
BranchName,
ChangeInfo,
ChangeViewChangeInfo,
CherryPickInput,
CommitId,
InheritedBooleanInfo,
isDetailedLabelInfo,
isQuickLabelInfo,
LabelInfo,
NumericChangeId,
PatchSetNum,
PropertyType,
RequestPayload,
RevertSubmissionInfo,
ReviewInput,
ServerInfo,
} from '../../../types/common';
import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
import {
ConfirmRevertEventDetail,
GrConfirmRevertDialog,
RevertType,
} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
import {GrConfirmCherrypickConflictDialog} from '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
import {
ConfirmRebaseEventDetail,
GrConfirmRebaseDialog,
} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {GrButton} from '../../shared/gr-button/gr-button';
import {
ChangeActions,
GrChangeActionsElement,
PrimaryActionKey,
RevisionActions,
UIActionInfo,
} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
import {fireAlert} from '../../../utils/event-util';
import {
CODE_REVIEW,
getApprovalInfo,
getVotingRange,
} from '../../../utils/label-util';
import {CommentThread} from '../../../utils/comment-util';
import {ShowAlertEventDetail} from '../../../types/events';
const ERR_BRANCH_EMPTY = 'The destination branch cant be empty.';
const ERR_COMMIT_EMPTY = 'The commit message cant be empty.';
const ERR_REVISION_ACTIONS = 'Couldnt load revision actions.';
enum 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 ActionLoadingLabels: {[actionKey: string]: string} = {
abandon: 'Abandoning...',
cherrypick: 'Cherry-picking...',
delete: 'Deleting...',
move: 'Moving..',
rebase: 'Rebasing...',
restore: 'Restoring...',
revert: 'Reverting...',
revert_submission: 'Reverting Submission...',
submit: 'Submitting...',
};
const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
interface QuickApproveUIActionInfo extends UIActionInfo {
key: string;
payload?: RequestPayload;
}
const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = {
__key: 'review',
__type: ActionType.CHANGE,
enabled: true,
key: 'review',
label: 'Quick approve',
method: HttpMethod.POST,
};
function isQuckApproveAction(
action: UIActionInfo
): action is QuickApproveUIActionInfo {
return (action as QuickApproveUIActionInfo).key === QUICK_APPROVE_ACTION.key;
}
const DOWNLOAD_ACTION: UIActionInfo = {
enabled: true,
label: 'Download patch',
title: 'Open download dialog',
__key: 'download',
__primary: false,
__type: ActionType.REVISION,
};
const REBASE_EDIT: UIActionInfo = {
enabled: true,
label: 'Rebase edit',
title: 'Rebase change edit',
__key: 'rebaseEdit',
__primary: false,
__type: ActionType.CHANGE,
method: HttpMethod.POST,
};
const PUBLISH_EDIT: UIActionInfo = {
enabled: true,
label: 'Publish edit',
title: 'Publish change edit',
__key: 'publishEdit',
__primary: false,
__type: ActionType.CHANGE,
method: HttpMethod.POST,
};
const DELETE_EDIT: UIActionInfo = {
enabled: true,
label: 'Delete edit',
title: 'Delete change edit',
__key: 'deleteEdit',
__primary: false,
__type: ActionType.CHANGE,
method: HttpMethod.DELETE,
};
const EDIT: UIActionInfo = {
enabled: true,
label: 'Edit',
title: 'Edit this change',
__key: 'edit',
__primary: false,
__type: ActionType.CHANGE,
};
const STOP_EDIT: UIActionInfo = {
enabled: true,
label: 'Stop editing',
title: 'Stop editing this change',
__key: 'stopEdit',
__primary: false,
__type: ActionType.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 EDIT_ACTIONS: Set<string> = new Set([
ChangeActions.DELETE_EDIT,
ChangeActions.EDIT,
ChangeActions.PUBLISH_EDIT,
ChangeActions.REBASE_EDIT,
ChangeActions.STOP_EDIT,
]);
const AWAIT_CHANGE_ATTEMPTS = 5;
const AWAIT_CHANGE_TIMEOUT_MS = 1000;
/* 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,
];
function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
// TODO(TS): Remove this function. The gr-change-actions adds properties
// to existing ActionInfo objects instead of creating a new objects. This
// function checks, that 'action' has all property required by UIActionInfo.
// In the future, we should avoid updates of an existing ActionInfos and
// instead create a new object to make code cleaner. However, at the current
// state this is unsafe, because other code can expect these properties to be
// set in ActionInfo.
if (!action) {
throw new Error('action is undefined');
}
const result = action as UIActionInfo;
if (result.__key === undefined || result.__type === undefined) {
throw new Error('action is not an UIActionInfo');
}
return result;
}
interface MenuAction {
name: string;
id: string;
action: UIActionInfo;
tooltip?: string;
}
interface OverflowAction {
type: ActionType;
key: string;
overflow?: boolean;
}
interface ActionPriorityOverride {
type: ActionType.CHANGE | ActionType.REVISION;
key: string;
priority: ActionPriority;
}
interface ChangeActionDialog extends HTMLElement {
resetFocus?(): void;
init?(): void;
}
export interface GrChangeActions {
$: {
mainContent: Element;
overlay: GrOverlay;
confirmRebase: GrConfirmRebaseDialog;
confirmCherrypick: GrConfirmCherrypickDialog;
confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
confirmMove: GrConfirmMoveDialog;
confirmRevertDialog: GrConfirmRevertDialog;
confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
confirmAbandonDialog: GrConfirmAbandonDialog;
confirmSubmitDialog: GrConfirmSubmitDialog;
createFollowUpDialog: GrDialog;
createFollowUpChange: GrCreateChangeDialog;
confirmDeleteDialog: GrDialog;
confirmDeleteEditDialog: GrDialog;
};
}
@customElement('gr-change-actions')
export class GrChangeActions
extends GestureEventListeners(LegacyElementMixin(PolymerElement))
implements GrChangeActionsElement {
static get template() {
return htmlTemplate;
}
/**
* 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
*/
// TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
// properties are replaced with enums everywhere and remove them from
// the GrChangeActions class
ActionType = ActionType;
ChangeActions = ChangeActions;
RevisionActions = RevisionActions;
reporting = appContext.reportingService;
private readonly jsAPI = appContext.jsApiService;
@property({type: Object})
change?: ChangeViewChangeInfo;
@property({type: Object})
actions: ActionNameToActionInfoMap = {};
@property({type: Array})
primaryActionKeys: PrimaryActionKey[] = [
ChangeActions.READY,
RevisionActions.SUBMIT,
];
@property({type: Boolean})
disableEdit = false;
@property({type: Boolean})
_hasKnownChainState = false;
@property({type: Boolean})
_hideQuickApproveAction = false;
@property({type: Object})
account?: AccountInfo;
@property({type: String})
changeNum?: NumericChangeId;
@property({type: String})
changeStatus?: ChangeStatus;
@property({type: String})
commitNum?: CommitId;
@property({type: Boolean, observer: '_computeChainState'})
hasParent?: boolean;
@property({type: String})
latestPatchNum?: PatchSetNum;
@property({type: String})
commitMessage = '';
@property({type: Object, notify: true})
revisionActions: ActionNameToActionInfoMap = {};
@property({type: Object, computed: '_getSubmitAction(revisionActions)'})
_revisionSubmitAction?: ActionInfo | null;
@property({type: Object, computed: '_getRebaseAction(revisionActions)'})
_revisionRebaseAction?: ActionInfo | null;
@property({type: String})
privateByDefault?: InheritedBooleanInfo;
@property({type: Boolean})
_loading = true;
@property({type: String})
_actionLoadingMessage = '';
@property({type: Array})
commentThreads: CommentThread[] = [];
@property({
type: Array,
computed:
'_computeAllActions(actions.*, revisionActions.*,' +
'primaryActionKeys.*, _additionalActions.*, change, ' +
'_config, _actionPriorityOverrides.*)',
})
_allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
@property({
type: Array,
computed:
'_computeTopLevelActions(_allActionValues.*, ' +
'_hiddenActions.*, editMode, _overflowActions.*)',
observer: '_filterPrimaryActions',
})
_topLevelActions?: UIActionInfo[];
@property({type: Array})
_topLevelPrimaryActions?: UIActionInfo[];
@property({type: Array})
_topLevelSecondaryActions?: UIActionInfo[];
@property({
type: Array,
computed:
'_computeMenuActions(_allActionValues.*, ' +
'_hiddenActions.*, _overflowActions.*)',
})
_menuActions?: MenuAction[];
@property({type: Array})
_overflowActions: OverflowAction[] = [
{
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,
},
];
@property({type: Array})
_actionPriorityOverrides: ActionPriorityOverride[] = [];
@property({type: Array})
_additionalActions: UIActionInfo[] = [];
@property({type: Array})
_hiddenActions: string[] = [];
@property({type: Array})
_disabledMenuActions: string[] = [];
@property({type: Boolean})
editPatchsetLoaded = false;
@property({type: Boolean})
editMode = false;
@property({type: Boolean})
editBasedOnCurrentPatchSet = true;
@property({type: Object})
_config?: ServerInfo;
private restApiService = appContext.restApiService;
/** @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.restApiService.getConfig().then(config => {
this._config = config;
});
this._handleLoadingComplete();
}
_getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
return this._getRevisionAction(revisionActions, 'submit');
}
_getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
return this._getRevisionAction(revisionActions, 'rebase');
}
_getRevisionAction(
revisionActions: ActionNameToActionInfoMap,
actionName: string
) {
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 null;
}
return revisionActions[actionName];
}
reload() {
if (!this.changeNum || !this.latestPatchNum || !this.change) {
return Promise.resolve();
}
const change = this.change;
this._loading = true;
return this.restApiService
.getChangeRevisionActions(this.changeNum, this.latestPatchNum)
.then(revisionActions => {
if (!revisionActions) {
return;
}
this.revisionActions = revisionActions;
this._sendShowRevisionActions({
change,
revisionActions,
});
this._handleLoadingComplete();
})
.catch(err => {
fireAlert(this, ERR_REVISION_ACTIONS);
this._loading = false;
throw err;
});
}
_handleLoadingComplete() {
getPluginLoader()
.awaitPluginsLoaded()
.then(() => (this._loading = false));
}
_sendShowRevisionActions(detail: {
change: ChangeInfo;
revisionActions: ActionNameToActionInfoMap;
}) {
this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
}
@observe('change')
_changeChanged() {
this.reload();
}
addActionButton(type: ActionType, label: string) {
if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
throw Error(`Invalid action type: ${type}`);
}
const action: UIActionInfo = {
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: string) {
const idx = this._indexOfActionButtonWithKey(key);
if (idx === -1) {
return;
}
this.splice('_additionalActions', idx, 1);
}
setActionButtonProp<T extends keyof UIActionInfo>(
key: string,
prop: T,
value: UIActionInfo[T]
) {
this.set(
['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
value
);
}
setActionOverflow(type: ActionType, key: string, overflow: boolean) {
if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
throw Error(`Invalid action type given: ${type}`);
}
const index = this._getActionOverflowIndex(type, key);
const action: OverflowAction = {
type,
key,
overflow,
};
if (!overflow && index !== -1) {
this.splice('_overflowActions', index, 1);
} else if (overflow) {
this.push('_overflowActions', action);
}
}
setActionPriority(
type: ActionType.CHANGE | ActionType.REVISION,
key: string,
priority: ActionPriority
) {
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: ActionPriorityOverride = {
type,
key,
priority,
};
if (index !== -1) {
this.set('_actionPriorityOverrides', index, action);
} else {
this.push('_actionPriorityOverrides', action);
}
}
setActionHidden(
type: ActionType.CHANGE | ActionType.REVISION,
key: string,
hidden: boolean
) {
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(actionName: string) {
if (this.revisionActions[actionName]) {
return this.revisionActions[actionName];
} else if (this.actions[actionName]) {
return this.actions[actionName];
} else {
return undefined;
}
}
_indexOfActionButtonWithKey(key: string) {
for (let i = 0; i < this._additionalActions.length; i++) {
if (this._additionalActions[i].__key === key) {
return i;
}
}
return -1;
}
_shouldHideActions(
actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
loading?: boolean
) {
return loading || !actions || !actions.base || !actions.base.length;
}
_keyCount(
changeRecord?: PolymerDeepPropertyChange<
ActionNameToActionInfoMap,
ActionNameToActionInfoMap
>
) {
return Object.keys(changeRecord?.base || {}).length;
}
@observe('actions.*', 'revisionActions.*', '_additionalActions.*')
_actionsChanged(
actionsChangeRecord?: PolymerDeepPropertyChange<
ActionNameToActionInfoMap,
ActionNameToActionInfoMap
>,
revisionActionsChangeRecord?: PolymerDeepPropertyChange<
ActionNameToActionInfoMap,
ActionNameToActionInfoMap
>,
additionalActionsChangeRecord?: PolymerDeepPropertyChange<
UIActionInfo[],
UIActionInfo[]
>
) {
// Polymer 2: check for undefined
if (
actionsChangeRecord === undefined ||
revisionActionsChangeRecord === undefined ||
additionalActionsChangeRecord === undefined
) {
return;
}
const additionalActions =
(additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
[];
this.hidden =
this._keyCount(actionsChangeRecord) === 0 &&
this._keyCount(revisionActionsChangeRecord) === 0 &&
additionalActions.length === 0;
this._actionLoadingMessage = '';
this._actionLoadingMessage = '';
this._disabledMenuActions = [];
const revisionActions = revisionActionsChangeRecord.base || {};
if (Object.keys(revisionActions).length !== 0) {
if (!revisionActions.download) {
this.set('revisionActions.download', DOWNLOAD_ACTION);
}
}
}
_deleteAndNotify(actionName: string) {
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);
}
}
@observe(
'editMode',
'editPatchsetLoaded',
'editBasedOnCurrentPatchSet',
'disableEdit',
'actions.*',
'change.*'
)
_editStatusChanged(
editMode: boolean,
editPatchsetLoaded: boolean,
editBasedOnCurrentPatchSet: boolean,
disableEdit: boolean,
actionsChangeRecord?: PolymerDeepPropertyChange<
ActionNameToActionInfoMap,
ActionNameToActionInfoMap
>,
changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
) {
if (actionsChangeRecord === undefined || changeChangeRecord === undefined) {
return;
}
if (disableEdit) {
this._deleteAndNotify('publishEdit');
this._deleteAndNotify('rebaseEdit');
this._deleteAndNotify('deleteEdit');
this._deleteAndNotify('stopEdit');
this._deleteAndNotify('edit');
return;
}
const actions = actionsChangeRecord.base;
const change = changeChangeRecord.base;
if (actions && editPatchsetLoaded) {
// Only show actions that mutate an edit if an actual edit patch set
// is loaded.
if (changeIsOpen(change)) {
if (editBasedOnCurrentPatchSet) {
if (!actions.publishEdit) {
this.set('actions.publishEdit', PUBLISH_EDIT);
}
this._deleteAndNotify('rebaseEdit');
} else {
if (!actions.rebaseEdit) {
this.set('actions.rebaseEdit', REBASE_EDIT);
}
this._deleteAndNotify('publishEdit');
}
}
if (!actions.deleteEdit) {
this.set('actions.deleteEdit', DELETE_EDIT);
}
} else {
this._deleteAndNotify('publishEdit');
this._deleteAndNotify('rebaseEdit');
this._deleteAndNotify('deleteEdit');
}
if (actions && changeIsOpen(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 (!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 (!actions.stopEdit) {
this.set('actions.stopEdit', STOP_EDIT);
fireAlert(this, 'Change is in edit mode');
}
} else {
this._deleteAndNotify('stopEdit');
}
} else {
// Remove edit button.
this._deleteAndNotify('edit');
}
}
_getValuesFor<T>(obj: {[key: string]: T}): T[] {
return Object.keys(obj).map(key => obj[key]);
}
_getLabelStatus(label: LabelInfo): LabelStatus {
if (isQuickLabelInfo(label)) {
if (label.approved) {
return LabelStatus.OK;
} else if (label.rejected) {
return LabelStatus.REJECT;
}
}
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.
*/
_getTopMissingApproval() {
if (!this.change || !this.change.labels || !this.change.permitted_labels) {
return null;
}
if (this.change && this.change.status === ChangeStatus.MERGED) {
return null;
}
let result;
for (const [label, labelInfo] of Object.entries(this.change.labels)) {
if (!(label in this.change.permitted_labels)) {
continue;
}
if (this.change.permitted_labels[label].length === 0) {
continue;
}
const status = this._getLabelStatus(labelInfo);
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;
}
}
// Allow the user to use quick approve to vote the max score on code review
// even if it is already granted by someone else. Does not apply if the
// user owns the change or has already granted the max score themselves.
const codeReviewLabel = this.change.labels[CODE_REVIEW];
const codeReviewPermittedValues = this.change.permitted_labels[CODE_REVIEW];
if (
!result &&
codeReviewLabel &&
codeReviewPermittedValues &&
this.account?._account_id &&
isDetailedLabelInfo(codeReviewLabel) &&
this._getLabelStatus(codeReviewLabel) === LabelStatus.OK &&
!isOwner(this.change, this.account) &&
getApprovalInfo(codeReviewLabel, this.account)?.value !==
getVotingRange(codeReviewLabel)?.max
) {
result = CODE_REVIEW;
}
if (result) {
const labelInfo = this.change.labels[result];
if (!isDetailedLabelInfo(labelInfo)) {
return null;
}
const permittedValues = this.change.permitted_labels[result];
const usersMaxPermittedScore =
permittedValues[permittedValues.length - 1];
const maxScoreForLabel = getVotingRange(labelInfo)?.max;
if (Number(usersMaxPermittedScore) === maxScoreForLabel) {
// Allow quick approve only for maximal score.
return {
label: result,
score: usersMaxPermittedScore,
};
}
}
return null;
}
hideQuickApproveAction() {
if (!this._topLevelSecondaryActions) {
throw new Error('_topLevelSecondaryActions must be set');
}
this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
sa => !isQuckApproveAction(sa)
);
this._hideQuickApproveAction = true;
}
_getQuickApproveAction(): QuickApproveUIActionInfo | null {
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 score = Number(approval.score);
if (isNaN(score)) {
return null;
}
const review: ReviewInput = {
drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
labels: {
[approval.label]: score,
},
};
action.payload = review;
return action;
}
_getActionValues(
actionsChangeRecord: PolymerDeepPropertyChange<
ActionNameToActionInfoMap,
ActionNameToActionInfoMap
>,
primariesChangeRecord: PolymerDeepPropertyChange<
PrimaryActionKey[],
PrimaryActionKey[]
>,
additionalActionsChangeRecord: PolymerDeepPropertyChange<
UIActionInfo[],
UIActionInfo[]
>,
type: ActionType
): UIActionInfo[] {
if (!actionsChangeRecord || !primariesChangeRecord) {
return [];
}
const actions = actionsChangeRecord.base || {};
const primaryActionKeys = primariesChangeRecord.base || [];
const result: UIActionInfo[] = [];
const values: Array<ChangeActions | RevisionActions> =
type === ActionType.CHANGE
? this._getValuesFor(ChangeActions)
: this._getValuesFor(RevisionActions);
const pluginActions: UIActionInfo[] = [];
Object.keys(actions).forEach(a => {
const action: UIActionInfo = actions[a] as UIActionInfo;
action.__key = a;
action.__type = type;
action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
// Plugin actions always contain ~ in the key.
if (a.indexOf('~') !== -1) {
this._populateActionUrl(action);
pluginActions.push(action);
// Add server-side provided plugin actions to overflow menu.
this._overflowActions.push({
type,
key: a,
});
return;
} else if (!values.includes(a as PrimaryActionKey)) {
return;
}
action.label = this._getActionLabel(action);
// Triggers a re-render by ensuring object inequality.
result.push({...action});
});
let additionalActions =
(additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
[];
additionalActions = additionalActions
.filter(a => a.__type === type)
.map(a => {
a.__primary = primaryActionKeys.includes(a.__key as PrimaryActionKey);
// Triggers a re-render by ensuring object inequality.
return {...a};
});
return result.concat(additionalActions).concat(pluginActions);
}
_populateActionUrl(action: UIActionInfo) {
const patchNum =
action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
if (!this.changeNum) {
return;
}
this.restApiService
.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: UIActionInfo) {
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.
*/
_toSentenceCase(s: string) {
if (!s.length) {
return '';
}
return s[0].toUpperCase() + s.slice(1).toLowerCase();
}
_computeLoadingLabel(action: string) {
return ActionLoadingLabels[action] || 'Working...';
}
_canSubmitChange() {
if (!this.change) {
return false;
}
return this.jsAPI.canSubmitChange(
this.change,
this._getRevision(this.change, this.latestPatchNum)
);
}
_getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
for (const rev of Object.values(change.revisions)) {
if (rev._number === patchNum) {
return rev;
}
}
return null;
}
showRevertDialog() {
const change = this.change;
if (!change) return;
// The search is still broken if there is a " in the topic.
const query = `submissionid: "${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.restApiService.getChanges(0, query).then(changes => {
if (!changes) {
this.reporting.error(new Error('changes is undefined'));
return;
}
this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
this._showActionDialog(this.$.confirmRevertDialog);
});
}
showRevertSubmissionDialog() {
const change = this.change;
if (!change) return;
const query = `submissionid:${change.submission_id}`;
this.restApiService.getChanges(0, query).then(changes => {
if (!changes) {
this.reporting.error(new Error('changes is undefined'));
return;
}
this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
change,
changes
);
this._showActionDialog(this.$.confirmRevertSubmissionDialog);
});
}
_handleActionTap(e: MouseEvent) {
e.preventDefault();
let el = (dom(e) as EventApi).localTarget as Element;
while (el.tagName.toLowerCase() !== 'gr-button') {
if (!el.parentElement) {
return;
}
el = el.parentElement;
}
const key = el.getAttribute('data-action-key');
if (!key) {
throw new Error("Button doesn't have data-action-key attribute");
}
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') as ActionType;
this._handleAction(type, key);
}
_handleOverflowItemTap(e: CustomEvent<MenuAction>) {
e.preventDefault();
const el = (dom(e) as EventApi).localTarget as Element;
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: ActionType, key: string) {
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),
assertUIActionInfo(this.actions[key]),
false
);
}
}
_handleChangeAction(key: string) {
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: {
const action = this._allActionValues.find(isQuckApproveAction);
if (!action) {
return;
}
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),
assertUIActionInfo(this.actions[key]),
false
);
}
}
_handleRevisionAction(key: string) {
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),
assertUIActionInfo(this.revisionActions[key]),
true
);
}
}
_prependSlash(key: string) {
return key === '/' ? key : `/${key}`;
}
/**
* _hasKnownChainState set to true true if hasParent is defined (can be
* either true or false). set to false otherwise.
*/
_computeChainState() {
this._hasKnownChainState = true;
}
_calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
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 as HTMLElement).hidden = true;
}
this.$.overlay.close();
}
_handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
const el = this.$.confirmRebase;
const payload = {base: e.detail.base};
this.$.overlay.close();
el.hidden = true;
this._fireAction(
'/rebase',
assertUIActionInfo(this.revisionActions.rebase),
true,
payload
);
}
_handleCherrypickConfirm() {
this._handleCherryPickRestApi(false);
}
_handleCherrypickConflictConfirm() {
this._handleCherryPickRestApi(true);
}
_handleCherryPickRestApi(conflicts: boolean) {
const el = this.$.confirmCherrypick;
if (!el.branch) {
fireAlert(this, ERR_BRANCH_EMPTY);
return;
}
if (!el.message) {
fireAlert(this, ERR_COMMIT_EMPTY);
return;
}
this.$.overlay.close();
el.hidden = true;
this._fireAction(
'/cherrypick',
assertUIActionInfo(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) {
fireAlert(this, ERR_BRANCH_EMPTY);
return;
}
this.$.overlay.close();
el.hidden = true;
this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
destination_branch: el.branch,
message: el.message,
});
}
_handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
const revertType = e.detail.revertType;
const message = e.detail.message;
const el = this.$.confirmRevertDialog;
this.$.overlay.close();
el.hidden = true;
switch (revertType) {
case RevertType.REVERT_SINGLE_CHANGE:
this._fireAction(
'/revert',
assertUIActionInfo(this.actions.revert),
false,
{message}
);
break;
case RevertType.REVERT_SUBMISSION:
this._fireAction(
'/revert_submission',
assertUIActionInfo(this.actions.revert_submission),
false,
{message}
);
break;
default:
this.reporting.error(new Error('invalid revert type'));
}
}
_handleRevertSubmissionDialogConfirm() {
const el = this.$.confirmRevertSubmissionDialog;
this.$.overlay.close();
el.hidden = true;
this._fireAction(
'/revert_submission',
assertUIActionInfo(this.actions.revert_submission),
false,
{message: el.message}
);
}
_handleAbandonDialogConfirm() {
const el = this.$.confirmAbandonDialog;
this.$.overlay.close();
el.hidden = true;
this._fireAction(
'/abandon',
assertUIActionInfo(this.actions.abandon),
false,
{
message: el.message,
}
);
}
_handleCreateFollowUpChange() {
this.$.createFollowUpChange.handleCreateChange();
this._handleCloseCreateFollowUpChange();
}
_handleCloseCreateFollowUpChange() {
this.$.overlay.close();
}
_handleDeleteConfirm() {
this._fireAction(
'/',
assertUIActionInfo(this.actions[ChangeActions.DELETE]),
false
);
}
_handleDeleteEditConfirm() {
this._hideAllDialogs();
this._fireAction(
'/edit',
assertUIActionInfo(this.actions.deleteEdit),
false
);
}
_handleSubmitConfirm() {
if (!this._canSubmitChange()) {
return;
}
this._hideAllDialogs();
this._fireAction(
'/submit',
assertUIActionInfo(this.revisionActions.submit),
true
);
}
_getActionOverflowIndex(type: string, key: string) {
return this._overflowActions.findIndex(
action => action.type === type && action.key === key
);
}
_setLoadingOnButtonWithKey(type: string, key: string) {
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}"]`
) as GrButton;
if (!buttonEl) {
throw new Error(`Can't find button by data-action-key '${buttonKey}'`);
}
buttonEl.setAttribute('loading', 'true');
buttonEl.disabled = true;
return () => {
this._actionLoadingMessage = '';
buttonEl.removeAttribute('loading');
buttonEl.disabled = false;
};
}
_fireAction(
endpoint: string,
action: UIActionInfo,
revAction: boolean,
payload?: RequestPayload
) {
const cleanupFn = this._setLoadingOnButtonWithKey(
action.__type,
action.__key
);
this._send(
action.method,
payload,
endpoint,
revAction,
cleanupFn,
action
).then(res => this._handleResponse(action, res));
}
_showActionDialog(dialog: ChangeActionDialog) {
this._hideAllDialogs();
if (dialog.init) dialog.init();
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.
_setReviewOnRevert(newChangeId: NumericChangeId) {
const review = this.jsAPI.getReviewPostRevert(this.change);
if (!review) {
return Promise.resolve(undefined);
}
return this.restApiService.saveChangeReview(newChangeId, CURRENT, review);
}
_handleResponse(action: UIActionInfo, response?: Response) {
if (!response) {
return;
}
return this.restApiService.getResponseObject(response).then(obj => {
switch (action.__key) {
case ChangeActions.REVERT: {
const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
this._waitForChangeReachable(revertChangeInfo._number)
.then(() => this._setReviewOnRevert(revertChangeInfo._number))
.then(() => {
GerritNav.navigateToChange(revertChangeInfo);
});
break;
}
case RevisionActions.CHERRYPICK: {
const cherrypickChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
this._waitForChangeReachable(cherrypickChangeInfo._number).then(
() => {
GerritNav.navigateToChange(cherrypickChangeInfo);
}
);
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: {
const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
if (
!revertSubmistionInfo.revert_changes ||
!revertSubmistionInfo.revert_changes.length
)
return;
/* If there is only 1 change then gerrit will automatically
redirect to that change */
GerritNav.navigateToSearchQuery(
`topic: ${revertSubmistionInfo.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: UIActionInfo,
response: Response | undefined | null,
body?: RequestPayload
) {
if (!response) {
return Promise.resolve(() => {
this.dispatchEvent(
new CustomEvent('show-error', {
detail: {message: `Could not perform action '${action.__key}'`},
composed: true,
bubbles: true,
})
);
});
}
if (action && action.__key === RevisionActions.CHERRYPICK) {
if (
response.status === 409 &&
body &&
!(body as CherryPickInput).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);
}
});
}
_send(
method: HttpMethod | undefined,
payload: RequestPayload | undefined,
actionEndpoint: string,
revisionAction: boolean,
cleanupFn: () => void,
action: UIActionInfo
): Promise<Response | undefined> {
const handleError: ErrorCallback = response => {
cleanupFn.call(this);
this._handleResponseError(action, response, payload);
};
const change = this.change;
const changeNum = this.changeNum;
if (!change || !changeNum) {
return Promise.reject(
new Error('Properties change and changeNum must be set.')
);
}
return fetchChangeUpdates(change, this.restApiService).then(result => {
if (!result.isLatest) {
this.dispatchEvent(
new CustomEvent<ShowAlertEventDetail>('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(undefined);
}
const patchNum = revisionAction ? this.latestPatchNum : undefined;
return this.restApiService
.executeChangeAction(
changeNum,
method,
actionEndpoint,
patchNum,
payload,
handleError
)
.then(response => {
cleanupFn.call(this);
return response;
});
});
}
_handleAbandonTap() {
this._showActionDialog(this.$.confirmAbandonDialog);
}
_handleCherrypickTap() {
if (!this.change) {
throw new Error('The change property must be set');
}
this.$.confirmCherrypick.branch = '' as BranchName;
const query = `topic: "${this.change.topic}"`;
const options = listChangesOptionsToHex(
ListChangesOption.MESSAGES,
ListChangesOption.ALL_REVISIONS
);
this.restApiService
.getChanges(0, query, undefined, options)
.then(changes => {
if (!changes) {
this.reporting.error(new Error('getChanges returns undefined'));
return;
}
this.$.confirmCherrypick.updateChanges(changes);
this._showActionDialog(this.$.confirmCherrypick);
});
}
_handleMoveTap() {
this.$.confirmMove.branch = '' as BranchName;
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() {
if (!this.actions.wip) {
return;
}
this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
}
_handlePublishEditTap() {
if (!this.actions.publishEdit) {
return;
}
this._fireAction(
'/edit:publish',
assertUIActionInfo(this.actions.publishEdit),
false,
{notify: NotifyType.NONE}
);
}
_handleRebaseEditTap() {
if (!this.actions.rebaseEdit) {
return;
}
this._fireAction(
'/edit:rebase',
assertUIActionInfo(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.
*/
_computeAllActions(
changeActionsRecord: PolymerDeepPropertyChange<
ActionNameToActionInfoMap,
ActionNameToActionInfoMap
>,
revisionActionsRecord: PolymerDeepPropertyChange<
ActionNameToActionInfoMap,
ActionNameToActionInfoMap
>,
primariesRecord: PolymerDeepPropertyChange<
PrimaryActionKey[],
PrimaryActionKey[]
>,
additionalActionsRecord: PolymerDeepPropertyChange<
UIActionInfo[],
UIActionInfo[]
>,
change?: ChangeInfo,
config?: ServerInfo
): UIActionInfo[] {
// 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: UIActionInfo) {
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: UIActionInfo, actionB: UIActionInfo) {
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: UIActionInfo, config?: ServerInfo) {
const skipActionKeys: string[] = [...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: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
editMode: boolean
): UIActionInfo[] {
const hiddenActions = hiddenActionsRecord.base || [];
return actionRecord.base.filter(a => {
if (hiddenActions.includes(a.__key)) return false;
if (editMode) return EDIT_ACTIONS.has(a.__key);
return this._getActionOverflowIndex(a.__type, a.__key) === -1;
});
}
_filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
this._topLevelPrimaryActions = _topLevelActions.filter(
action => action.__primary
);
this._topLevelSecondaryActions = _topLevelActions.filter(
action => !action.__primary
);
}
_computeMenuActions(
actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
): MenuAction[] {
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: PropertyType<GrChangeActions, '_revisionRebaseAction'>
) {
if (revisionRebaseAction) {
return !!revisionRebaseAction.enabled;
}
return null;
}
/**
* Occasionally, a change created by a change action is not yet known 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.
*
*/
_waitForChangeReachable(changeNum: NumericChangeId) {
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.restApiService
.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?: string) {
return !!title;
}
_computeHasIcon(action: UIActionInfo) {
return action.icon ? '' : 'hidden';
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-actions': GrChangeActions;
}
}