blob: 15532d2b791513e53a137ab53c45babec6e73c42 [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-label/gr-account-label';
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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary';
import '../gr-change-list-column-requirement/gr-change-list-column-requirement';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
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 {getAppContext} 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 {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {
ChangeInfo,
ServerInfo,
AccountInfo,
QuickLabelInfo,
Timestamp,
} from '../../../types/common';
import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
import {pluralize} from '../../../utils/string-util';
import {showNewSubmitRequirements} from '../../../utils/label-util';
import {changeListStyles} from '../../../styles/gr-change-list-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
import {ifDefined} from 'lit/directives/if-defined';
import {KnownExperimentId} from '../../../services/flags/flags';
import {WAITING} from '../../../constants/constants';
import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
import {resolve} from '../../../models/dependency';
import {subscribe} from '../../lit/subscription-controller';
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',
}
// How many reviewers should be shown with an account-label?
const PRIMARY_REVIEWERS_COUNT = 2;
declare global {
interface HTMLElementTagNameMap {
'gr-change-list-item': GrChangeListItem;
}
}
/**
* @attr {Boolean} selected - change list item is selected by cursor
*/
@customElement('gr-change-list-item')
export class GrChangeListItem extends LitElement {
/** 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: Boolean})
showStar = false;
@property({type: Boolean})
showNumber = false;
@state() private dynamicCellEndpoints?: string[];
reporting: ReportingService = getAppContext().reportingService;
private readonly flagsService = getAppContext().flagsService;
@state() private checked = false;
private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
override connectedCallback() {
super.connectedCallback();
getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
this.dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
'change-list-item-cell'
);
});
subscribe(
this,
this.getBulkActionsModel().selectedChangeNums$,
selectedChangeNums => {
if (!this.change) return;
this.checked = selectedChangeNums.includes(this.change._number);
}
);
}
static override get styles() {
return [
changeListStyles,
sharedStyles,
submitRequirementsStyles,
css`
:host {
display: table-row;
color: var(--primary-text-color);
}
:host(:focus) {
outline: none;
}
:host(:hover) {
background-color: var(--hover-background-color);
}
.container {
position: relative;
}
.content {
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.content a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.comments,
.reviewers,
.requirements {
white-space: nowrap;
}
.reviewers {
--account-max-length: 70px;
}
.spacer {
height: 0;
overflow: hidden;
}
.status {
align-items: center;
display: inline-flex;
}
.status .comma {
padding-right: var(--spacing-xs);
}
/* Used to hide the leading separator comma for statuses. */
.status .comma:first-of-type {
display: none;
}
.size gr-tooltip-content {
margin: -0.4rem -0.6rem;
max-width: 2.5rem;
padding: var(--spacing-m) var(--spacing-l);
}
.size span {
border-radius: var(--border-radius);
color: var(--dashboard-size-text);
font-size: var(--font-size-small);
/* To set height and width of span, it has to be inline block */
display: inline-block;
height: 20px;
width: 20px;
text-align: center;
vertical-align: top;
}
.size span.size-xs {
background-color: var(--dashboard-size-xs);
color: var(--dashboard-size-xs-text);
}
.size span.size-s {
background-color: var(--dashboard-size-s);
}
.size span.size-m {
background-color: var(--dashboard-size-m);
}
.size span.size-l {
background-color: var(--dashboard-size-l);
}
.size span.size-xl {
background-color: var(--dashboard-size-xl);
color: var(--dashboard-size-xl-text);
}
a {
color: inherit;
cursor: pointer;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.subject:hover .content {
text-decoration: underline;
}
.u-monospace {
font-family: var(--monospace-font-family);
font-size: var(--font-size-mono);
line-height: var(--line-height-mono);
}
.u-green,
.u-green iron-icon {
color: var(--positive-green-text-color);
}
.u-red,
.u-red iron-icon {
color: var(--negative-red-text-color);
}
.u-gray-background {
background-color: var(--table-header-background-color);
}
.comma,
.placeholder {
color: var(--deemphasized-text-color);
}
.cell.selection input {
vertical-align: middle;
}
.cell.label {
font-weight: var(--font-weight-normal);
}
.cell.label iron-icon {
vertical-align: top;
}
/* Requirement child needs whole area */
.cell.requirement {
padding: 0;
margin: 0;
}
@media only screen and (max-width: 50em) {
:host {
display: flex;
}
}
`,
];
}
override render() {
const changeUrl = this.computeChangeURL();
return html`
<td aria-hidden="true" class="cell leftPadding"></td>
${this.renderCellSelectionBox()} ${this.renderCellStar()}
${this.renderCellNumber(changeUrl)} ${this.renderCellSubject(changeUrl)}
${this.renderCellStatus()} ${this.renderCellOwner()}
${this.renderCellReviewers()} ${this.renderCellComments()}
${this.renderCellRepo()} ${this.renderCellBranch()}
${this.renderCellUpdated()} ${this.renderCellSubmitted()}
${this.renderCellWaiting()} ${this.renderCellSize()}
${this.renderCellRequirements()}
${this.labelNames?.map(labelNames => this.renderChangeLabels(labelNames))}
${this.dynamicCellEndpoints?.map(pluginEndpointName =>
this.renderChangePluginEndpoint(pluginEndpointName)
)}
`;
}
private renderCellSelectionBox() {
if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
return html`
<td class="cell selection">
<!--
The .checked property must be used rather than the attribute because
the attribute only controls the default checked state and does not
update the current checked state.
See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked
-->
<input
type="checkbox"
.checked=${this.checked}
@click=${() => this.handleChangeSelectionClick()}
/>
</td>
`;
}
private renderCellStar() {
if (!this.showStar) return;
return html`
<td class="cell star">
<gr-change-star .change=${this.change}></gr-change-star>
</td>
`;
}
private renderCellNumber(changeUrl: string) {
if (!this.showNumber) return;
return html`
<td class="cell number">
<a href=${changeUrl}>${this.change?._number}</a>
</td>
`;
}
private renderCellSubject(changeUrl: string) {
if (this.computeIsColumnHidden('Subject', this.visibleChangeTableColumns))
return;
return html`
<td class="cell subject">
<a
title=${ifDefined(this.change?.subject)}
href=${changeUrl}
@click=${() => this.handleChangeClick()}
>
<div class="container">
<div class="content">${this.change?.subject}</div>
<div class="spacer">${this.change?.subject}</div>
<span>&nbsp;</span>
</div>
</a>
</td>
`;
}
private renderCellStatus() {
if (this.computeIsColumnHidden('Status', this.visibleChangeTableColumns))
return;
return html` <td class="cell status">${this.renderChangeStatus()}</td> `;
}
private renderChangeStatus() {
if (!this.changeStatuses().length) {
return html`<span class="placeholder">--</span>`;
}
return this.changeStatuses().map(
status => html`
<div class="comma">,</div>
<gr-change-status flat .status=${status}></gr-change-status>
`
);
}
private renderCellOwner() {
if (this.computeIsColumnHidden('Owner', this.visibleChangeTableColumns))
return;
return html`
<td class="cell owner">
<gr-account-label
highlightAttention
clickable
.change=${this.change}
.account=${this.change?.owner}
></gr-account-label>
</td>
`;
}
private renderCellReviewers() {
if (this.computeIsColumnHidden('Reviewers', this.visibleChangeTableColumns))
return;
return html`
<td class="cell reviewers">
<div>
${this.computePrimaryReviewers().map((reviewer, index) =>
this.renderChangeReviewers(reviewer, index)
)}
${this.computeAdditionalReviewersCount()
? html`<span title=${this.computeAdditionalReviewersTitle()}
>+${this.computeAdditionalReviewersCount()}</span
>`
: ''}
</div>
</td>
`;
}
private renderChangeReviewers(reviewer: AccountInfo, index: number) {
return html`
<gr-account-label
clickable
hideAvatar
firstName
highlightAttention
.change=${this.change}
.account=${reviewer}
></gr-account-label
><span ?hidden=${this.computeCommaHidden(index)} aria-hidden="true"
>,
</span>
`;
}
private renderCellComments() {
if (this.computeIsColumnHidden('Comments', this.visibleChangeTableColumns))
return;
return html`
<td class="cell comments">
${this.change?.unresolved_comment_count
? html`<iron-icon icon="gr-icons:comment"></iron-icon>`
: ''}
<span
>${this.computeComments(this.change?.unresolved_comment_count)}</span
>
</td>
`;
}
private renderCellRepo() {
if (this.computeIsColumnHidden('Repo', this.visibleChangeTableColumns))
return;
return html`
<td class="cell repo">
<a class="fullRepo" href=${this.computeRepoUrl()}>
${this.computeRepoDisplay()}
</a>
<a
class="truncatedRepo"
href=${this.computeRepoUrl()}
title=${this.computeRepoDisplay()}
>
${this.computeTruncatedRepoDisplay()}
</a>
</td>
`;
}
private renderCellBranch() {
if (this.computeIsColumnHidden('Branch', this.visibleChangeTableColumns))
return;
return html`
<td class="cell branch">
<a href=${this.computeRepoBranchURL()}> ${this.change?.branch} </a>
${this.renderChangeBranch()}
</td>
`;
}
private renderChangeBranch() {
if (!this.change?.topic) return;
return html`
(<a href=${this.computeTopicURL()}
><!--
--><gr-limited-text .limit=${50} .text=${this.change.topic}>
</gr-limited-text
><!--
--></a
>)
`;
}
private renderCellUpdated() {
if (this.computeIsColumnHidden('Updated', this.visibleChangeTableColumns))
return;
return html`
<td class="cell updated">
<gr-date-formatter
withTooltip
.dateStr=${this.formatDate(this.change?.updated)}
></gr-date-formatter>
</td>
`;
}
private renderCellSubmitted() {
if (this.computeIsColumnHidden('Submitted', this.visibleChangeTableColumns))
return;
return html`
<td class="cell submitted">
<gr-date-formatter
withTooltip
.dateStr=${this.formatDate(this.change?.submitted)}
></gr-date-formatter>
</td>
`;
}
private renderCellWaiting() {
if (this.computeIsColumnHidden(WAITING, this.visibleChangeTableColumns))
return;
return html`
<td class="cell waiting">
<gr-date-formatter
withTooltip
forceRelative
relativeOptionNoAgo
.dateStr=${this.computeWaiting()}
></gr-date-formatter>
</td>
`;
}
private renderCellSize() {
if (this.computeIsColumnHidden('Size', this.visibleChangeTableColumns))
return;
return html`
<td class="cell size">
<gr-tooltip-content has-tooltip title=${this.computeSizeTooltip()}>
${this.renderChangeSize()}
</gr-tooltip-content>
</td>
`;
}
private renderChangeSize() {
const changeSize = this.computeChangeSize();
if (!changeSize) return html`<span class="placeholder">--</span>`;
return html`
<span class="size-${changeSize.toLowerCase()}">${changeSize}</span>
`;
}
private renderCellRequirements() {
if (this.computeIsColumnHidden(' Status ', this.visibleChangeTableColumns))
return;
return html`
<td class="cell requirements">
<gr-change-list-column-requirements-summary .change=${this.change}>
</gr-change-list-column-requirements-summary>
</td>
`;
}
private renderChangeLabels(labelName: string) {
if (showNewSubmitRequirements(this.flagsService, this.change)) {
return html` <td class="cell label requirement">
<gr-change-list-column-requirement
.change=${this.change}
.labelName=${labelName}
>
</gr-change-list-column-requirement>
</td>`;
}
return html`
<td
title=${this.computeLabelTitle(labelName)}
class=${this.computeLabelClass(labelName)}
>
${this.renderChangeHasLabelIcon(labelName)}
</td>
`;
}
private renderChangeHasLabelIcon(labelName: string) {
if (this.computeLabelIcon(labelName) === '')
return html`<span>${this.computeLabelValue(labelName)}</span>`;
return html`
<iron-icon icon=${this.computeLabelIcon(labelName)}></iron-icon>
`;
}
private renderChangePluginEndpoint(pluginEndpointName: string) {
return html`
<td class="cell endpoint">
<gr-endpoint-decorator name=${pluginEndpointName}>
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
</gr-endpoint-decorator>
</td>
`;
}
private handleChangeSelectionClick() {
assertIsDefined(this.change, 'change');
this.checked = !this.checked;
if (this.checked)
this.getBulkActionsModel().addSelectedChangeNum(this.change._number);
else
this.getBulkActionsModel().removeSelectedChangeNum(this.change._number);
}
private changeStatuses() {
if (!this.change) return [];
return changeStatuses(this.change);
}
private computeChangeURL() {
if (!this.change) return '';
return GerritNav.getUrlForChange(this.change);
}
// private but used in test
computeLabelTitle(labelName: string) {
const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
const category = this.computeLabelCategory(labelName);
if (!label || category === LabelCategory.NOT_APPLICABLE) {
return 'Label not applicable';
}
const titleParts: string[] = [];
if (category === LabelCategory.UNRESOLVED_COMMENTS) {
const num = this.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;
}
// private but used in test
computeLabelClass(labelName: string) {
const classes = ['cell', 'label'];
const category = this.computeLabelCategory(labelName);
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(' ');
}
// private but used in test
computeLabelIcon(labelName: string): string {
const category = this.computeLabelCategory(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 '';
}
}
// private but used in test
computeLabelCategory(labelName: string) {
const label: QuickLabelInfo | undefined = this.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 (this.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;
}
// private but used in test
computeLabelValue(labelName: string) {
const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
const category = this.computeLabelCategory(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'; // ✕
default:
return '';
}
}
private computeRepoUrl() {
if (!this.change) return '';
return GerritNav.getUrlForProjectChanges(
this.change.project,
true,
this.change.internalHost
);
}
private computeRepoBranchURL() {
if (!this.change) return '';
return GerritNav.getUrlForBranch(
this.change.branch,
this.change.project,
undefined,
this.change.internalHost
);
}
private computeTopicURL() {
if (!this.change?.topic) return '';
return GerritNav.getUrlForTopic(
this.change.topic,
this.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.
*
* private but used in test
*/
computeRepoDisplay() {
if (!this.change?.project) return '';
let str = '';
if (this.change.internalHost) {
str += this.change.internalHost + '/';
}
str += this.change.project;
return str;
}
// private but used in test
computeTruncatedRepoDisplay() {
if (!this.change?.project) {
return '';
}
let str = '';
if (this.change.internalHost) {
str += this.change.internalHost + '/';
}
str += truncatePath(this.change.project, 2);
return str;
}
// private but used in test
computeSizeTooltip() {
if (
!this.change ||
this.change.insertions + this.change.deletions === 0 ||
isNaN(this.change.insertions + this.change.deletions)
) {
return 'Size unknown';
} else {
return `added ${this.change.insertions}, removed ${this.change.deletions} lines`;
}
}
private 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.
*
* private but used in test
*/
computeReviewers() {
if (!this.change?.reviewers || !this.change?.reviewers.REVIEWER) return [];
const reviewers = [...this.change.reviewers.REVIEWER].filter(
r =>
(!this.change?.owner ||
this.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;
}
private computePrimaryReviewers() {
return this.computeReviewers().slice(0, PRIMARY_REVIEWERS_COUNT);
}
private computeAdditionalReviewers() {
return this.computeReviewers().slice(PRIMARY_REVIEWERS_COUNT);
}
private computeAdditionalReviewersCount() {
return this.computeAdditionalReviewers().length;
}
private computeAdditionalReviewersTitle() {
if (!this.change || !this.config) return '';
return this.computeAdditionalReviewers()
.map(user => getDisplayName(this.config, user, true))
.join(', ');
}
private 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
*
* private but used in test
*/
computeChangeSize() {
if (!this.change) return null;
const delta = this.change.insertions + this.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';
}
}
private computeWaiting(): Timestamp | undefined {
if (!this.account?._account_id || !this.change?.attention_set)
return undefined;
return this.change?.attention_set[this.account._account_id]?.last_update;
}
private computeIsColumnHidden(
columnToCheck?: string,
columnsToDisplay?: string[]
) {
if (!columnsToDisplay || !columnToCheck) {
return false;
}
return !columnsToDisplay.includes(columnToCheck);
}
private formatDate(date: Timestamp | undefined): string | undefined {
if (!date) return undefined;
return date.toString();
}
private 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,
});
}
private computeCommaHidden(index: number) {
const additionalCount = this.computeAdditionalReviewersCount();
const primaryCount = this.computePrimaryReviewers().length;
const isLast = index === primaryCount - 1;
return isLast && additionalCount === 0;
}
}