blob: 84bdffb836ecac219d0358a8056636e570c646f9 [file] [log] [blame]
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import './gr-checks-chip';
import './gr-summary-chip';
import '../../shared/gr-avatar/gr-avatar-stack';
import '../../shared/gr-icon/gr-icon';
import '../../checks/gr-checks-action';
import {LitElement, css, html, nothing} from 'lit';
import {customElement, state} from 'lit/decorators.js';
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 {fireShowTab} from '../../../utils/event-util';
import {
compareByWorstCategory,
getResultsOf,
hasCompletedWithoutResults,
hasResults,
hasResultsOf,
isRunningOrScheduled,
isRunningScheduledOrCompleted,
} from '../../../models/checks/checks-util';
import {
CommentThread,
getFirstComment,
getMentionedThreads,
hasHumanReply,
isResolved,
isRobotThread,
isUnresolved,
} from '../../../utils/comment-util';
import {pluralize} from '../../../utils/string-util';
import {AccountInfo} from '../../../types/common';
import {isDefined} from '../../../types/types';
import {Tab} 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 {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';
import {SummaryChipStyles} from './gr-summary-chip';
import {when} from 'lit/directives/when.js';
import {KnownExperimentId} from '../../../services/flags/flags';
import {combineLatest} from 'rxjs';
import {userModelToken} from '../../../models/user/user-model';
function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
if (modifierPressed(e)) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
e.stopPropagation();
handler();
}
/** 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()
commentThreads?: CommentThread[];
@state()
mentionCount = 0;
@state()
selfAccount?: AccountInfo;
@state()
runs: CheckRun[] = [];
@state()
showChecksSummary = false;
@state()
someProvidersAreLoading = false;
@state()
errorMessages: ErrorMessages = {};
@state()
loginCallback?: () => void;
@state()
actions: Action[] = [];
@state()
messages: string[] = [];
@state()
draftCount = 0;
private readonly showAllChips = new Map<RunStatus | Category, boolean>();
private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly getUserModel = resolve(this, userModelToken);
private readonly getChecksModel = resolve(this, checksModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly reporting = getAppContext().reportingService;
private readonly flagsService = getAppContext().flagsService;
constructor() {
super();
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().draftsCount$,
x => (this.draftCount = x)
);
subscribe(
this,
() => this.getCommentsModel().threads$,
x => (this.commentThreads = x)
);
subscribe(
this,
() => this.getUserModel().account$,
x => (this.selfAccount = x)
);
if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
subscribe(
this,
() =>
combineLatest([
this.getUserModel().account$,
this.getCommentsModel().threads$,
]),
([selfAccount, threads]) => {
if (!selfAccount || !selfAccount.email) return;
const unresolvedThreadsMentioningSelf = getMentionedThreads(
threads,
selfAccount
).filter(isUnresolved);
this.mentionCount = unresolvedThreadsMentioningSelf.length;
}
);
}
}
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.info,
div.error,
.login {
display: flex;
color: var(--primary-text-color);
padding: 0 var(--spacing-s);
margin: var(--spacing-xs) 0;
width: 490px;
}
div.info {
background-color: var(--info-background);
}
div.error {
background-color: var(--error-background);
}
div.info gr-icon,
div.error gr-icon {
font-size: 16px;
position: relative;
top: 4px;
margin-right: var(--spacing-s);
}
div.info gr-icon {
color: var(--info-foreground);
}
div.error gr-icon {
color: var(--error-foreground);
}
div.info .right,
div.error .right {
overflow: hidden;
}
div.info .right .message,
div.error .right .message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login {
justify-content: space-between;
background: var(--info-background);
}
.login gr-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));
}
gr-avatar-stack {
--avatar-size: var(--line-height-small, 16px);
--stack-border-color: var(--warning-background);
}
.unresolvedIcon {
font-size: var(--line-height-small);
color: var(--warning-foreground);
}
/* 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 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}
>
<gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
<span id="moreMessage">More</span>
</gr-dropdown>
`;
}
private renderSummaryMessage() {
return this.messages.map(
m => html`
<div class="info">
<div class="left">
<gr-icon icon="info" filled></gr-icon>
</div>
<div class="right">
<div class="message" title=${m}>${m}</div>
</div>
</div>
`
);
}
renderErrorMessages() {
return Object.entries(this.errorMessages).map(
([plugin, message]) =>
html`
<div class="error zeroState">
<div class="left">
<gr-icon icon="error" filled></gr-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">
<gr-icon icon="info"></gr-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),
});
fireShowTab(this, Tab.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);
return html`
<div>
<table>
<tr>
<td class="key">Comments</td>
<td class="value">
${this.renderZeroState(
countResolvedComments,
countUnresolvedComments
)}
${this.renderDraftChip()} ${this.renderMentionChip()}
${this.renderUnresolvedCommentsChip(
countUnresolvedComments,
unresolvedAuthors
)}
${this.renderResolvedCommentsChip(countResolvedComments)}
</td>
</tr>
${this.renderChecksSummary()}
</table>
</div>
`;
}
private renderZeroState(
countResolvedComments: number,
countUnresolvedComments: number
) {
if (
!!countResolvedComments ||
!!this.draftCount ||
!!countUnresolvedComments
)
return nothing;
return html`<span class="zeroState"> No comments</span>`;
}
private renderMentionChip() {
if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
return nothing;
if (!this.mentionCount) return nothing;
return html` <gr-summary-chip
class="mentionSummary"
styleType=${SummaryChipStyles.WARNING}
category=${CommentTabState.MENTIONS}
icon="alternate_email"
>
${pluralize(this.mentionCount, 'mention')}</gr-summary-chip
>`;
}
private renderDraftChip() {
if (!this.draftCount) return nothing;
return html` <gr-summary-chip
styleType=${SummaryChipStyles.INFO}
category=${CommentTabState.DRAFTS}
icon="rate_review"
iconFilled
>
${pluralize(this.draftCount, 'draft')}</gr-summary-chip
>`;
}
private renderUnresolvedCommentsChip(
countUnresolvedComments: number,
unresolvedAuthors: AccountInfo[]
) {
if (!countUnresolvedComments) return nothing;
return html` <gr-summary-chip
styleType=${SummaryChipStyles.WARNING}
category=${CommentTabState.UNRESOLVED}
?hidden=${!countUnresolvedComments}
>
<gr-avatar-stack .accounts=${unresolvedAuthors} imageSize="32">
<gr-icon
slot="fallback"
icon="chat_bubble"
filled
class="unresolvedIcon"
>
</gr-icon>
</gr-avatar-stack>
${countUnresolvedComments} unresolved</gr-summary-chip
>`;
}
private renderResolvedCommentsChip(countResolvedComments: number) {
if (!countResolvedComments) return nothing;
return html` <gr-summary-chip
styleType=${SummaryChipStyles.CHECK}
category=${CommentTabState.SHOW_ALL}
icon="mark_chat_read"
>${countResolvedComments} resolved</gr-summary-chip
>`;
}
private renderChecksSummary() {
const hasNonRunningChip = this.runs.some(
run => hasCompletedWithoutResults(run) || hasResults(run)
);
const hasRunningChip = this.runs.some(isRunningOrScheduled);
if (!this.showChecksSummary) return nothing;
return html` <tr>
<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()}
${when(
this.someProvidersAreLoading,
() => html`<span class="loadingSpin"></span>`
)}
${this.renderErrorMessages()} ${this.renderChecksLogin()}
${this.renderSummaryMessage()} ${this.renderActions()}
</div>
</td>
</tr>`;
}
getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
return commentThreads
.map(getFirstComment)
.map(comment => comment?.author ?? this.selfAccount)
.filter(isDefined);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-summary': GrChangeSummary;
}
}