blob: 66ad38c8d9c08ca64e0d6faa62763f0dbb933a7a [file] [log] [blame]
/**
* @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,
isDetailedLabelInfo,
SubmitRequirementExpressionInfo,
SubmitRequirementResultInfo,
SubmitRequirementStatus,
} from '../../../api/rest-api';
import {
canVote,
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 => canVote(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'
) {
const labels = this.change?.labels ?? {};
const labelInfo = labels[labelName];
if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
if (!this.account || !canVote(labelInfo, this.account)) return;
const approvalInfo = getApprovalInfo(labelInfo, this.account);
const maxVote = approvalInfo?.permitted_voting_range?.max;
if (!maxVote || maxVote <= 0) return;
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, true);
});
}
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;
}
}