blob: 830a35d1a9eb73472d10135b1836aab9824e73c2 [file] [log] [blame]
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-label-info/gr-label-info';
import '../../shared/gr-icon/gr-icon';
import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
import '../gr-trigger-vote/gr-trigger-vote';
import '../gr-change-summary/gr-change-summary';
import '../../shared/gr-limited-text/gr-limited-text';
import '../../shared/gr-vote-chip/gr-vote-chip';
import {LitElement, css, html, TemplateResult, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {ParsedChangeInfo} from '../../../types/types';
import {
AccountInfo,
isDetailedLabelInfo,
isQuickLabelInfo,
LabelNameToInfoMap,
SubmitRequirementResultInfo,
SubmitRequirementStatus,
} from '../../../api/rest-api';
import {
extractAssociatedLabels,
extractLabelsWithCountFrom,
getAllUniqueApprovals,
getRequirements,
getTriggerVotes,
hasApprovedVote,
hasNeutralStatus,
hasRejectedVote,
hasVotes,
iconForRequirement,
orderSubmitRequirements,
} from '../../../utils/label-util';
import {fontStyles} from '../../../styles/gr-font-styles';
import {capitalizeFirstLetter, charsOnly} from '../../../utils/string-util';
import {subscribe} from '../../lit/subscription-controller';
import {CheckRun} from '../../../models/checks/checks-model';
import {getResultsOf, hasResultsOf} from '../../../models/checks/checks-util';
import {Category, RunStatus} from '../../../api/checks';
import {fireShowTab} from '../../../utils/event-util';
import {Tab} from '../../../constants/constants';
import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
import {resolve} from '../../../models/dependency';
import {checksModelToken} from '../../../models/checks/checks-model';
import {map} from 'lit/directives/map.js';
/**
* @attr {Boolean} suppress-title - hide titles, currently for hovercard view
*/
@customElement('gr-submit-requirements')
export class GrSubmitRequirements extends LitElement {
@property({type: Object})
change?: ParsedChangeInfo;
@property({type: Object})
account?: AccountInfo;
@property({type: Boolean})
mutable?: boolean;
@property({type: Boolean, attribute: 'disable-hovercards'})
disableHovercards = false;
@property({type: Boolean, attribute: 'disable-endpoints'})
disableEndpoints = false;
@state()
runs: CheckRun[] = [];
static override get styles() {
return [
fontStyles,
submitRequirementsStyles,
css`
:host([suppress-title]) .metadata-title {
display: none;
}
.metadata-title {
color: var(--deemphasized-text-color);
padding-left: var(--metadata-horizontal-padding);
margin: 0 0 var(--spacing-s);
padding-top: var(--spacing-s);
}
gr-icon {
font-size: var(--line-height-normal, 20px);
}
.requirements,
section.trigger-votes {
margin-left: var(--spacing-l);
}
.trigger-votes {
padding-top: var(--spacing-s);
display: flex;
flex-wrap: wrap;
gap: var(--spacing-s);
/* Setting max-width as defined in Submit Requirements design,
* to wrap overflowed items to next row.
*/
max-width: 390px;
}
gr-limited-text.name {
font-weight: var(--font-weight-bold);
}
table {
border-collapse: collapse;
border-spacing: 0;
}
td {
padding: var(--spacing-s);
white-space: nowrap;
vertical-align: top;
}
.votes {
display: flex;
flex-flow: column;
row-gap: var(--spacing-s);
}
.votes-line {
display: flex;
flex-flow: wrap;
}
gr-vote-chip {
margin-right: var(--spacing-s);
}
gr-checks-chip {
/* .checksChip has top: 2px, this is canceling it */
margin-top: -2px;
}
`,
];
}
private readonly getChecksModel = resolve(this, checksModelToken);
constructor() {
super();
subscribe(
this,
() => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
x => (this.runs = x)
);
}
override render() {
return html`${this.renderSubmitRequirements()}${this.renderTriggerVotes()}`;
}
private renderSubmitRequirements() {
const submit_requirements = orderSubmitRequirements(
getRequirements(this.change)
);
if (submit_requirements.length === 0) return nothing;
return html` <h3
class="metadata-title heading-3"
id="submit-requirements-caption"
>
Submit Requirements
</h3>
<table class="requirements" aria-labelledby="submit-requirements-caption">
<thead hidden>
<tr>
<th>Status</th>
<th>Name</th>
<th>Votes</th>
</tr>
</thead>
<tbody>
${submit_requirements.map((requirement, index) =>
this.renderRequirement(requirement, index)
)}
</tbody>
</table>
${this.disableHovercards
? ''
: submit_requirements.map(
(requirement, index) => html`
<gr-submit-requirement-hovercard
for="requirement-${index}-${charsOnly(requirement.name)}"
.requirement=${requirement}
.change=${this.change}
.account=${this.account}
.mutable=${this.mutable ?? false}
></gr-submit-requirement-hovercard>
`
)}`;
}
private renderRequirement(
requirement: SubmitRequirementResultInfo,
index: number
) {
const row = html`
<td>${this.renderStatus(requirement)}</td>
<td class="name">
<gr-limited-text
class="name"
.text=${requirement.name}
></gr-limited-text>
</td>
<td>
${this.renderEndpoint(requirement, this.renderVoteCell(requirement))}
</td>
</tr>
`;
if (this.disableHovercards) {
// when hovercards are disabled, we don't make line focusable (tabindex)
// since otherwise there is no action associated with the line
return html`<tr>
${row}
</tr>`;
} else {
return html`<tr
id="requirement-${index}-${charsOnly(requirement.name)}"
role="button"
tabindex="0"
>
${row}
</tr>`;
}
}
renderEndpoint(
requirement: SubmitRequirementResultInfo,
slot: TemplateResult
) {
if (this.disableEndpoints)
return html`<div class="votes-cell">${slot}</div>`;
const endpointName = this.computeEndpointName(requirement.name);
return html`<gr-endpoint-decorator class="votes-cell" name=${endpointName}>
<gr-endpoint-param
name="change"
.value=${this.change}
></gr-endpoint-param>
<gr-endpoint-param
name="requirement"
.value=${requirement}
></gr-endpoint-param>
${slot}
</gr-endpoint-decorator>`;
}
private renderStatus(requirement: SubmitRequirementResultInfo) {
const icon = iconForRequirement(requirement);
return html`<gr-icon
class=${icon.icon}
?filled=${icon.filled}
.icon=${icon.icon}
role="img"
aria-label=${requirement.status.toLowerCase()}
></gr-icon>`;
}
renderVoteCell(requirement: SubmitRequirementResultInfo) {
if (requirement.status === SubmitRequirementStatus.ERROR) {
return html`<span class="error">Error</span>`;
}
const requirementLabels = extractAssociatedLabels(requirement);
const allLabels = this.change?.labels ?? {};
const associatedLabels = Object.keys(allLabels).filter(label =>
requirementLabels.includes(label)
);
const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
if (requirementWithoutLabelToVoteOn) {
const status = capitalizeFirstLetter(requirement.status.toLowerCase());
return this.renderChecks(requirement) || html`${status}`;
}
const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
label => !hasVotes(allLabels[label])
);
if (everyAssociatedLabelsIsWithoutVotes) {
return this.renderChecks(requirement) || html`No votes`;
}
const associatedLabelsWithVotes = associatedLabels.filter(label =>
hasVotes(allLabels[label])
);
return html`<div class="votes">
${map(
associatedLabelsWithVotes,
label =>
html`<div class="votes-line">
${this.renderLabelVote(label, allLabels)}
${this.renderVoteCountHelpLabel(requirement, label, allLabels)}
${this.renderOverrideLabels(
requirement,
label,
associatedLabelsWithVotes.length > 1
)}
${this.renderChecks(requirement, label)}
</div>`
)}
</div> `;
}
// Help when submit requirement needs more votes and there is already 1 vote
renderVoteCountHelpLabel(
requirement: SubmitRequirementResultInfo,
label: string,
labels: LabelNameToInfoMap
) {
if (requirement.status !== SubmitRequirementStatus.UNSATISFIED) {
return nothing;
}
const labelInfo = labels[label];
if (!hasApprovedVote(labelInfo) || hasRejectedVote(labelInfo)) {
return nothing;
}
const count = extractLabelsWithCountFrom(
requirement.submittability_expression_result.expression
).find(labelWithCount => labelWithCount.label === label)?.count;
if (!count || count === 1) return nothing;
return html`Requires ${count} votes`;
}
renderLabelVote(label: string, labels: LabelNameToInfoMap) {
const labelInfo = labels[label];
if (isDetailedLabelInfo(labelInfo)) {
const uniqueApprovals = getAllUniqueApprovals(labelInfo).filter(
approval => !hasNeutralStatus(labelInfo, approval)
);
return uniqueApprovals.map(
approvalInfo =>
html`<gr-vote-chip
.vote=${approvalInfo}
.label=${labelInfo}
.more=${(labelInfo.all ?? []).filter(
other => other.value === approvalInfo.value
).length > 1}
></gr-vote-chip>`
);
} else if (isQuickLabelInfo(labelInfo)) {
return [html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`];
} else {
return html``;
}
}
renderOverrideLabels(
requirement: SubmitRequirementResultInfo,
forLabel: string,
showForAllLabel: boolean
) {
if (
!showForAllLabel &&
requirement.status !== SubmitRequirementStatus.OVERRIDDEN
)
return;
const requirementLabels = extractAssociatedLabels(
requirement,
showForAllLabel ? 'all' : 'onlyOverride'
)
.filter(label => label === forLabel)
.filter(label => {
const allLabels = this.change?.labels ?? {};
return allLabels[label] && hasVotes(allLabels[label]);
});
return requirementLabels.map(
label => html`<span class="overrideLabel">${label}</span>`
);
}
renderChecks(requirement: SubmitRequirementResultInfo, labelName?: string) {
const requirementLabels = extractAssociatedLabels(requirement);
const errorRuns = this.runs
.filter(run => hasResultsOf(run, Category.ERROR))
.filter(run => {
if (labelName) {
return labelName === run.labelName;
} else {
return run.labelName && requirementLabels.includes(run.labelName);
}
});
const errorRunsCount = errorRuns.reduce(
(sum, run) => sum + getResultsOf(run, Category.ERROR).length,
0
);
if (errorRunsCount > 0) {
return this.renderChecksCategoryChip(
errorRuns,
errorRunsCount,
Category.ERROR
);
}
const runningRuns = this.runs
.filter(r => r.isLatestAttempt)
.filter(
r => r.status === RunStatus.RUNNING || r.status === RunStatus.SCHEDULED
)
.filter(run => {
if (labelName) {
return labelName === run.labelName;
} else {
return run.labelName && requirementLabels.includes(run.labelName);
}
});
const runningRunsCount = runningRuns.length;
if (runningRunsCount > 0) {
return this.renderChecksCategoryChip(
runningRuns,
runningRunsCount,
RunStatus.RUNNING
);
}
return;
}
renderChecksCategoryChip(
runs: CheckRun[],
runsCount: Number,
category: Category | RunStatus
) {
if (runsCount === 0) return;
const links = [];
if (runs.length === 1 && runs[0].statusLink) {
links.push(runs[0].statusLink);
}
return html`<gr-checks-chip
.text=${`${runsCount}`}
.links=${links}
.statusOrCategory=${category}
@click=${() => {
fireShowTab(this, Tab.CHECKS, false, {
checksTab: {
statusOrCategory: category,
},
});
}}
></gr-checks-chip>`;
}
renderTriggerVotes() {
const labels = this.change?.labels ?? {};
const triggerVotes = getTriggerVotes(this.change).filter(label =>
hasVotes(labels[label])
);
if (!triggerVotes.length) return;
return html`<h3 class="metadata-title heading-3">
${this.computeTriggerVotesTitle()}
</h3>
<section class="trigger-votes">
${triggerVotes.map(
label =>
html`<gr-trigger-vote
.label=${label}
.labelInfo=${labels[label]}
.change=${this.change}
.account=${this.account}
.mutable=${this.mutable ?? false}
.disableHovercards=${this.disableHovercards}
></gr-trigger-vote>`
)}
</section>`;
}
private computeTriggerVotesTitle() {
if (getRequirements(this.change).length === 0) {
// This is special case for old changes without submit requirements.
return 'Label Votes';
} else {
return 'Trigger Votes';
}
}
// not private for tests
computeEndpointName(requirementName: string) {
// remove class name annnotation after ~
const name = requirementName.split('~')[0];
const normalizedName = charsOnly(name).toLowerCase();
return `submit-requirement-${normalizedName}`;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-submit-requirements': GrSubmitRequirements;
}
}