blob: c78d6c0255d2e76a2092de7c476a63e1bcaf783c [file] [log] [blame]
/**
* @license
* Copyright (C) 2020 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 {html} from 'lit-html';
import {css, customElement, property} from 'lit-element';
import {GrLitElement} from '../../lit/gr-lit-element';
import {sharedStyles} from '../../../styles/shared-styles';
import {appContext} from '../../../services/app-context';
import {
allRunsLatestPatchsetLatestAttempt$,
aPluginHasRegistered$,
CheckResult,
CheckRun,
errorMessageLatest$,
loginCallbackLatest$,
someProvidersAreLoadingLatest$,
} from '../../../services/checks/checks-model';
import {Category, RunStatus} from '../../../api/checks';
import {fireShowPrimaryTab} from '../../../utils/event-util';
import '../../shared/gr-avatar/gr-avatar';
import {
firstPrimaryLink,
getResultsOf,
hasCompletedWithoutResults,
hasResultsOf,
iconFor,
isRunning,
isRunningOrHasCompleted,
isStatus,
labelFor,
} from '../../../services/checks/checks-util';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {
CommentThread,
getFirstComment,
hasHumanReply,
isResolved,
isRobotThread,
isUnresolved,
} from '../../../utils/comment-util';
import {pluralize} from '../../../utils/string-util';
import {AccountInfo} from '../../../types/common';
import {notUndefined} from '../../../types/types';
import {uniqueDefinedAvatar} from '../../../utils/account-util';
import {PrimaryTab} from '../../../constants/constants';
import {ChecksTabState, CommentTabState} from '../../../types/events';
import {spinnerStyles} from '../../../styles/gr-spinner-styles';
import {modifierPressed} from '../../../utils/dom-util';
export enum SummaryChipStyles {
INFO = 'info',
WARNING = 'warning',
CHECK = 'check',
UNDEFINED = '',
}
@customElement('gr-summary-chip')
export class GrSummaryChip extends GrLitElement {
@property()
icon = '';
@property()
styleType = SummaryChipStyles.UNDEFINED;
@property()
category?: CommentTabState;
private readonly reporting = appContext.reportingService;
static get styles() {
return [
sharedStyles,
css`
.summaryChip {
color: var(--chip-color);
cursor: pointer;
display: inline-block;
padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
var(--spacing-s);
margin-right: var(--spacing-s);
border-radius: 12px;
border: 1px solid gray;
vertical-align: top;
}
iron-icon {
width: var(--line-height-small);
height: var(--line-height-small);
vertical-align: top;
}
.summaryChip.warning {
border-color: var(--warning-foreground);
background: var(--warning-background);
}
.summaryChip.warning:hover {
background: var(--warning-background-hover);
box-shadow: var(--elevation-level-1);
}
.summaryChip.warning:focus-within {
background: var(--warning-background-focus);
}
.summaryChip.warning iron-icon {
color: var(--warning-foreground);
}
.summaryChip.check {
border-color: var(--gray-foreground);
background: var(--gray-background);
}
.summaryChip.check:hover {
background: var(--gray-background-hover);
box-shadow: var(--elevation-level-1);
}
.summaryChip.check:focus-within {
background: var(--gray-background-focus);
}
.summaryChip.check iron-icon {
color: var(--gray-foreground);
}
`,
];
}
render() {
const chipClass = `summaryChip font-small ${this.styleType}`;
const grIcon = this.icon ? `gr-icons:${this.icon}` : '';
return html`<button class="${chipClass}" @click="${this.handleClick}">
${this.icon && html`<iron-icon icon="${grIcon}"></iron-icon>`}
<slot></slot>
</button>`;
}
private handleClick(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
this.reporting.reportInteraction('comment chip click', {
category: this.category,
});
fireShowPrimaryTab(this, PrimaryTab.COMMENT_THREADS, true, {
commentTab: this.category,
});
}
}
@customElement('gr-checks-chip')
export class GrChecksChip extends GrLitElement {
@property()
statusOrCategory?: Category | RunStatus;
@property()
text = '';
static get styles() {
return [
sharedStyles,
css`
:host {
display: inline-block;
}
.checksChip {
color: var(--chip-color);
cursor: pointer;
display: inline-block;
margin-right: var(--spacing-s);
padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
var(--spacing-s);
border-radius: 12px;
border: 1px solid gray;
vertical-align: top;
}
.checksChip .text {
display: inline-block;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
iron-icon {
width: var(--line-height-small);
height: var(--line-height-small);
vertical-align: top;
}
.checksChip.error {
color: var(--error-foreground);
border-color: var(--error-foreground);
background: var(--error-background);
}
.checksChip.error:hover {
background: var(--error-background-hover);
box-shadow: var(--elevation-level-1);
}
.checksChip.error:focus-within {
background: var(--error-background-focus);
}
.checksChip.error iron-icon {
color: var(--error-foreground);
}
.checksChip.warning {
border-color: var(--warning-foreground);
background: var(--warning-background);
}
.checksChip.warning:hover {
background: var(--warning-background-hover);
box-shadow: var(--elevation-level-1);
}
.checksChip.warning:focus-within {
background: var(--warning-background-focus);
}
.checksChip.warning iron-icon {
color: var(--warning-foreground);
}
.checksChip.info-outline {
border-color: var(--info-foreground);
background: var(--info-background);
}
.checksChip.info-outline:hover {
background: var(--info-background-hover);
box-shadow: var(--elevation-level-1);
}
.checksChip.info-outline:focus-within {
background: var(--info-background-focus);
}
.checksChip.info-outline iron-icon {
color: var(--info-foreground);
}
.checksChip.check-circle-outline {
border-color: var(--success-foreground);
background: var(--success-background);
}
.checksChip.check-circle-outline:hover {
background: var(--success-background-hover);
box-shadow: var(--elevation-level-1);
}
.checksChip.check-circle-outline:focus-within {
background: var(--success-background-focus);
}
.checksChip.check-circle-outline iron-icon {
color: var(--success-foreground);
}
.checksChip.timelapse {
}
.checksChip.timelapse {
border-color: var(--gray-foreground);
background: var(--gray-background);
}
.checksChip.timelapse:hover {
background: var(--gray-background-hover);
box-shadow: var(--elevation-level-1);
}
.checksChip.timelapse:focus-within {
background: var(--gray-background-focus);
}
.checksChip.timelapse iron-icon {
color: var(--gray-foreground);
}
`,
];
}
render() {
if (!this.text) return;
if (!this.statusOrCategory) return;
const icon = iconFor(this.statusOrCategory);
const label = labelFor(this.statusOrCategory);
const count = Number(this.text);
let ariaLabel = label;
if (!isNaN(count)) {
const type = isStatus(this.statusOrCategory) ? 'run' : 'result';
const plural = count > 1 ? 's' : '';
ariaLabel = `${this.text} ${label} ${type}${plural}`;
}
const chipClass = `checksChip font-small ${icon}`;
const grIcon = `gr-icons:${icon}`;
return html`
<div
class="${chipClass}"
role="link"
tabindex="0"
aria-label="${ariaLabel}"
>
<iron-icon icon="${grIcon}"></iron-icon>
<div class="text">${this.text}</div>
<slot></slot>
</div>
`;
}
}
/** What is the maximum number of expanded checks chips? */
const DETAILS_QUOTA = 2;
@customElement('gr-change-summary')
export class GrChangeSummary extends GrLitElement {
@property({type: Object})
changeComments?: ChangeComments;
@property({type: Array})
commentThreads?: CommentThread[];
@property({type: Object})
selfAccount?: AccountInfo;
@property()
runs: CheckRun[] = [];
@property()
showChecksSummary = false;
@property()
someProvidersAreLoading = false;
@property()
errorMessage?: string;
@property()
loginCallback?: () => void;
/**
* How many check chips may still be rendered as a detailed chip. Is reset
* when rendering begins and decreases while chips are rendered. So when
* there are two ERRORs, then those would consume 2 from this quota and then
* there would only be DETAILS_QUOTA - 2 left for the other summary chips.
* Once there are more results than quota left we will stop rendering
* detailed chips and fall back to just icon+number rendering.
*/
private detailsQuota = DETAILS_QUOTA;
/**
* Is reset when rendering begins and contains the check names of runs that
* have a detailed chip. We keep track of this such that we can ensure to not
* show two detailed chips with the same name.
*/
private detailsCheckNames: string[] = [];
constructor() {
super();
this.subscribe('runs', allRunsLatestPatchsetLatestAttempt$);
this.subscribe('showChecksSummary', aPluginHasRegistered$);
this.subscribe('someProvidersAreLoading', someProvidersAreLoadingLatest$);
this.subscribe('errorMessage', errorMessageLatest$);
this.subscribe('loginCallback', loginCallbackLatest$);
}
static get styles() {
return [
sharedStyles,
spinnerStyles,
css`
:host {
display: block;
color: var(--deemphasized-text-color);
max-width: 650px;
margin-bottom: var(--spacing-m);
}
.zeroState {
color: var(--deemphasized-text-color);
}
.loading.zeroState {
margin-right: var(--spacing-m);
}
div.error {
background-color: var(--error-background);
display: flex;
padding: var(--spacing-s);
}
div.error iron-icon {
color: var(--error-foreground);
width: 16px;
height: 16px;
position: relative;
top: 2px;
margin-right: var(--spacing-s);
}
.login gr-button {
margin: -4px var(--spacing-s);
}
td.key {
padding-right: var(--spacing-l);
padding-bottom: var(--spacing-m);
}
td.value {
padding-right: var(--spacing-l);
padding-bottom: var(--spacing-m);
}
iron-icon.launch {
color: var(--gray-foreground);
width: var(--line-height-small);
height: var(--line-height-small);
vertical-align: top;
}
gr-avatar {
height: var(--line-height-small, 16px);
width: var(--line-height-small, 16px);
vertical-align: top;
margin-right: var(--spacing-xs);
}
/* The basics of .loadingSpin are defined in shared styles. */
.loadingSpin {
width: calc(var(--line-height-normal) - 2px);
height: calc(var(--line-height-normal) - 2px);
display: inline-block;
vertical-align: top;
position: relative;
/* Making up for the 2px reduced height above. */
top: 1px;
}
`,
];
}
renderChecksError() {
if (!this.errorMessage) return;
return html`
<div class="error zeroState">
<div class="left">
<iron-icon icon="gr-icons:error"></iron-icon>
</div>
<div class="right">
<div>Error while fetching check results</div>
<div>${this.errorMessage}</div>
</div>
</div>
`;
}
renderChecksLogin() {
if (this.errorMessage || !this.loginCallback) return;
return html`
<div class="login zeroState">
Not logged in
<gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
</div>
`;
}
renderChecksZeroState() {
if (this.errorMessage || this.loginCallback) return;
if (this.runs.some(isRunningOrHasCompleted)) return;
const msg = this.someProvidersAreLoading ? 'Loading results' : 'No results';
return html`<span role="status" class="loading zeroState">${msg}</span>`;
}
renderChecksChipForCategory(category: Category) {
if (this.errorMessage || this.loginCallback) return;
const runs = this.runs.filter(run => {
if (hasResultsOf(run, category)) return true;
return category === Category.SUCCESS && hasCompletedWithoutResults(run);
});
const count = (run: CheckRun) => getResultsOf(run, category);
return this.renderChecksChip(runs, category, count);
}
renderChecksChipForStatus(
status: RunStatus,
filter: (run: CheckRun) => boolean
) {
if (this.errorMessage || this.loginCallback) return;
const runs = this.runs.filter(filter);
return this.renderChecksChip(runs, status, () => []);
}
renderChecksChip(
runs: CheckRun[],
statusOrCategory: RunStatus | Category,
resultFilter: (run: CheckRun) => CheckResult[]
) {
if (runs.length === 0) {
return html``;
}
// If a run has both an error and a warning result, then we only want to
// show a detailed chip with the expanded checkName once. For simplicity
// just stop rendering detailed chips completely as soon as we run into
// this by setting detailsQuota to 0 (after the if-block).
const hasDetailChipAlready = runs.some(run =>
this.detailsCheckNames.includes(run.checkName)
);
const notInfo = statusOrCategory !== Category.INFO;
if (!hasDetailChipAlready && notInfo && runs.length <= this.detailsQuota) {
this.detailsQuota -= runs.length;
return runs.map(run => {
this.detailsCheckNames.push(run.checkName);
const allPrimaryLinks = resultFilter(run)
.map(firstPrimaryLink)
.filter(notUndefined);
const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
const text = `${run.checkName}`;
return html`<gr-checks-chip
.statusOrCategory="${statusOrCategory}"
.text="${text}"
@click="${() => this.onChipClick({checkName: run.checkName})}"
@keydown="${(e: KeyboardEvent) =>
this.onChipKeyDown(e, {checkName: run.checkName})}"
>${links.map(
link => html`
<a
href="${link.url}"
target="_blank"
@click="${this.onLinkClick}"
@keydown="${this.onLinkKeyDown}"
aria-label="Link to check details"
><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
></a>
`
)}
</gr-checks-chip>`;
});
}
this.detailsQuota = 0;
this.detailsCheckNames = [];
const sum = runs.reduce(
(sum, run) => sum + (resultFilter(run).length || 1),
0
);
if (sum === 0) return;
return html`<gr-checks-chip
.statusOrCategory="${statusOrCategory}"
.text="${sum}"
@click="${() => this.onChipClick({statusOrCategory})}"
@keydown="${(e: KeyboardEvent) =>
this.onChipKeyDown(e, {statusOrCategory})}"
></gr-checks-chip>`;
}
private onChipKeyDown(e: KeyboardEvent, state: ChecksTabState) {
if (modifierPressed(e)) return;
// Only react to `return` and `space`.
if (e.keyCode !== 13 && e.keyCode !== 32) return;
e.preventDefault();
e.stopPropagation();
this.onChipClick(state);
}
private onChipClick(state: ChecksTabState) {
fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
checksTab: state,
});
}
private onLinkKeyDown(e: KeyboardEvent) {
// Prevents onConChipKeyDown() from reacting to <a> link keyboard events.
e.stopPropagation();
}
private onLinkClick(e: MouseEvent) {
// Prevents onChipClick() from reacting to <a> link clicks.
e.stopPropagation();
}
render() {
this.detailsQuota = DETAILS_QUOTA;
this.detailsCheckNames = [];
const commentThreads =
this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
[];
const countResolvedComments = commentThreads.filter(isResolved).length;
const unresolvedThreads = commentThreads.filter(isUnresolved);
const countUnresolvedComments = unresolvedThreads.length;
const unresolvedAuthors = this.getAccounts(unresolvedThreads);
const draftCount = this.changeComments?.computeDraftCount() ?? 0;
return html`
<div>
<table>
<tr ?hidden=${!this.showChecksSummary}>
<td class="key">Checks</td>
<td class="value">
${this.renderChecksError()}${this.renderChecksLogin()}
${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
Category.ERROR
)}${this.renderChecksChipForCategory(
Category.WARNING
)}${this.renderChecksChipForCategory(
Category.INFO
)}${this.renderChecksChipForCategory(
Category.SUCCESS
)}${this.renderChecksChipForStatus(RunStatus.RUNNING, isRunning)}
<span
class="loadingSpin"
?hidden="${!this.someProvidersAreLoading}"
></span>
</td>
</tr>
<tr>
<td class="key">Comments</td>
<td class="value">
<span
class="zeroState"
?hidden=${!!countResolvedComments ||
!!draftCount ||
!!countUnresolvedComments}
>
No comments</span
><gr-summary-chip
styleType=${SummaryChipStyles.WARNING}
category=${CommentTabState.DRAFTS}
icon="edit"
?hidden=${!draftCount}
>
${pluralize(draftCount, 'draft')}</gr-summary-chip
><gr-summary-chip
styleType=${SummaryChipStyles.WARNING}
category=${CommentTabState.UNRESOLVED}
?hidden=${!countUnresolvedComments}
>
${unresolvedAuthors.map(
account =>
html`<gr-avatar
.account="${account}"
imageSize="32"
></gr-avatar>`
)}
${countUnresolvedComments} unresolved</gr-summary-chip
><gr-summary-chip
styleType=${SummaryChipStyles.CHECK}
category=${CommentTabState.SHOW_ALL}
icon="markChatRead"
?hidden=${!countResolvedComments}
>${countResolvedComments} resolved</gr-summary-chip
>
</td>
</tr>
<tr hidden>
<td class="key">Findings</td>
<td class="value"></td>
</tr>
</table>
</div>
`;
}
getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
const uniqueAuthors = commentThreads
.map(getFirstComment)
.map(comment => comment?.author ?? this.selfAccount)
.filter(notUndefined)
.filter(account => !!account?.avatars?.[0]?.url)
.filter(uniqueDefinedAvatar);
return uniqueAuthors.slice(0, 3);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-summary': GrChangeSummary;
'gr-checks-chip': GrChecksChip;
'gr-summary-chip': GrSummaryChip;
}
}