| /** |
| * @license |
| * Copyright 2021 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../../shared/gr-button/gr-button'; |
| import '../../shared/gr-icon/gr-icon'; |
| import '../../shared/gr-label-info/gr-label-info'; |
| import {customElement, property} from 'lit/decorators.js'; |
| import { |
| AccountInfo, |
| ChangeStatus, |
| SubmitRequirementExpressionInfo, |
| SubmitRequirementResultInfo, |
| SubmitRequirementStatus, |
| } from '../../../api/rest-api'; |
| import { |
| canReviewerVote, |
| extractAssociatedLabels, |
| getApprovalInfo, |
| hasVotes, |
| iconForRequirement, |
| } from '../../../utils/label-util'; |
| import {ParsedChangeInfo} from '../../../types/types'; |
| import {css, html, LitElement} from 'lit'; |
| import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin'; |
| import {fontStyles} from '../../../styles/gr-font-styles'; |
| import {DraftsAction} from '../../../constants/constants'; |
| import {ReviewInput} from '../../../types/common'; |
| import {getAppContext} from '../../../services/app-context'; |
| import {assertIsDefined} from '../../../utils/common-util'; |
| import {fireReload} from '../../../utils/event-util'; |
| import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles'; |
| import { |
| atomizeExpression, |
| SubmitRequirementExpressionAtomStatus, |
| } from '../../../utils/submit-requirement-util'; |
| |
| // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error. |
| const base = HovercardMixin(LitElement); |
| |
| @customElement('gr-submit-requirement-hovercard') |
| export class GrSubmitRequirementHovercard extends base { |
| @property({type: Object}) |
| requirement?: SubmitRequirementResultInfo; |
| |
| @property({type: Object}) |
| change?: ParsedChangeInfo; |
| |
| @property({type: Object}) |
| account?: AccountInfo; |
| |
| @property({type: Boolean}) |
| mutable = false; |
| |
| @property({type: Boolean}) |
| expanded = false; |
| |
| private readonly restApiService = getAppContext().restApiService; |
| |
| static override get styles() { |
| return [ |
| fontStyles, |
| submitRequirementsStyles, |
| base.styles || [], |
| css` |
| #container { |
| min-width: 356px; |
| max-width: 356px; |
| padding: var(--spacing-xl) 0 var(--spacing-m) 0; |
| } |
| section.label { |
| display: table-row; |
| } |
| .label-title { |
| min-width: 10em; |
| padding-top: var(--spacing-s); |
| } |
| .label-value { |
| padding-top: var(--spacing-s); |
| } |
| .label-title, |
| .label-value { |
| display: table-cell; |
| vertical-align: top; |
| } |
| .row { |
| display: flex; |
| } |
| .title { |
| color: var(--deemphasized-text-color); |
| margin-right: var(--spacing-m); |
| } |
| div.section { |
| margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl); |
| display: flex; |
| align-items: center; |
| } |
| div.sectionIcon { |
| flex: 0 0 30px; |
| } |
| div.sectionIcon gr-icon { |
| position: relative; |
| } |
| .section.condition > .sectionContent { |
| background-color: var(--gray-background); |
| padding: var(--spacing-m); |
| flex-grow: 1; |
| } |
| .button ~ .condition { |
| margin-top: var(--spacing-m); |
| } |
| .expression { |
| color: var(--gray-foreground); |
| } |
| .expression .failing.atom { |
| border-bottom: 2px solid var(--error-foreground); |
| } |
| .expression .passing.atom { |
| border-bottom: 2px solid var(--success-foreground); |
| } |
| .button gr-icon { |
| color: inherit; |
| } |
| div.button { |
| border-top: 1px solid var(--border-color); |
| margin-top: var(--spacing-m); |
| padding: var(--spacing-m) var(--spacing-xl) 0; |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| if (!this.requirement) return; |
| return html` <div id="container" role="tooltip" tabindex="-1"> |
| <div class="section"> |
| <div class="sectionIcon">${this.renderStatus(this.requirement)}</div> |
| <div class="sectionContent"> |
| <h3 class="name heading-3"> |
| <span>${this.requirement.name}</span> |
| </h3> |
| </div> |
| </div> |
| <div class="section"> |
| <div class="sectionIcon"> |
| <gr-icon class="small" icon="info"></gr-icon> |
| </div> |
| <div class="sectionContent"> |
| <div class="row"> |
| <div class="title">Status</div> |
| <div>${this.requirement.status}</div> |
| </div> |
| </div> |
| </div> |
| ${this.renderLabelSection()}${this.renderDescription()} |
| ${this.renderShowHideConditionButton()}${this.renderConditionSection()} |
| ${this.renderVotingButtons()} |
| </div>`; |
| } |
| |
| private renderStatus(requirement: SubmitRequirementResultInfo) { |
| const icon = iconForRequirement(requirement); |
| return html`<gr-icon |
| class=${icon.icon} |
| icon=${icon.icon} |
| ?filled=${icon.filled} |
| role="img" |
| aria-label=${requirement.status.toLowerCase()} |
| ></gr-icon>`; |
| } |
| |
| private renderDescription() { |
| let description = this.requirement?.description; |
| if (this.requirement?.status === SubmitRequirementStatus.ERROR) { |
| const submitRecord = this.change?.submit_records?.filter( |
| record => record.rule_name === this.requirement?.name |
| ); |
| if (submitRecord?.length === 1 && submitRecord[0].error_message) { |
| description = submitRecord[0].error_message; |
| } |
| } |
| if (!description) return; |
| return html`<div class="section description"> |
| <div class="sectionIcon"> |
| <gr-icon icon="description"></gr-icon> |
| </div> |
| <div class="sectionContent"> |
| <gr-formatted-text |
| .markdown=${true} |
| .content=${description} |
| ></gr-formatted-text> |
| </div> |
| </div>`; |
| } |
| |
| private renderLabelSection() { |
| if (!this.requirement) return; |
| const requirementLabels = extractAssociatedLabels(this.requirement); |
| const allLabels = this.change?.labels ?? {}; |
| const labels: string[] = []; |
| for (const label of Object.keys(allLabels)) { |
| if (requirementLabels.includes(label)) { |
| const labelInfo = allLabels[label]; |
| const canSomeoneVote = (this.change?.reviewers['REVIEWER'] ?? []).some( |
| reviewer => canReviewerVote(labelInfo, reviewer) |
| ); |
| if (hasVotes(labelInfo) || canSomeoneVote) { |
| labels.push(label); |
| } |
| } |
| } |
| |
| if (labels.length === 0) return; |
| const showLabelName = labels.length >= 2; |
| return html` <div class="section"> |
| <div class="sectionIcon"></div> |
| <div class="row"> |
| <div>${labels.map(l => this.renderLabel(l, showLabelName))}</div> |
| </div> |
| </div>`; |
| } |
| |
| private renderLabel(labelName: string, showLabelName: boolean) { |
| const labels = this.change?.labels ?? {}; |
| return html` |
| ${showLabelName ? html`<div>${labelName} votes</div>` : ''} |
| <gr-label-info |
| .change=${this.change} |
| .account=${this.account} |
| .mutable=${this.mutable} |
| .label=${labelName} |
| .labelInfo=${labels[labelName]} |
| ></gr-label-info> |
| `; |
| } |
| |
| private renderShowHideConditionButton() { |
| const buttonText = this.expanded ? 'Hide conditions' : 'View conditions'; |
| const icon = this.expanded ? 'expand_less' : 'expand_more'; |
| |
| return html` <div class="button"> |
| <gr-button |
| link="" |
| id="toggleConditionsButton" |
| @click=${(_: MouseEvent) => this.toggleConditionsVisibility()} |
| > |
| ${buttonText} |
| <gr-icon .icon=${icon}></gr-icon> |
| </gr-button> |
| </div>`; |
| } |
| |
| private renderVotingButtons() { |
| if (!this.requirement) return; |
| if (!this.account) return; |
| if (this.change?.status === ChangeStatus.MERGED) return; |
| |
| const submittabilityLabels = extractAssociatedLabels( |
| this.requirement, |
| 'onlySubmittability' |
| ); |
| const submittabilityVotes = submittabilityLabels.map(labelName => |
| this.renderLabelVote(labelName, 'submittability') |
| ); |
| |
| const overrideLabels = extractAssociatedLabels( |
| this.requirement, |
| 'onlyOverride' |
| ); |
| const overrideVotes = overrideLabels.map(labelName => |
| this.renderLabelVote(labelName, 'override') |
| ); |
| |
| return submittabilityVotes.concat(overrideVotes); |
| } |
| |
| private renderLabelVote( |
| labelName: string, |
| type: 'override' | 'submittability' |
| ) { |
| if (!this.account) return; |
| const votes = this.change?.permitted_labels?.[labelName]; |
| if (!votes || votes.length < 1) return; |
| const maxVote = Number(votes[votes.length - 1]); |
| if (maxVote <= 0) return; |
| |
| const labels = this.change?.labels ?? {}; |
| const labelInfo = labels[labelName]; |
| const approvalInfo = getApprovalInfo(labelInfo, this.account); |
| if (approvalInfo?.value === maxVote) return; // Already voted maxVote |
| |
| return html` <div class="button quickApprove"> |
| <gr-button |
| link="" |
| @click=${(_: MouseEvent) => this.quickApprove(labelName, maxVote)} |
| > |
| ${this.computeVoteButtonName(labelName, maxVote, type)} |
| </gr-button> |
| </div>`; |
| } |
| |
| private computeVoteButtonName( |
| labelName: string, |
| maxVote: number, |
| type: 'override' | 'submittability' |
| ) { |
| if (type === 'override') { |
| return `Override (${labelName})`; |
| } else { |
| return `Vote ${labelName} +${maxVote}`; |
| } |
| } |
| |
| private quickApprove(label: string, score: number) { |
| assertIsDefined(this.change, 'change'); |
| const review: ReviewInput = { |
| drafts: DraftsAction.PUBLISH_ALL_REVISIONS, |
| labels: { |
| [label]: score, |
| }, |
| }; |
| return this.restApiService |
| .saveChangeReview( |
| this.change._number, |
| this.change.current_revision, |
| review |
| ) |
| .then(() => { |
| fireReload(this); |
| }); |
| } |
| |
| private renderConditionSection() { |
| if (!this.expanded) return; |
| return html` |
| ${this.renderCondition( |
| 'Submit condition', |
| this.requirement?.submittability_expression_result |
| )} |
| ${this.renderCondition( |
| 'Application condition', |
| this.requirement?.applicability_expression_result |
| )} |
| ${this.renderCondition( |
| 'Override condition', |
| this.requirement?.override_expression_result |
| )} |
| `; |
| } |
| |
| private getClassFromAtomStatus( |
| status: SubmitRequirementExpressionAtomStatus |
| ) { |
| switch (status) { |
| case SubmitRequirementExpressionAtomStatus.PASSING: |
| return 'passing atom'; |
| case SubmitRequirementExpressionAtomStatus.FAILING: |
| return 'failing atom'; |
| default: |
| return 'atom'; |
| } |
| } |
| |
| private getTitleFromAtomStatus( |
| status: SubmitRequirementExpressionAtomStatus |
| ) { |
| switch (status) { |
| case SubmitRequirementExpressionAtomStatus.PASSING: |
| return 'Atom evaluates to True'; |
| case SubmitRequirementExpressionAtomStatus.FAILING: |
| return 'Atom evaluates to False'; |
| default: |
| return 'Atom value is unknown'; |
| } |
| } |
| |
| private renderCondition( |
| name: string, |
| expression?: SubmitRequirementExpressionInfo |
| ) { |
| if (!expression?.expression) return ''; |
| return html` |
| <div class="section condition"> |
| <div class="sectionContent"> |
| ${name}:<br /> |
| <span class="expression"> |
| ${atomizeExpression(expression).map(part => |
| part.isAtom |
| ? html`<span |
| class=${this.getClassFromAtomStatus(part.atomStatus!)} |
| title=${this.getTitleFromAtomStatus(part.atomStatus!)} |
| >${part.value}</span |
| >` |
| : part.value |
| )} |
| </span> |
| </div> |
| </div> |
| `; |
| } |
| |
| private toggleConditionsVisibility() { |
| this.expanded = !this.expanded; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-submit-requirement-hovercard': GrSubmitRequirementHovercard; |
| } |
| } |