blob: 6e60a2217517db9ba4f448e95c65dbec55489e3a [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/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} from 'lit';
import {customElement, property, state} from 'lit/decorators';
import {ParsedChangeInfo} from '../../../types/types';
import {
AccountInfo,
isDetailedLabelInfo,
isQuickLabelInfo,
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 {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} from '../../../api/checks';
import {fireShowPrimaryTab} from '../../../utils/event-util';
import {PrimaryTab} from '../../../constants/constants';
import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
import {resolve} from '../../../models/dependency';
import {checksModelToken} from '../../../models/checks/checks-model';
/**
* @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);
}
iron-icon {
width: var(--line-height-normal, 20px);
height: var(--line-height-normal, 20px);
vertical-align: top;
}
.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;
}
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);
override connectedCallback(): void {
super.connectedCallback();
subscribe(
this,
this.getChecksModel().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, 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>
`
)}
${this.renderTriggerVotes()}`;
}
renderRequirement(requirement: SubmitRequirementResultInfo, index: number) {
const row = html`
<td>${this.renderStatus(requirement.status)}</td>
<td class="name">
<gr-limited-text
class="name"
limit="25"
.text=${requirement.name}
></gr-limited-text>
</td>
<td>
${this.renderEndpoint(
requirement,
html`${this.renderVotesAndChecksChips(requirement)}
${this.renderOverrideLabels(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.calculateEndpointName(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>`;
}
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>`;
}
renderVotesAndChecksChips(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 everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
label => !hasVotes(allLabels[label])
);
const checksChips = this.renderChecks(requirement);
const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
if (requirementWithoutLabelToVoteOn) {
const status = capitalizeFirstLetter(requirement.status.toLowerCase());
return checksChips || html`${status}`;
}
if (everyAssociatedLabelsIsWithoutVotes) {
return checksChips || html`No votes`;
}
return html`${associatedLabels.map(label =>
this.renderLabelVote(label, allLabels)
)}
${checksChips}`;
}
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;
const links = [];
if (requirementRuns.length === 1 && requirementRuns[0].statusLink) {
links.push(requirementRuns[0].statusLink);
}
return html`<gr-checks-chip
.text=${`${runsCount}`}
.links=${links}
.statusOrCategory=${Category.ERROR}
@click=${() => {
fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
checksTab: {
statusOrCategory: Category.ERROR,
},
});
}}
></gr-checks-chip>`;
}
renderOverrideLabels(requirement: SubmitRequirementResultInfo) {
if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
const requirementLabels = extractAssociatedLabels(
requirement,
'onlyOverride'
).filter(label => {
const allLabels = this.change?.labels ?? {};
return allLabels[label] && hasVotes(allLabels[label]);
});
return requirementLabels.map(
label => html`<span class="overrideLabel">${label}</span>`
);
}
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}
.disableHovercards=${this.disableHovercards}
></gr-trigger-vote>`
)}
</section>`;
}
// not private for tests
calculateEndpointName(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;
}
}