blob: f5893ac88d28f107bed0e79bf9573d982721d23d [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 {LitElement, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {subscribe} from '../../lit/subscription-controller';
import {sharedStyles} from '../../../styles/shared-styles';
import {getAppContext} from '../../../services/app-context';
import {
CheckResult,
CheckRun,
ErrorMessages,
} from '../../../models/checks/checks-model';
import {Action, Category, RunStatus} from '../../../api/checks';
import {fireShowPrimaryTab} from '../../../utils/event-util';
import '../../shared/gr-avatar/gr-avatar';
import '../../checks/gr-checks-action';
import {
compareByWorstCategory,
getResultsOf,
hasCompletedWithoutResults,
hasResults,
hasResultsOf,
iconFor,
isRunningOrScheduled,
isRunningScheduledOrCompleted,
isStatus,
labelFor,
} from '../../../models/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';
import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
import {fontStyles} from '../../../styles/gr-font-styles';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {resolve} from '../../../models/dependency';
import {checksModelToken} from '../../../models/checks/checks-model';
import {changeModelToken} from '../../../models/change/change-model';
import {Interaction} from '../../../constants/reporting';
import {roleDetails} from '../../../utils/change-util';
export enum SummaryChipStyles {
INFO = 'info',
WARNING = 'warning',
CHECK = 'check',
UNDEFINED = '',
}
function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
if (modifierPressed(e)) return;
// Only react to `return` and `space`.
if (e.keyCode !== 13 && e.keyCode !== 32) return;
e.preventDefault();
e.stopPropagation();
handler();
}
@customElement('gr-summary-chip')
export class GrSummaryChip extends LitElement {
@property()
icon = '';
@property()
styleType = SummaryChipStyles.UNDEFINED;
@property()
category?: CommentTabState;
private readonly reporting = getAppContext().reportingService;
static override get styles() {
return [
sharedStyles,
fontStyles,
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;
/* centered position of 20px chips in 24px line-height inline flow */
vertical-align: top;
position: relative;
top: 2px;
}
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);
}
`,
];
}
override 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 LitElement {
@property()
statusOrCategory?: Category | RunStatus;
@property()
text = '';
@property()
links: string[] = [];
private readonly reporting = getAppContext().reportingService;
static override get styles() {
return [
fontStyles,
sharedStyles,
css`
:host {
display: inline-block;
position: relative;
white-space: nowrap;
}
.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;
/* centered position of 20px chips in 24px line-height inline flow */
vertical-align: top;
position: relative;
top: 2px;
}
.checksChip.hoverFullLength {
position: absolute;
z-index: 1;
display: none;
}
.checksChip.hoverFullLength .text {
max-width: 500px;
}
:host(:hover) .checksChip.hoverFullLength {
display: inline-block;
}
.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 a iron-icon.launch {
color: var(--link-color);
}
.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.scheduled {
border-color: var(--gray-foreground);
background: var(--gray-background);
}
.checksChip.timelapse:hover,
.checksChip.scheduled:hover {
background: var(--gray-background-hover);
box-shadow: var(--elevation-level-1);
}
.checksChip.timelapse:focus-within,
.checksChip.scheduled:focus-within {
background: var(--gray-background-focus);
}
.checksChip.timelapse iron-icon,
.checksChip.scheduled iron-icon {
color: var(--gray-foreground);
}
`,
];
}
override 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 chipClassFullLength = `${chipClass} hoverFullLength`;
const grIcon = `gr-icons:${icon}`;
// 15 is roughly the number of chars for the chip exceeding its 120px width.
return html`
${this.text.length > 15
? html` ${this.renderChip(chipClassFullLength, ariaLabel, grIcon)}`
: ''}
${this.renderChip(chipClass, ariaLabel, grIcon)}
`;
}
private renderChip(clazz: string, ariaLabel: string, icon: string) {
return html`
<div class=${clazz} role="link" tabindex="0" aria-label=${ariaLabel}>
<iron-icon icon=${icon}></iron-icon>
${this.renderLinks()}
<div class="text">${this.text}</div>
</div>
`;
}
private renderLinks() {
return this.links.map(
link => html`
<a
href=${link}
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>
`
);
}
private onLinkKeyDown(e: KeyboardEvent) {
// Prevents onChipKeyDown() from reacting to <a> link keyboard events.
e.stopPropagation();
}
private onLinkClick(e: MouseEvent) {
// Prevents onChipClick() from reacting to <a> link clicks.
e.stopPropagation();
this.reporting.reportInteraction(Interaction.CHECKS_CHIP_LINK_CLICKED, {
text: this.text,
status: this.statusOrCategory,
});
}
}
/** What is the maximum number of detailed checks chips? */
const DETAILS_QUOTA: Map<RunStatus | Category, number> = new Map();
DETAILS_QUOTA.set(Category.ERROR, 7);
DETAILS_QUOTA.set(Category.WARNING, 2);
DETAILS_QUOTA.set(RunStatus.RUNNING, 2);
@customElement('gr-change-summary')
export class GrChangeSummary extends LitElement {
@state()
changeComments?: ChangeComments;
@state()
commentThreads?: CommentThread[];
@state()
selfAccount?: AccountInfo;
@state()
runs: CheckRun[] = [];
@state()
showChecksSummary = false;
@state()
someProvidersAreLoading = false;
@state()
errorMessages: ErrorMessages = {};
@state()
loginCallback?: () => void;
@state()
actions: Action[] = [];
@state()
messages: string[] = [];
private readonly showAllChips = new Map<RunStatus | Category, boolean>();
private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly userModel = getAppContext().userModel;
private readonly getChecksModel = resolve(this, checksModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly reporting = getAppContext().reportingService;
override connectedCallback() {
super.connectedCallback();
subscribe(
this,
this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
x => (this.runs = x)
);
subscribe(
this,
this.getChecksModel().aPluginHasRegistered$,
x => (this.showChecksSummary = x)
);
subscribe(
this,
this.getChecksModel().someProvidersAreLoadingFirstTime$,
x => (this.someProvidersAreLoading = x)
);
subscribe(
this,
this.getChecksModel().errorMessagesLatest$,
x => (this.errorMessages = x)
);
subscribe(
this,
this.getChecksModel().loginCallbackLatest$,
x => (this.loginCallback = x)
);
subscribe(
this,
this.getChecksModel().topLevelActionsLatest$,
x => (this.actions = x)
);
subscribe(
this,
this.getChecksModel().topLevelMessagesLatest$,
x => (this.messages = x)
);
subscribe(
this,
this.getCommentsModel().changeComments$,
x => (this.changeComments = x)
);
subscribe(
this,
this.getCommentsModel().threads$,
x => (this.commentThreads = x)
);
subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
}
static override get styles() {
return [
sharedStyles,
spinnerStyles,
css`
:host {
display: block;
color: var(--deemphasized-text-color);
max-width: 625px;
margin-bottom: var(--spacing-m);
}
.zeroState {
color: var(--deemphasized-text-color);
}
.loading.zeroState {
margin-right: var(--spacing-m);
}
div.error,
.login {
display: flex;
color: var(--primary-text-color);
padding: 0 var(--spacing-s);
margin: var(--spacing-xs) 0;
width: 490px;
}
div.error {
background-color: var(--error-background);
}
div.error iron-icon {
color: var(--error-foreground);
width: 16px;
height: 16px;
position: relative;
top: 4px;
margin-right: var(--spacing-s);
}
div.error .right {
overflow: hidden;
}
div.error .right .message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login {
justify-content: space-between;
background: var(--info-background);
}
.login iron-icon {
color: var(--info-foreground);
}
.login gr-button {
margin: -4px var(--spacing-s);
}
td.key {
padding-right: var(--spacing-l);
padding-bottom: var(--spacing-s);
line-height: calc(var(--line-height-normal) + var(--spacing-s));
}
td.value {
padding-right: var(--spacing-l);
padding-bottom: var(--spacing-s);
line-height: calc(var(--line-height-normal) + var(--spacing-s));
}
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;
}
.actions {
margin-left: calc(0px - var(--spacing-m));
line-height: var(--line-height-normal);
}
.actions gr-checks-action,
.actions gr-dropdown {
vertical-align: top;
--gr-button-padding: 0 var(--spacing-m);
}
.actions #moreMessage {
display: none;
}
.summaryMessage {
line-height: var(--line-height-normal);
color: var(--primary-text-color);
}
`,
];
}
private renderSummaryMessage() {
return this.messages.map(m => html`<div class="summaryMessage">${m}</div>`);
}
private renderActions() {
const actions = this.actions ?? [];
const summaryActions = actions.filter(a => a.summary).slice(0, 2);
if (summaryActions.length === 0) return;
const topActions = summaryActions.slice(0, 2);
const overflowActions = summaryActions.slice(2).map(action => {
return {...action, id: action.name};
});
const disabledActionIds = overflowActions
.filter(action => action.disabled)
.map(action => action.id);
return html`
<div class="actions">
${topActions.map(this.renderAction)}
${this.renderOverflow(overflowActions, disabledActionIds)}
</div>
`;
}
private renderAction(action?: Action) {
if (!action) return;
return html`<gr-checks-action
context="summary"
.action=${action}
></gr-checks-action>`;
}
private handleAction(e: CustomEvent<Action>) {
this.getChecksModel().triggerAction(
e.detail,
undefined,
'summary-dropdown'
);
}
private renderOverflow(items: DropdownLink[], disabledIds: string[] = []) {
if (items.length === 0) return;
return html`
<gr-dropdown
id="moreActions"
link=""
vertical-offset="32"
horizontal-align="right"
@tap-item=${this.handleAction}
.items=${items}
.disabledIds=${disabledIds}
>
<iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
</iron-icon>
<span id="moreMessage">More</span>
</gr-dropdown>
`;
}
renderErrorMessages() {
return Object.entries(this.errorMessages).map(
([plugin, message]) =>
html`
<div class="error zeroState">
<div class="left">
<iron-icon icon="gr-icons:error"></iron-icon>
</div>
<div class="right">
<div class="message" title=${message}>
Error while fetching results for ${plugin}: ${message}
</div>
</div>
</div>
`
);
}
renderChecksLogin() {
if (!this.loginCallback) return;
return html`
<div class="login">
<div class="left">
<iron-icon
class="info-outline"
icon="gr-icons:info-outline"
></iron-icon>
Not logged in
</div>
<div class="right">
<gr-button @click=${this.loginCallback} link>Sign in</gr-button>
</div>
</div>
`;
}
renderChecksZeroState() {
if (Object.keys(this.errorMessages).length > 0) return;
if (this.loginCallback) return;
if (this.runs.some(isRunningScheduledOrCompleted)) return;
const msg = this.someProvidersAreLoading ? 'Loading results' : 'No results';
return html`<span role="status" class="loading zeroState">${msg}</span>`;
}
renderChecksChipForCategory(category: Category) {
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);
if (category === Category.SUCCESS || category === Category.INFO) {
return this.renderChecksChipsCollapsed(runs, category, count);
}
return this.renderChecksChipsExpanded(runs, category);
}
renderChecksChipRunning() {
const runs = this.runs
.filter(isRunningOrScheduled)
.sort(compareByWorstCategory);
return this.renderChecksChipsExpanded(runs, RunStatus.RUNNING);
}
renderChecksChipsExpanded(
runs: CheckRun[],
statusOrCategory: RunStatus | Category
) {
if (runs.length === 0) return;
const showAll = this.showAllChips.get(statusOrCategory) ?? false;
let count = showAll ? 999 : DETAILS_QUOTA.get(statusOrCategory) ?? 2;
if (count === runs.length - 1) count = runs.length;
const more = runs.length - count;
return html`${runs
.slice(0, count)
.map(run =>
this.renderChecksChipDetailed(run, statusOrCategory)
)}${this.renderChecksChipPlusMore(statusOrCategory, more)}`;
}
private renderChecksChipsCollapsed(
runs: CheckRun[],
statusOrCategory: RunStatus | Category,
resultFilter: (run: CheckRun) => CheckResult[]
) {
const count = runs.reduce(
(sum, run) => sum + (resultFilter(run).length || 1),
0
);
if (count === 0) return;
const handler = () => this.onChipClick({statusOrCategory});
return html`<gr-checks-chip
.statusOrCategory=${statusOrCategory}
.text=${`${count}`}
@click=${handler}
@keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
></gr-checks-chip>`;
}
private renderChecksChipPlusMore(
statusOrCategory: RunStatus | Category,
count: number
) {
if (count <= 0) return;
if (this.showAllChips.get(statusOrCategory) === true) return;
const handler = () => {
this.showAllChips.set(statusOrCategory, true);
this.requestUpdate();
};
return html`<gr-checks-chip
.statusOrCategory=${statusOrCategory}
.text="+ ${count} more"
@click=${handler}
@keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
></gr-checks-chip>`;
}
private renderChecksChipDetailed(
run: CheckRun,
statusOrCategory: RunStatus | Category
) {
const links = [];
if (run.statusLink) links.push(run.statusLink);
const text = `${run.checkName}`;
const tabState: ChecksTabState = {
checkName: run.checkName,
statusOrCategory,
};
// Scheduled runs are rendered in the RUNNING section, but the icon of the
// chip must be the one for SCHEDULED.
if (
statusOrCategory === RunStatus.RUNNING &&
run.status === RunStatus.SCHEDULED
) {
statusOrCategory = RunStatus.SCHEDULED;
}
const handler = () => this.onChipClick(tabState);
return html`<gr-checks-chip
.statusOrCategory=${statusOrCategory}
.text=${text}
.links=${links}
@click=${handler}
@keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
></gr-checks-chip>`;
}
private onChipClick(state: ChecksTabState) {
this.reporting.reportInteraction(Interaction.CHECKS_CHIP_CLICKED, {
statusOrCategory: state.statusOrCategory,
checkName: state.checkName,
...roleDetails(this.getChangeModel().getChange(), this.selfAccount),
});
fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
checksTab: state,
});
}
override render() {
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;
const hasNonRunningChip = this.runs.some(
run => hasCompletedWithoutResults(run) || hasResults(run)
);
const hasRunningChip = this.runs.some(isRunningOrScheduled);
return html`
<div>
<table>
<tr ?hidden=${!this.showChecksSummary}>
<td class="key">Checks</td>
<td class="value">
<div class="checksSummary">
${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
Category.ERROR
)}${this.renderChecksChipForCategory(
Category.WARNING
)}${this.renderChecksChipForCategory(
Category.INFO
)}${this.renderChecksChipForCategory(
Category.SUCCESS
)}${hasNonRunningChip && hasRunningChip
? html`<br />`
: ''}${this.renderChecksChipRunning()}
<span
class="loadingSpin"
?hidden=${!this.someProvidersAreLoading}
></span>
${this.renderErrorMessages()} ${this.renderChecksLogin()}
${this.renderSummaryMessage()} ${this.renderActions()}
</div>
</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;
}
}