| /** | |
| * @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, | |
| getAllUniqueApprovals, | |
| getRequirements, | |
| getTriggerVotes, | |
| hasNeutralStatus, | |
| 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.renderOverrideLabels( | |
| requirement, | |
| label, | |
| associatedLabelsWithVotes.length > 1 | |
| )} | |
| ${this.renderChecks(requirement, label)} | |
| </div>` | |
| )} | |
| </div> `; | |
| } | |
| 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; | |
| } | |
| } |