blob: 313b91f29feec8ae39773bdd2965144fcf89b962 [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 '../../../styles/gr-change-list-styles';
import '../../shared/gr-account-link/gr-account-link';
import '../../shared/gr-change-star/gr-change-star';
import '../../shared/gr-change-status/gr-change-status';
import '../../shared/gr-date-formatter/gr-date-formatter';
import '../../shared/gr-icons/gr-icons';
import '../../shared/gr-limited-text/gr-limited-text';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../../../styles/shared-styles';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-change-list-item_html';
import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getDisplayName} from '../../../utils/display-name-util';
import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {appContext} from '../../../services/app-context';
import {truncatePath} from '../../../utils/path-list-util';
import {changeStatuses} from '../../../utils/change-util';
import {isSelf, isServiceUser} from '../../../utils/account-util';
import {customElement, property} from '@polymer/decorators';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {
ChangeInfo,
ServerInfo,
AccountInfo,
QuickLabelInfo,
Timestamp,
} from '../../../types/common';
import {hasOwnProperty} from '../../../utils/common-util';
import {pluralize} from '../../../utils/string-util';
import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
enum ChangeSize {
XS = 10,
SMALL = 50,
MEDIUM = 250,
LARGE = 1000,
}
// export for testing
export enum LabelCategory {
NOT_APPLICABLE = 'NOT_APPLICABLE',
APPROVED = 'APPROVED',
POSITIVE = 'POSITIVE',
NEUTRAL = 'NEUTRAL',
UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS',
NEGATIVE = 'NEGATIVE',
REJECTED = 'REJECTED',
}
export interface ChangeListToggleReviewedDetail {
change: ChangeInfo;
reviewed: boolean;
}
// How many reviewers should be shown with an account-label?
const PRIMARY_REVIEWERS_COUNT = 2;
// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
const base = ChangeTableMixin(PolymerElement);
@customElement('gr-change-list-item')
export class GrChangeListItem extends base {
static get template() {
return htmlTemplate;
}
/** The logged-in user's account, or null if no user is logged in. */
@property({type: Object})
account: AccountInfo | null = null;
@property({type: Array})
visibleChangeTableColumns?: string[];
@property({type: Array})
labelNames?: string[];
@property({type: Object})
change?: ChangeInfo;
@property({type: Object})
config?: ServerInfo;
/** Name of the section in the change-list. Used for reporting. */
@property({type: String})
sectionName?: string;
@property({type: String, computed: '_computeChangeURL(change)'})
changeURL?: string;
@property({type: Array, computed: '_changeStatuses(change)'})
statuses?: ChangeStates[];
@property({type: Boolean})
showStar = false;
@property({type: Boolean})
showNumber = false;
@property({type: String, computed: '_computeChangeSize(change)'})
_changeSize?: string;
@property({type: Array})
_dynamicCellEndpoints?: string[];
reporting: ReportingService = appContext.reportingService;
override connectedCallback() {
super.connectedCallback();
getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
'change-list-item-cell'
);
});
}
_changeStatuses(change?: ChangeInfo) {
if (!change) return [];
return changeStatuses(change);
}
_computeChangeURL(change?: ChangeInfo) {
if (!change) return '';
return GerritNav.getUrlForChange(change);
}
_computeLabelTitle(change: ChangeInfo | undefined, labelName: string) {
const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
const category = this._computeLabelCategory(change, labelName);
if (!label || category === LabelCategory.NOT_APPLICABLE) {
return 'Label not applicable';
}
const titleParts: string[] = [];
if (category === LabelCategory.UNRESOLVED_COMMENTS) {
const num = change?.unresolved_comment_count ?? 0;
titleParts.push(pluralize(num, 'unresolved comment'));
}
const significantLabel =
label.rejected || label.approved || label.disliked || label.recommended;
if (significantLabel?.name) {
titleParts.push(`${labelName} by ${significantLabel.name}`);
}
if (titleParts.length > 0) {
return titleParts.join(',\n');
}
return labelName;
}
_computeLabelClass(change: ChangeInfo | undefined, labelName: string) {
const category = this._computeLabelCategory(change, labelName);
const classes = ['cell', 'label'];
switch (category) {
case LabelCategory.NOT_APPLICABLE:
classes.push('u-gray-background');
break;
case LabelCategory.APPROVED:
classes.push('u-green');
break;
case LabelCategory.POSITIVE:
classes.push('u-monospace');
classes.push('u-green');
break;
case LabelCategory.NEGATIVE:
classes.push('u-monospace');
classes.push('u-red');
break;
case LabelCategory.REJECTED:
classes.push('u-red');
break;
}
return classes.sort().join(' ');
}
_computeHasLabelIcon(change: ChangeInfo | undefined, labelName: string) {
return this._computeLabelIcon(change, labelName) !== '';
}
_computeLabelIcon(change: ChangeInfo | undefined, labelName: string): string {
const category = this._computeLabelCategory(change, labelName);
switch (category) {
case LabelCategory.APPROVED:
return 'gr-icons:check';
case LabelCategory.UNRESOLVED_COMMENTS:
return 'gr-icons:comment';
case LabelCategory.REJECTED:
return 'gr-icons:close';
default:
return '';
}
}
_computeLabelCategory(change: ChangeInfo | undefined, labelName: string) {
const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
if (!label) {
return LabelCategory.NOT_APPLICABLE;
}
if (label.rejected) {
return LabelCategory.REJECTED;
}
if (label.value && label.value < 0) {
return LabelCategory.NEGATIVE;
}
if (change?.unresolved_comment_count && labelName === 'Code-Review') {
return LabelCategory.UNRESOLVED_COMMENTS;
}
if (label.approved) {
return LabelCategory.APPROVED;
}
if (label.value && label.value > 0) {
return LabelCategory.POSITIVE;
}
return LabelCategory.NEUTRAL;
}
_computeLabelValue(change: ChangeInfo | undefined, labelName: string) {
const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
const category = this._computeLabelCategory(change, labelName);
switch (category) {
case LabelCategory.NOT_APPLICABLE:
return '';
case LabelCategory.APPROVED:
return '\u2713'; // ✓
case LabelCategory.POSITIVE:
return `+${label?.value}`;
case LabelCategory.NEUTRAL:
return '';
case LabelCategory.UNRESOLVED_COMMENTS:
return 'u';
case LabelCategory.NEGATIVE:
return `${label?.value}`;
case LabelCategory.REJECTED:
return '\u2715'; // ✕
}
}
_computeRepoUrl(change?: ChangeInfo) {
if (!change) return '';
return GerritNav.getUrlForProjectChanges(
change.project,
true,
change.internalHost
);
}
_computeRepoBranchURL(change?: ChangeInfo) {
if (!change) return '';
return GerritNav.getUrlForBranch(
change.branch,
change.project,
undefined,
change.internalHost
);
}
_computeTopicURL(change?: ChangeInfo) {
if (!change?.topic) {
return '';
}
return GerritNav.getUrlForTopic(change.topic, change.internalHost);
}
/**
* Computes the display string for the project column. If there is a host
* specified in the change detail, the string will be prefixed with it.
*
* @param truncate whether or not the project name should be
* truncated. If this value is truthy, the name will be truncated.
*/
_computeRepoDisplay(change: ChangeInfo | undefined, truncate: boolean) {
if (!change?.project) {
return '';
}
let str = '';
if (change.internalHost) {
str += change.internalHost + '/';
}
str += truncate ? truncatePath(change.project, 2) : change.project;
return str;
}
_computeSizeTooltip(change?: ChangeInfo) {
if (
!change ||
change.insertions + change.deletions === 0 ||
isNaN(change.insertions + change.deletions)
) {
return 'Size unknown';
} else {
return `added ${change.insertions}, removed ${change.deletions} lines`;
}
}
_hasAttention(account: AccountInfo) {
if (!this.change || !this.change.attention_set || !account._account_id) {
return false;
}
return hasOwnProperty(this.change.attention_set, account._account_id);
}
/**
* Computes the array of all reviewers with sorting the reviewers in the
* attention set before others, and the current user first.
*/
_computeReviewers(change?: ChangeInfo) {
if (!change?.reviewers || !change?.reviewers.REVIEWER) return [];
const reviewers = [...change.reviewers.REVIEWER].filter(
r =>
(!change.owner || change.owner._account_id !== r._account_id) &&
!isServiceUser(r)
);
reviewers.sort((r1, r2) => {
if (this.account) {
if (isSelf(r1, this.account)) return -1;
if (isSelf(r2, this.account)) return 1;
}
if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
return (r1.name || '').localeCompare(r2.name || '');
});
return reviewers;
}
_computePrimaryReviewers(change?: ChangeInfo) {
return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
}
_computeAdditionalReviewers(change?: ChangeInfo) {
return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
}
_computeAdditionalReviewersCount(change?: ChangeInfo) {
return this._computeAdditionalReviewers(change).length;
}
_computeAdditionalReviewersTitle(
change: ChangeInfo | undefined,
config: ServerInfo
) {
if (!change || !config) return '';
return this._computeAdditionalReviewers(change)
.map(user => getDisplayName(config, user, true))
.join(', ');
}
_computeComments(unresolved_comment_count?: number) {
if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
return `${unresolved_comment_count} unresolved`;
}
/**
* TShirt sizing is based on the following paper:
* http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
*/
_computeChangeSize(change?: ChangeInfo) {
if (!change) return null;
const delta = change.insertions + change.deletions;
if (isNaN(delta) || delta === 0) {
return null; // Unknown
}
if (delta < ChangeSize.XS) {
return 'XS';
} else if (delta < ChangeSize.SMALL) {
return 'S';
} else if (delta < ChangeSize.MEDIUM) {
return 'M';
} else if (delta < ChangeSize.LARGE) {
return 'L';
} else {
return 'XL';
}
}
_computeWaiting(
account?: AccountInfo,
change?: ChangeInfo
): Timestamp | undefined {
if (!account?._account_id || !change?.attention_set) return undefined;
return change?.attention_set[account._account_id]?.last_update;
}
toggleReviewed() {
if (!this.change) return;
const newVal = !this.change?.reviewed;
this.set('change.reviewed', newVal);
const detail: ChangeListToggleReviewedDetail = {
change: this.change,
reviewed: newVal,
};
this.dispatchEvent(
new CustomEvent('toggle-reviewed', {
bubbles: true,
composed: true,
detail,
})
);
}
_handleChangeClick() {
// Don't prevent the default and neither stop bubbling. We just want to
// report the click, but then let the browser handle the click on the link.
const selfId = (this.account && this.account._account_id) || -1;
const ownerId =
(this.change && this.change.owner && this.change.owner._account_id) || -1;
this.reporting.reportInteraction('change-row-clicked', {
section: this.sectionName,
isOwner: selfId === ownerId,
});
}
_computeCommaHidden(index?: number, change?: ChangeInfo) {
if (index === undefined) return false;
if (change === undefined) return false;
const additionalCount = this._computeAdditionalReviewersCount(change);
const primaryCount = this._computePrimaryReviewers(change).length;
const isLast = index === primaryCount - 1;
return isLast && additionalCount === 0;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-list-item': GrChangeListItem;
}
}