blob: 5728529dba4c80c4dcbed0c1ec58529563ef084f [file] [log] [blame]
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {customElement, query, state} from 'lit/decorators.js';
import {LitElement, html, css, nothing} from 'lit';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {resolve} from '../../../models/dependency';
import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
import {subscribe} from '../../lit/subscription-controller';
import {ChangeInfo, AccountInfo, NumericChangeId} from '../../../api/rest-api';
import {
getTriggerVotes,
computeLabels,
computeOrderedLabelValues,
mergeLabelInfoMaps,
mergeLabelMaps,
Label,
StandardLabels,
} from '../../../utils/label-util';
import {getAppContext} from '../../../services/app-context';
import {fontStyles} from '../../../styles/gr-font-styles';
import {queryAndAssert} from '../../../utils/common-util';
import {
LabelNameToValuesMap,
ReviewInput,
LabelNameToValueMap,
} from '../../../types/common';
import {GrLabelScoreRow} from '../../change/gr-label-score-row/gr-label-score-row';
import {ProgressStatus} from '../../../constants/constants';
import {fireAlert, fireReload} from '../../../utils/event-util';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-icon/gr-icon';
import '../../change/gr-label-score-row/gr-label-score-row';
import {getOverallStatus} from '../../../utils/bulk-flow-util';
import {allSettled} from '../../../utils/async-util';
import {pluralize} from '../../../utils/string-util';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {Interaction} from '../../../constants/reporting';
import {createChangeUrl} from '../../../models/views/change';
@customElement('gr-change-list-bulk-vote-flow')
export class GrChangeListBulkVoteFlow extends LitElement {
private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
private readonly userModel = getAppContext().userModel;
private readonly reportingService = getAppContext().reportingService;
@state() selectedChanges: ChangeInfo[] = [];
@state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
@query('#actionOverlay') actionOverlay!: GrOverlay;
@query('gr-dialog') dialog?: GrDialog;
@state() account?: AccountInfo;
static override get styles() {
return [
fontStyles,
css`
gr-dialog {
width: 840px;
}
.scoresTable {
display: table;
width: 100%;
}
.scoresTable.newSubmitRequirements {
table-layout: fixed;
}
gr-label-score-row:hover {
background-color: var(--hover-background-color);
}
gr-label-score-row {
display: table-row;
}
/* TODO(dhruvsri): Consider using flex column with gap */
.scoresTable:not(:first-of-type) {
margin-top: var(--spacing-m);
}
.vote-type {
margin-bottom: var(--spacing-s);
margin-top: 0;
display: table-caption;
}
.main-heading {
margin-bottom: var(--spacing-m);
font-weight: var(--font-weight-h2);
}
.error-container {
background-color: var(--error-background);
margin-top: var(--spacing-l);
}
.code-review-message-container gr-icon,
.error-container gr-icon {
padding: 10px var(--spacing-xl);
}
.error-container gr-icon {
color: var(--error-foreground);
}
.code-review-message-container gr-icon {
color: var(--selected-foreground);
}
.error-container .error-text,
.code-review-message-container .warning-text {
position: relative;
top: 10px;
}
.code-review-message-container {
display: table-caption;
background-color: var(--code-review-warning-background);
margin-bottom: var(--spacing-m);
}
.code-review-message-layout-container {
display: flex;
}
.code-review-message-container gr-button {
margin-top: 6px;
margin-right: var(--spacing-xl);
}
.flex-space {
flex-grow: 1;
}
`,
];
}
constructor() {
super();
subscribe(
this,
() => this.getBulkActionsModel().selectedChanges$,
selectedChanges => {
this.selectedChanges = selectedChanges;
this.resetFlow();
}
);
subscribe(
this,
() => this.userModel.account$,
account => (this.account = account)
);
}
override render() {
const permittedLabels = this.computePermittedLabels();
const triggerLabels = this.computeCommonTriggerLabels(permittedLabels);
const nonTriggerLabels = this.computeCommonPermittedLabels(
permittedLabels
).filter(label => !triggerLabels.some(l => l.name === label.name));
return html`
<gr-button id="voteFlowButton" flatten @click=${this.openOverlay}
>Vote</gr-button
>
<gr-overlay id="actionOverlay" with-backdrop="">
<gr-dialog
.disableCancel=${!this.isCancelEnabled()}
.disabled=${!this.isConfirmEnabled()}
?loading=${this.isLoading()}
.loadingLabel=${'Voting in progress...'}
@confirm=${() => this.handleConfirm()}
@cancel=${() => this.handleClose()}
.confirmLabel=${'Vote'}
.cancelLabel=${'Cancel'}
>
<div slot="header">
<span class="main-heading"> Vote on selected changes </span>
</div>
<div slot="main">
${this.renderLabels(
nonTriggerLabels,
'Submit requirements votes',
permittedLabels,
true
)}
${this.renderLabels(
triggerLabels,
'Trigger Votes',
permittedLabels
)}
${this.renderErrors()}
</div>
</gr-dialog>
</gr-overlay>
`;
}
private renderCodeReviewMessage() {
return html`
<div class="code-review-message-container">
<div class="code-review-message-layout-container">
<div>
<gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
<span class="warning-text">
Code Review vote is only available on the individual change page
</span>
</div>
<div class="flex-space"></div>
<div>
<gr-button
aria-label=${`Open ${pluralize(
this.selectedChanges.length,
'change'
)} in different tabs`}
flatten
link
@click=${this.handleOpenChanges}
>Open ${pluralize(this.selectedChanges.length, 'change')}
</gr-button>
</div>
</div>
</div>
`;
}
private handleOpenChanges() {
for (const change of this.selectedChanges) {
window.open(createChangeUrl({change, usp: 'bulk-vote'}));
}
}
private async openOverlay() {
await this.actionOverlay.open();
this.actionOverlay.setFocusStops({
start: queryAndAssert(this.dialog, 'header'),
end: queryAndAssert(this.dialog, 'footer'),
});
}
private renderErrors() {
if (getOverallStatus(this.progressByChange) !== ProgressStatus.FAILED) {
return nothing;
}
return html`
<div class="error-container">
<gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
<span class="error-text">
<!-- prettier-ignore -->
Failed to vote on ${pluralize(
Array.from(this.progressByChange.values()).filter(
status => status === ProgressStatus.FAILED
).length,
'change'
)}
</span>
</div>
`;
}
private renderLabels(
labels: Label[],
heading: string,
permittedLabels?: LabelNameToValuesMap,
showCodeReviewWarning?: boolean
) {
return html` <div class="scoresTable newSubmitRequirements">
<h3 class="heading-4 vote-type">${labels.length ? heading : nothing}</h3>
${showCodeReviewWarning ? this.renderCodeReviewMessage() : nothing}
${labels
.filter(
label =>
permittedLabels?.[label.name] &&
permittedLabels?.[label.name].length > 0
)
.map(
label => html`<gr-label-score-row
.label=${label}
.name=${label.name}
.labels=${this.computeLabelNameToInfoMap()}
.permittedLabels=${permittedLabels}
.orderedLabelValues=${computeOrderedLabelValues(permittedLabels)}
></gr-label-score-row>`
)}
</div>`;
}
private resetFlow() {
this.progressByChange = new Map(
this.selectedChanges.map(change => [
change._number,
ProgressStatus.NOT_STARTED,
])
);
}
private isLoading() {
return getOverallStatus(this.progressByChange) === ProgressStatus.RUNNING;
}
private isConfirmEnabled() {
// Action is allowed if none of the changes have any bulk action performed
// on them. In case an error happens then we keep the button disabled.
return (
getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED
);
}
private isCancelEnabled() {
return getOverallStatus(this.progressByChange) !== ProgressStatus.RUNNING;
}
private handleClose() {
this.actionOverlay.close();
if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
return;
fireReload(this, true);
}
private async handleConfirm() {
this.progressByChange.clear();
this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
type: 'vote',
selectedChangeCount: this.selectedChanges.length,
});
const reviewInput: ReviewInput = {
labels: this.getLabelValues(
this.computeCommonPermittedLabels(this.computePermittedLabels())
),
};
for (const change of this.selectedChanges) {
this.progressByChange.set(change._number, ProgressStatus.RUNNING);
}
this.requestUpdate();
const promises = this.getBulkActionsModel().voteChanges(reviewInput);
await allSettled(
promises.map((promise, index) => {
const changeNum = this.selectedChanges[index]._number;
return promise
.then(() => {
this.progressByChange.set(changeNum, ProgressStatus.SUCCESSFUL);
})
.catch(() => {
this.progressByChange.set(changeNum, ProgressStatus.FAILED);
})
.finally(() => {
this.requestUpdate();
if (
getOverallStatus(this.progressByChange) ===
ProgressStatus.SUCCESSFUL
) {
fireAlert(this, 'Votes added');
this.handleClose();
}
});
})
);
if (getOverallStatus(this.progressByChange) === ProgressStatus.FAILED) {
this.reportingService.reportInteraction('bulk-action-failure', {
type: 'vote',
count: Array.from(this.progressByChange.values()).filter(
status => status === ProgressStatus.FAILED
).length,
});
}
}
// private but used in tests
getLabelValues(commonPermittedLabels: Label[]): LabelNameToValueMap {
const labels: LabelNameToValueMap = {};
for (const label of commonPermittedLabels) {
const selectorEl = queryAndAssert<GrLabelScoreRow>(
this,
`gr-label-score-row[name="${label.name}"]`
);
if (!selectorEl?.selectedItem) continue;
const selectedVal =
typeof selectorEl.selectedValue === 'string'
? Number(selectorEl.selectedValue)
: selectorEl.selectedValue;
if (selectedVal === undefined) continue;
labels[label.name] = selectedVal;
}
return labels;
}
// private but used in tests
computePermittedLabels() {
// Reduce method for empty array throws error if no initial value specified
if (this.selectedChanges.length === 0) return {};
const permittedLabels = this.selectedChanges
.map(changes => changes.permitted_labels)
.reduce(mergeLabelMaps);
// TODO: show a warning to the user that Code Review cannot be voted upon
if (permittedLabels?.[StandardLabels.CODE_REVIEW]) {
delete permittedLabels[StandardLabels.CODE_REVIEW];
}
return permittedLabels;
}
private computeLabelNameToInfoMap() {
// Reduce method for empty array throws error if no initial value specified
if (this.selectedChanges.length === 0) return {};
return this.selectedChanges
.map(changes => changes.labels)
.reduce(mergeLabelInfoMaps);
}
// private but used in tests
computeCommonTriggerLabels(permittedLabels?: LabelNameToValuesMap) {
if (this.selectedChanges.length === 0) return [];
const triggerVotes = this.selectedChanges
.map(change => getTriggerVotes(change))
.reduce((prev, current) =>
current.filter(label => prev.some(l => l === label))
);
return this.computeCommonPermittedLabels(permittedLabels).filter(label =>
triggerVotes.includes(label.name)
);
}
// private but used in tests
computeCommonPermittedLabels(permittedLabels?: LabelNameToValuesMap) {
// Reduce method for empty array throws error if no initial value specified
if (this.selectedChanges.length === 0) return [];
return this.selectedChanges
.map(change => computeLabels(this.account, change))
.reduce((prev, current) =>
current.filter(label => prev.some(l => l.name === label.name))
)
.filter(
label =>
permittedLabels?.[label.name] &&
permittedLabels?.[label.name].length > 0
);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-list-bulk-vote-flow': GrChangeListBulkVoteFlow;
}
}