blob: 254eccac7582c200f18e87081a5f63774d532031 [file] [log] [blame]
/**
* @license
* Copyright (C) 2015 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 '../../shared/gr-account-chip/gr-account-chip';
import '../../shared/gr-button/gr-button';
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-reviewer-list_html';
import {isServiceUser} from '../../../utils/account-util';
import {hasAttention} from '../../../utils/attention-set-util';
import {customElement, property, computed, observe} from '@polymer/decorators';
import {
ChangeInfo,
ServerInfo,
LabelNameToValueMap,
AccountInfo,
ApprovalInfo,
Reviewers,
AccountId,
DetailedLabelInfo,
EmailAddress,
} from '../../../types/common';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
import {hasOwnProperty} from '../../../utils/common-util';
import {isRemovableReviewer} from '../../../utils/change-util';
import {ReviewerState} from '../../../constants/constants';
import {appContext} from '../../../services/app-context';
import {KnownExperimentId} from '../../../services/flags/flags';
@customElement('gr-reviewer-list')
export class GrReviewerList extends GestureEventListeners(
LegacyElementMixin(PolymerElement)
) {
static get template() {
return htmlTemplate;
}
/**
* Fired when the "Add reviewer..." button is tapped.
*
* @event show-reply-dialog
*/
@property({type: Object})
change?: ChangeInfo;
@property({type: Object})
serverConfig?: ServerInfo;
@property({type: Boolean, reflectToAttribute: true})
disabled = false;
@property({type: Boolean})
mutable = false;
@property({type: Boolean})
reviewersOnly = false;
@property({type: Boolean})
ccsOnly = false;
@property({type: Array})
_displayedReviewers: AccountInfo[] = [];
@property({type: Array})
_reviewers: AccountInfo[] = [];
@property({type: Boolean})
_showInput = false;
@property({type: Object})
_xhrPromise?: Promise<Response | undefined>;
@property({type: Boolean})
_isNewChangeSummaryUiEnabled = false;
private readonly restApiService = appContext.restApiService;
private readonly flagsService = appContext.flagsService;
/** @override */
ready() {
super.ready();
this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
KnownExperimentId.NEW_CHANGE_SUMMARY_UI
);
}
@computed('ccsOnly')
get _addLabel() {
return this.ccsOnly ? 'Add CC' : 'Add reviewer';
}
@computed('_reviewers', '_displayedReviewers')
get _hiddenReviewerCount() {
// Polymer 2: check for undefined
if (
this._reviewers === undefined ||
this._displayedReviewers === undefined
) {
return undefined;
}
return this._reviewers.length - this._displayedReviewers.length;
}
/**
* Converts change.permitted_labels to an array of hashes of label keys to
* numeric scores.
* Example:
* [{
* 'Code-Review': ['-1', ' 0', '+1']
* }]
* will be converted to
* [{
* label: 'Code-Review',
* scores: [-1, 0, 1]
* }]
*/
_permittedLabelsToNumericScores(labels: LabelNameToValueMap | undefined) {
if (!labels) return [];
return Object.keys(labels).map(label => {
return {
label,
scores: labels[label].map(v => Number(v)),
};
});
}
/**
* Returns hash of labels to max permitted score.
*
* @returns labels to max permitted scores hash
*/
_getMaxPermittedScores(change: ChangeInfo) {
return this._permittedLabelsToNumericScores(change.permitted_labels)
.map(({label, scores}) => {
return {
[label]: scores.reduce((a, b) => Math.max(a, b)),
};
})
.reduce((acc, i) => Object.assign(acc, i), {});
}
/**
* Returns max permitted score for reviewer.
*/
_getReviewerPermittedScore(
reviewer: AccountInfo,
change: ChangeInfo,
label: string
) {
// Note (issue 7874): sometimes the "all" list is not included in change
// detail responses, even when DETAILED_LABELS is included in options.
if (!change.labels) {
return NaN;
}
const detailedLabel = change.labels[label] as DetailedLabelInfo;
if (!detailedLabel.all) {
return NaN;
}
const detailed = detailedLabel.all
.filter(
(approval: ApprovalInfo) =>
reviewer._account_id === approval._account_id
)
.pop();
if (!detailed) {
return NaN;
}
if (hasOwnProperty(detailed, 'permitted_voting_range')) {
if (!detailed.permitted_voting_range) return NaN;
return detailed.permitted_voting_range.max;
} else if (hasOwnProperty(detailed, 'value')) {
// If preset, user can vote on the label.
return 0;
}
return NaN;
}
_computeVoteableText(reviewer: AccountInfo, change: ChangeInfo) {
if (!change || !change.labels) {
return '';
}
const maxScores = [];
const maxPermitted = this._getMaxPermittedScores(change);
for (const label of Object.keys(change.labels)) {
const maxScore = this._getReviewerPermittedScore(reviewer, change, label);
if (isNaN(maxScore) || maxScore < 0) {
continue;
}
if (maxScore > 0 && maxScore === maxPermitted[label]) {
maxScores.push(`${label}: +${maxScore}`);
} else {
maxScores.push(`${label}`);
}
}
return maxScores.join(', ');
}
@observe('change.reviewers.*', 'change.owner', 'serverConfig')
_reviewersChanged(
changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
owner: AccountInfo,
serverConfig: ServerInfo
) {
// Polymer 2: check for undefined
if (
changeRecord === undefined ||
owner === undefined ||
serverConfig === undefined ||
this.change === undefined
) {
return;
}
let result: AccountInfo[] = [];
const reviewers = changeRecord.base;
for (const key in reviewers) {
if (this.reviewersOnly && key !== 'REVIEWER') {
continue;
}
if (this.ccsOnly && key !== 'CC') {
continue;
}
if (key === 'REVIEWER' || key === 'CC') {
result = result.concat(reviewers[key]!);
}
}
this._reviewers = result
.filter(reviewer => reviewer._account_id !== owner._account_id)
// Sort order:
// 1. Human users in the attention set.
// 2. Other human users.
// 3. Service users.
.sort((r1, r2) => {
const a1 = hasAttention(serverConfig, r1, this.change!) ? 1 : 0;
const a2 = hasAttention(serverConfig, r2, this.change!) ? 1 : 0;
const s1 = isServiceUser(r1) ? -2 : 0;
const s2 = isServiceUser(r2) ? -2 : 0;
return a2 - a1 + s2 - s1;
});
if (this._reviewers.length > 8) {
this._displayedReviewers = this._reviewers.slice(0, 6);
} else {
this._displayedReviewers = this._reviewers;
}
}
_computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
return mutable && isRemovableReviewer(this.change, reviewer);
}
_handleRemove(e: Event) {
e.preventDefault();
const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
if (!target.account || !this.change) {
return;
}
const accountID = target.account._account_id || target.account.email;
this.disabled = true;
if (!accountID) return;
this._xhrPromise = this._removeReviewer(accountID)
.then((response: Response | undefined) => {
this.disabled = false;
if (!response || !response.ok) {
return response;
}
if (!this.change || !this.change.reviewers) return;
const reviewers = this.change.reviewers;
for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
const reviewerStateByType = reviewers[type] || [];
reviewers[type] = reviewerStateByType;
for (let i = 0; i < reviewerStateByType.length; i++) {
if (
reviewerStateByType[i]._account_id === accountID ||
reviewerStateByType[i].email === accountID
) {
this.splice('change.reviewers.' + type, i, 1);
break;
}
}
}
return;
})
.catch((err: Error) => {
this.disabled = false;
throw err;
});
}
_handleAddTap(e: Event) {
e.preventDefault();
const value = {
reviewersOnly: false,
ccsOnly: false,
};
if (this.reviewersOnly) {
value.reviewersOnly = true;
}
if (this.ccsOnly) {
value.ccsOnly = true;
}
this.dispatchEvent(
new CustomEvent('show-reply-dialog', {
detail: {value},
composed: true,
bubbles: true,
})
);
}
_handleViewAll() {
this._displayedReviewers = this._reviewers;
}
_removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
if (!this.change) return Promise.resolve(undefined);
return this.restApiService.removeChangeReviewer(this.change._number, id);
}
}