blob: 436e435d40cb181e94fcbe67b55db62992a9652c [file] [log] [blame]
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {css, html, LitElement, nothing} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {ProgressStatus, ReviewerState} from '../../../constants/constants';
import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
import {configModelToken} from '../../../models/config/config-model';
import {resolve} from '../../../models/dependency';
import {
AccountDetailInfo,
AccountInfo,
ChangeInfo,
NumericChangeId,
ServerInfo,
SuggestedReviewerGroupInfo,
} from '../../../types/common';
import {subscribe} from '../../lit/subscription-controller';
import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-icon/gr-icon';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {getAppContext} from '../../../services/app-context';
import {
GrReviewerSuggestionsProvider,
ReviewerSuggestionsProvider,
} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import '../../shared/gr-account-list/gr-account-list';
import {getOverallStatus} from '../../../utils/bulk-flow-util';
import {allSettled} from '../../../utils/async-util';
import {listForSentence, pluralize} from '../../../utils/string-util';
import {getDisplayName} from '../../../utils/display-name-util';
import {
AccountInput,
GrAccountList,
} from '../../shared/gr-account-list/gr-account-list';
import {getReplyByReason} from '../../../utils/attention-set-util';
import {intersection, queryAndAssert} from '../../../utils/common-util';
import {accountKey, getUserId} from '../../../utils/account-util';
import {ValueChangedEvent} from '../../../types/events';
import {fireAlert, fireReload} from '../../../utils/event-util';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {Interaction} from '../../../constants/reporting';
@customElement('gr-change-list-reviewer-flow')
export class GrChangeListReviewerFlow extends LitElement {
@state() private selectedChanges: ChangeInfo[] = [];
// contents are given to gr-account-lists to mutate
// private but used in tests
@state() updatedAccountsByReviewerState: Map<ReviewerState, AccountInput[]> =
new Map([
[ReviewerState.REVIEWER, []],
[ReviewerState.CC, []],
]);
@state() private suggestionsProviderByReviewerState: Map<
ReviewerState,
ReviewerSuggestionsProvider
> = new Map();
@state() private progressByChangeNum = new Map<
NumericChangeId,
ProgressStatus
>();
@state() private isOverlayOpen = false;
@state() private serverConfig?: ServerInfo;
@state()
private groupPendingConfirmationByReviewerState: Map<
ReviewerState,
SuggestedReviewerGroupInfo | null
> = new Map([
[ReviewerState.REVIEWER, null],
[ReviewerState.CC, null],
]);
@query('gr-overlay#flow') private overlay?: GrOverlay;
@query('gr-account-list#reviewer-list') private reviewerList?: GrAccountList;
@query('gr-account-list#cc-list') private ccList?: GrAccountList;
@query('gr-overlay#confirm-reviewer')
private reviewerConfirmOverlay?: GrOverlay;
@query('gr-overlay#confirm-cc') private ccConfirmOverlay?: GrOverlay;
@query('gr-dialog') dialog?: GrDialog;
private readonly reportingService = getAppContext().reportingService;
private getBulkActionsModel = resolve(this, bulkActionsModelToken);
private getConfigModel = resolve(this, configModelToken);
private restApiService = getAppContext().restApiService;
private isLoggedIn = false;
private account?: AccountDetailInfo;
static override get styles() {
return [
css`
gr-dialog {
width: 60em;
}
.grid {
display: grid;
grid-template-columns: min-content 1fr;
column-gap: var(--spacing-l);
}
gr-account-list {
display: flex;
flex-wrap: wrap;
}
.warning,
.error {
display: flex;
align-items: center;
gap: var(--spacing-xl);
padding: var(--spacing-l);
padding-left: var(--spacing-xl);
background-color: var(--yellow-50);
}
.error {
background-color: var(--error-background);
}
.grid + .warning,
.error {
margin-top: var(--spacing-l);
}
.warning + .warning {
margin-top: var(--spacing-s);
}
gr-icon {
color: var(--orange-800);
font-size: 18px;
}
gr-overlay#confirm-cc,
gr-overlay#confirm-reviewer {
padding: var(--spacing-l);
text-align: center;
}
.confirmation-buttons {
margin-top: var(--spacing-l);
}
`,
];
}
constructor() {
super();
subscribe(
this,
() => this.getBulkActionsModel().selectedChanges$,
selectedChanges => (this.selectedChanges = selectedChanges)
);
subscribe(
this,
() => this.getConfigModel().serverConfig$,
serverConfig => (this.serverConfig = serverConfig)
);
subscribe(
this,
() => getAppContext().userModel.loggedIn$,
isLoggedIn => (this.isLoggedIn = isLoggedIn)
);
subscribe(
this,
() => getAppContext().userModel.account$,
account => (this.account = account)
);
}
override render() {
return html`
<gr-button
id="start-flow"
.disabled=${this.isFlowDisabled()}
flatten
@click=${() => this.openOverlay()}
>add reviewer/cc</gr-button
>
<gr-overlay id="flow" with-backdrop>
${this.isOverlayOpen ? this.renderDialog() : nothing}
</gr-overlay>
`;
}
private renderDialog() {
const overallStatus = getOverallStatus(this.progressByChangeNum);
return html`
<gr-dialog
@cancel=${() => this.closeOverlay()}
@confirm=${() => this.onConfirm(overallStatus)}
.confirmLabel=${'Add'}
.disabled=${overallStatus === ProgressStatus.RUNNING}
.loadingLabel=${'Adding Reviewer and CC in progress...'}
?loading=${getOverallStatus(this.progressByChangeNum) ===
ProgressStatus.RUNNING}
>
<div slot="header">Add reviewer / CC</div>
<div slot="main">
<div class="grid">
<span>Reviewers</span>
${this.renderAccountList(
ReviewerState.REVIEWER,
'reviewer-list',
'Add reviewer'
)}
<span>CC</span>
${this.renderAccountList(ReviewerState.CC, 'cc-list', 'Add CC')}
</div>
${this.renderAnyOverwriteWarnings()} ${this.renderErrors()}
</div>
</gr-dialog>
`;
}
private renderAccountList(
reviewerState: ReviewerState,
id: string,
placeholder: string
) {
const updatedAccounts =
this.updatedAccountsByReviewerState.get(reviewerState);
const suggestionsProvider =
this.suggestionsProviderByReviewerState.get(reviewerState);
if (!updatedAccounts || !suggestionsProvider) {
return;
}
return html`
<gr-account-list
id=${id}
.accounts=${updatedAccounts}
.removableValues=${[]}
.suggestionsProvider=${suggestionsProvider}
.placeholder=${placeholder}
.pendingConfirmation=${this.groupPendingConfirmationByReviewerState.get(
reviewerState
)}
@accounts-changed=${() => this.onAccountsChanged(reviewerState)}
@pending-confirmation-changed=${(
ev: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
) => this.onPendingConfirmationChanged(reviewerState, ev)}
>
</gr-account-list>
${this.renderConfirmationDialog(reviewerState)}
`;
}
private renderConfirmationDialog(reviewerState: ReviewerState) {
const id =
reviewerState === ReviewerState.CC ? 'confirm-cc' : 'confirm-reviewer';
const suggestion =
this.groupPendingConfirmationByReviewerState.get(reviewerState);
return html`
<gr-overlay
id=${id}
@iron-overlay-canceled=${() => this.cancelPendingGroup(reviewerState)}
>
<div class="confirmation-text">
Group
<span class="groupName"> ${suggestion?.group.name} </span>
has
<span class="groupSize"> ${suggestion?.count} </span>
members.
<br />
Are you sure you want to add them all?
</div>
<div class="confirmation-buttons">
<gr-button
@click=${() => this.confirmPendingGroup(reviewerState, suggestion)}
>Yes</gr-button
>
<gr-button @click=${() => this.cancelPendingGroup(reviewerState)}
>No</gr-button
>
</div>
</gr-overlay>
`;
}
private renderAnyOverwriteWarnings() {
return html`
${this.renderAnyOverwriteWarning(ReviewerState.REVIEWER)}
${this.renderAnyOverwriteWarning(ReviewerState.CC)}
`;
}
private renderErrors() {
if (getOverallStatus(this.progressByChangeNum) !== ProgressStatus.FAILED)
return nothing;
const failedAccounts = [
...(this.updatedAccountsByReviewerState.get(ReviewerState.REVIEWER) ??
[]),
...(this.updatedAccountsByReviewerState.get(ReviewerState.CC) ?? []),
].map(account => getDisplayName(this.serverConfig, account));
if (failedAccounts.length === 0) {
return nothing;
}
return html`
<div class="error">
<gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
Failed to add ${listForSentence(failedAccounts)} to changes.
</div>
`;
}
private renderAnyOverwriteWarning(currentReviewerState: ReviewerState) {
const updatedReviewerState =
currentReviewerState === ReviewerState.CC
? ReviewerState.REVIEWER
: ReviewerState.CC;
const overwrittenNames =
this.getOverwrittenDisplayNames(currentReviewerState);
if (overwrittenNames.length === 0) {
return nothing;
}
const pluralizedVerb = overwrittenNames.length === 1 ? 'is a' : 'are';
const currentLabel = `${
currentReviewerState === ReviewerState.CC ? 'CC' : 'reviewer'
}${overwrittenNames.length > 1 ? 's' : ''}`;
const updatedLabel =
updatedReviewerState === ReviewerState.CC ? 'CC' : 'reviewer';
return html`
<div class="warning">
<gr-icon
icon="warning"
filled
role="img"
aria-label="Warning"
></gr-icon>
${listForSentence(overwrittenNames)} ${pluralizedVerb} ${currentLabel}
on some selected changes and will be moved to ${updatedLabel} on all
changes.
</div>
`;
}
private getAccountsInCurrentState(currentReviewerState: ReviewerState) {
return this.selectedChanges
.flatMap(
change =>
change.reviewers[currentReviewerState]?.filter(isNotOwner(change)) ??
[]
)
.filter(account => account?._account_id !== undefined);
}
private getOverwrittenDisplayNames(
currentReviewerState: ReviewerState
): string[] {
const updatedReviewerState =
currentReviewerState === ReviewerState.CC
? ReviewerState.REVIEWER
: ReviewerState.CC;
const accountsInCurrentState =
this.getAccountsInCurrentState(currentReviewerState);
return this.updatedAccountsByReviewerState
.get(updatedReviewerState)!
.filter(account =>
accountsInCurrentState.some(
otherAccount => getUserId(otherAccount) === getUserId(account)
)
)
.map(reviewer => getDisplayName(this.serverConfig, reviewer));
}
private async openOverlay() {
this.resetFlow();
this.isOverlayOpen = true;
// Must await the overlay opening because the dialog is lazily rendered.
await this.overlay?.open();
this.overlay?.setFocusStops({
start: queryAndAssert(this.dialog, 'header'),
end: queryAndAssert(this.dialog, 'footer'),
});
}
private closeOverlay() {
this.isOverlayOpen = false;
this.overlay?.close();
}
private resetFlow() {
this.progressByChangeNum = new Map(
this.selectedChanges.map(change => [
change._number,
ProgressStatus.NOT_STARTED,
])
);
for (const state of [ReviewerState.REVIEWER, ReviewerState.CC] as const) {
this.updatedAccountsByReviewerState.set(
state,
this.getCurrentAccounts(state)
);
if (this.selectedChanges.length > 0) {
this.suggestionsProviderByReviewerState.set(
state,
this.createSuggestionsProvider(state)
);
}
}
this.requestUpdate();
}
/*
* Removes accounts from one list when they are added to the other. Also
* trigger re-render so warnings will update as accounts are added, removed,
* and confirmed.
*/
private onAccountsChanged(reviewerState: ReviewerState) {
const reviewerStateKeys = this.updatedAccountsByReviewerState
.get(reviewerState)!
.map(getUserId);
const oppositeReviewerState =
reviewerState === ReviewerState.CC
? ReviewerState.REVIEWER
: ReviewerState.CC;
const oppositeUpdatedAccounts = this.updatedAccountsByReviewerState.get(
oppositeReviewerState
)!;
const notOverwrittenOppositeAccounts = oppositeUpdatedAccounts.filter(
acc => !reviewerStateKeys.includes(getUserId(acc))
);
if (
notOverwrittenOppositeAccounts.length !== oppositeUpdatedAccounts.length
) {
this.updatedAccountsByReviewerState.set(
oppositeReviewerState,
notOverwrittenOppositeAccounts
);
}
this.requestUpdate();
}
private async onPendingConfirmationChanged(
reviewerState: ReviewerState,
ev: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
) {
this.groupPendingConfirmationByReviewerState.set(
reviewerState,
ev.detail.value
);
this.requestUpdate();
await this.updateComplete;
const overlay =
reviewerState === ReviewerState.CC
? this.ccConfirmOverlay
: this.reviewerConfirmOverlay;
if (ev.detail.value === null) {
overlay?.close();
} else {
await overlay?.open();
}
}
private cancelPendingGroup(reviewerState: ReviewerState) {
const overlay =
reviewerState === ReviewerState.CC
? this.ccConfirmOverlay
: this.reviewerConfirmOverlay;
overlay?.close();
this.groupPendingConfirmationByReviewerState.set(reviewerState, null);
this.requestUpdate();
}
private confirmPendingGroup(
reviewerState: ReviewerState,
suggestion: SuggestedReviewerGroupInfo | null | undefined
) {
if (!suggestion) return;
const accountList =
reviewerState === ReviewerState.CC ? this.ccList : this.reviewerList;
accountList?.confirmGroup(suggestion.group);
}
private onConfirm(overallStatus: ProgressStatus) {
switch (overallStatus) {
case ProgressStatus.NOT_STARTED:
this.saveReviewers();
break;
case ProgressStatus.SUCCESSFUL:
this.overlay?.close();
break;
case ProgressStatus.FAILED:
this.overlay?.close();
break;
}
}
private fireSuccessToasts() {
const numReviewersAdded =
(this.updatedAccountsByReviewerState.get(ReviewerState.REVIEWER)
?.length ?? 0) - this.getCurrentAccounts(ReviewerState.REVIEWER).length;
const numCcsAdded =
(this.updatedAccountsByReviewerState.get(ReviewerState.CC)?.length ?? 0) -
this.getCurrentAccounts(ReviewerState.CC).length;
let alert = '';
if (numReviewersAdded && numCcsAdded) {
alert = `${pluralize(numReviewersAdded, 'reviewer')} and ${pluralize(
numCcsAdded,
'CC'
)} added`;
} else if (numReviewersAdded) {
alert = `${pluralize(numReviewersAdded, 'reviewer')} added`;
} else {
alert = `${pluralize(numCcsAdded, 'CC')} added`;
}
fireAlert(this, alert);
}
private async saveReviewers() {
this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
type: 'add-reviewer',
selectedChangeCount: this.selectedChanges.length,
});
this.progressByChangeNum = new Map(
this.selectedChanges.map(change => [
change._number,
ProgressStatus.RUNNING,
])
);
const inFlightActions = this.getBulkActionsModel().addReviewers(
this.updatedAccountsByReviewerState,
getReplyByReason(this.account, this.serverConfig)
);
await allSettled(
inFlightActions.map((promise, index) => {
const change = this.selectedChanges[index];
return promise
.then(() => {
this.progressByChangeNum.set(
change._number,
ProgressStatus.SUCCESSFUL
);
this.requestUpdate();
})
.catch(() => {
this.progressByChangeNum.set(change._number, ProgressStatus.FAILED);
this.requestUpdate();
});
})
);
if (getOverallStatus(this.progressByChangeNum) === ProgressStatus.FAILED) {
this.reportingService.reportInteraction('bulk-action-failure', {
type: 'add-reviewer',
count: Array.from(this.progressByChangeNum.values()).filter(
status => status === ProgressStatus.FAILED
).length,
});
} else {
this.fireSuccessToasts();
this.closeOverlay();
fireReload(this);
}
}
private isFlowDisabled() {
// No additional checks are necessary. If the user has visibility enough to
// see the change, they have permission enough to add reviewers/cc.
return this.selectedChanges.length === 0;
}
// private but used in tests
getCurrentAccounts(reviewerState: ReviewerState) {
const reviewersPerChange = this.selectedChanges.map(
change =>
change.reviewers[reviewerState]?.filter(isNotOwner(change)) ?? []
);
return intersection(
reviewersPerChange,
(account1, account2) => accountKey(account1) === accountKey(account2)
);
}
private createSuggestionsProvider(
state: ReviewerState.CC | ReviewerState.REVIEWER
): ReviewerSuggestionsProvider {
const suggestionsProvider = new GrReviewerSuggestionsProvider(
this.restApiService,
state,
this.serverConfig,
this.isLoggedIn,
...this.selectedChanges
);
return suggestionsProvider;
}
}
function isNotOwner(change: ChangeInfo) {
return (account: AccountInfo) =>
accountKey(change.owner) !== accountKey(account);
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-list-reviewer-flow': GrChangeListReviewerFlow;
}
}