blob: 986db6512ff6bc94cfc355bdb3920be25a06e125 [file] [log] [blame]
/**
* @license
* Copyright (C) 2021 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-label-info/gr-label-info';
import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
import {LitElement, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {ParsedChangeInfo} from '../../../types/types';
import {
AccountInfo,
isDetailedLabelInfo,
isQuickLabelInfo,
LabelInfo,
LabelNameToInfoMap,
SubmitRequirementResultInfo,
SubmitRequirementStatus,
} from '../../../api/rest-api';
import {
extractAssociatedLabels,
getAllUniqueApprovals,
getRequirements,
getTriggerVotes,
hasNeutralStatus,
hasVotes,
iconForStatus,
orderSubmitRequirements,
} from '../../../utils/label-util';
import {fontStyles} from '../../../styles/gr-font-styles';
import {charsOnly, pluralize} from '../../../utils/string-util';
import {subscribe} from '../../lit/subscription-controller';
import {
allRunsLatestPatchsetLatestAttempt$,
CheckRun,
} from '../../../services/checks/checks-model';
import {getResultsOf, hasResultsOf} from '../../../services/checks/checks-util';
import {Category} from '../../../api/checks';
import '../../shared/gr-vote-chip/gr-vote-chip';
/**
* @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;
@state()
runs: CheckRun[] = [];
static override get styles() {
return [
fontStyles,
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);
border-top: 1px solid var(--border-color);
padding-top: var(--spacing-s);
}
iron-icon {
width: var(--line-height-normal, 20px);
height: var(--line-height-normal, 20px);
}
iron-icon.check,
iron-icon.overridden {
color: var(--success-foreground);
}
iron-icon.close {
color: var(--error-foreground);
}
.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;
}
.votes-cell {
display: flex;
}
.check-error {
margin-right: var(--spacing-l);
}
.check-error iron-icon {
color: var(--error-foreground);
vertical-align: top;
}
gr-vote-chip {
margin-right: var(--spacing-s);
}
`,
];
}
constructor() {
super();
subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
}
override render() {
const submit_requirements = orderSubmitRequirements(
getRequirements(this.change)
);
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 =>
this.renderRequirement(requirement)
)}
</tbody>
</table>
${submit_requirements.map(
requirement => html`
<gr-submit-requirement-hovercard
for="requirement-${charsOnly(requirement.name)}"
.requirement="${requirement}"
.change="${this.change}"
.account="${this.account}"
.mutable="${this.mutable ?? false}"
></gr-submit-requirement-hovercard>
`
)}
${this.renderTriggerVotes()}`;
}
renderRequirement(requirement: SubmitRequirementResultInfo) {
return html`
<tr id="requirement-${charsOnly(requirement.name)}">
<td>${this.renderStatus(requirement.status)}</td>
<td class="name">
<gr-limited-text
class="name"
limit="25"
.text="${requirement.name}"
></gr-limited-text>
</td>
<td>
<gr-endpoint-decorator
class="votes-cell"
name="${`submit-requirement-${charsOnly(
requirement.name
).toLowerCase()}`}"
>
<gr-endpoint-param
name="change"
.value=${this.change}
></gr-endpoint-param>
<gr-endpoint-param
name="requirement"
.value=${requirement}
></gr-endpoint-param>
${this.renderVotes(requirement)}${this.renderChecks(requirement)}
</gr-endpoint-decorator>
</td>
</tr>
`;
}
renderStatus(status: SubmitRequirementStatus) {
const icon = iconForStatus(status);
return html`<iron-icon
class="${icon}"
icon="gr-icons:${icon}"
role="img"
aria-label="${status.toLowerCase()}"
></iron-icon>`;
}
renderVotes(requirement: SubmitRequirementResultInfo) {
const requirementLabels = extractAssociatedLabels(requirement);
const allLabels = this.change?.labels ?? {};
const associatedLabels = Object.keys(allLabels).filter(label =>
requirementLabels.includes(label)
);
const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
label => !hasVotes(allLabels[label])
);
if (everyAssociatedLabelsIsWithoutVotes) return html`No votes`;
return associatedLabels.map(label =>
this.renderLabelVote(label, allLabels)
);
}
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``;
}
}
renderChecks(requirement: SubmitRequirementResultInfo) {
const requirementLabels = extractAssociatedLabels(requirement);
const requirementRuns = this.runs
.filter(run => hasResultsOf(run, Category.ERROR))
.filter(
run => run.labelName && requirementLabels.includes(run.labelName)
);
const runsCount = requirementRuns.reduce(
(sum, run) => sum + getResultsOf(run, Category.ERROR).length,
0
);
if (runsCount > 0) {
return html`<span class="check-error"
><iron-icon icon="gr-icons:error"></iron-icon>${pluralize(
runsCount,
'error'
)}</span
>`;
}
return;
}
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">Trigger Votes</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}"
></gr-trigger-vote>`
)}
</section>`;
}
}
@customElement('gr-trigger-vote')
export class GrTriggerVote extends LitElement {
@property()
label?: string;
@property({type: Object})
labelInfo?: LabelInfo;
@property({type: Object})
change?: ParsedChangeInfo;
@property({type: Object})
account?: AccountInfo;
@property({type: Boolean})
mutable?: boolean;
static override get styles() {
return css`
:host {
display: block;
}
.container {
box-sizing: border-box;
border: 1px solid var(--border-color);
border-radius: calc(var(--border-radius) + 2px);
background-color: var(--background-color-primary);
display: flex;
padding: 0;
padding-left: var(--spacing-s);
padding-right: var(--spacing-xxs);
align-items: center;
}
.label {
padding-right: var(--spacing-s);
font-weight: var(--font-weight-bold);
}
gr-vote-chip {
--gr-vote-chip-width: 14px;
--gr-vote-chip-height: 14px;
margin-right: 0px;
margin-left: var(--spacing-xs);
}
gr-vote-chip:first-of-type {
margin-left: 0px;
}
`;
}
override render() {
if (!this.labelInfo) return;
return html`
<div class="container">
<gr-trigger-vote-hovercard .labelName=${this.label}>
<gr-label-info
slot="label-info"
.change=${this.change}
.account=${this.account}
.mutable=${this.mutable}
.label=${this.label}
.labelInfo=${this.labelInfo}
.showAllReviewers=${false}
></gr-label-info>
</gr-trigger-vote-hovercard>
<span class="label">${this.label}</span>
${this.renderVotes()}
</div>
`;
}
private renderVotes() {
const {labelInfo} = this;
if (!labelInfo) return;
if (isDetailedLabelInfo(labelInfo)) {
const approvals = getAllUniqueApprovals(labelInfo).filter(
approval => !hasNeutralStatus(labelInfo, approval)
);
return approvals.map(
approvalInfo => html`<gr-vote-chip
.vote="${approvalInfo}"
.label="${labelInfo}"
></gr-vote-chip>`
);
} else if (isQuickLabelInfo(labelInfo)) {
return [html`<gr-vote-chip .label="${this.labelInfo}"></gr-vote-chip>`];
} else {
return html``;
}
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-submit-requirements': GrSubmitRequirements;
'gr-trigger-vote': GrTriggerVote;
}
}