blob: a0b32748034d2952e98a291dc61b9bd3e1b4bfff [file] [log] [blame]
/**
* @license
* Copyright (C) 2018 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 '../../../styles/gr-font-styles';
import '../../../styles/gr-voting-styles';
import '../../../styles/shared-styles';
import '../gr-vote-chip/gr-vote-chip';
import '../gr-account-label/gr-account-label';
import '../gr-account-link/gr-account-link';
import '../gr-account-chip/gr-account-chip';
import '../gr-button/gr-button';
import '../gr-icons/gr-icons';
import '../gr-label/gr-label';
import '../gr-tooltip-content/gr-tooltip-content';
import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {
AccountInfo,
LabelInfo,
ApprovalInfo,
AccountId,
isQuickLabelInfo,
isDetailedLabelInfo,
LabelNameToInfoMap,
} from '../../../types/common';
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators';
import {GrButton} from '../gr-button/gr-button';
import {
canVote,
getApprovalInfo,
getVotingRangeOrDefault,
hasNeutralStatus,
hasVoted,
showNewSubmitRequirements,
valueString,
} from '../../../utils/label-util';
import {getAppContext} from '../../../services/app-context';
import {ParsedChangeInfo} from '../../../types/types';
import {fontStyles} from '../../../styles/gr-font-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {votingStyles} from '../../../styles/gr-voting-styles';
import {ifDefined} from 'lit/directives/if-defined';
import {fireReload} from '../../../utils/event-util';
import {sortReviewers} from '../../../utils/attention-set-util';
declare global {
interface HTMLElementTagNameMap {
'gr-label-info': GrLabelInfo;
}
}
enum LabelClassName {
NEGATIVE = 'negative',
POSITIVE = 'positive',
MIN = 'min',
MAX = 'max',
}
interface FormattedLabel {
className?: LabelClassName;
account: ApprovalInfo | AccountInfo;
value: string;
}
@customElement('gr-label-info')
export class GrLabelInfo extends LitElement {
@property({type: Object})
labelInfo?: LabelInfo;
@property({type: String})
label = '';
@property({type: Object})
change?: ParsedChangeInfo;
@property({type: Object})
account?: AccountInfo;
/**
* A user is able to delete a vote iff the mutable property is true and the
* reviewer that left the vote exists in the list of removable_reviewers
* received from the backend.
*/
@property({type: Boolean})
mutable = false;
/**
* if true - show all reviewers that can vote on label
* if false - show only reviewers that voted on label
*/
@property({type: Boolean})
showAllReviewers = true;
private readonly restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
private readonly flagsService = getAppContext().flagsService;
// TODO(TS): not used, remove later
_xhrPromise?: Promise<void>;
static override get styles() {
return [
sharedStyles,
fontStyles,
votingStyles,
css`
.placeholder {
color: var(--deemphasized-text-color);
}
.hidden {
display: none;
}
/* Note that most of the .voteChip styles are coming from the
gr-voting-styles include. */
.voteChip {
display: flex;
justify-content: center;
margin-right: var(--spacing-s);
padding: 1px;
}
.max {
background-color: var(--vote-color-approved);
}
.min {
background-color: var(--vote-color-rejected);
}
.positive {
background-color: var(--vote-color-recommended);
border-radius: 12px;
border: 1px solid var(--vote-outline-recommended);
color: var(--chip-color);
}
.negative {
background-color: var(--vote-color-disliked);
border-radius: 12px;
border: 1px solid var(--vote-outline-disliked);
color: var(--chip-color);
}
.hidden {
display: none;
}
td {
vertical-align: top;
}
tr {
min-height: var(--line-height-normal);
}
gr-tooltip-content {
display: block;
}
gr-button {
vertical-align: top;
}
gr-button::part(paper-button) {
height: var(--line-height-normal);
width: var(--line-height-normal);
padding: 0;
}
gr-button[disabled] iron-icon {
color: var(--border-color);
}
gr-account-link {
--account-max-length: 100px;
margin-right: var(--spacing-xs);
}
iron-icon {
height: calc(var(--line-height-normal) - 2px);
width: calc(var(--line-height-normal) - 2px);
}
.labelValueContainer:not(:first-of-type) td {
padding-top: var(--spacing-s);
}
.reviewer-row {
padding-top: var(--spacing-s);
}
.reviewer-row:first-of-type {
padding-top: 0;
}
.reviewer-row gr-account-chip,
.reviewer-row gr-tooltip-content {
display: inline-block;
vertical-align: top;
}
.reviewer-row .no-votes {
color: var(--deemphasized-text-color);
margin-left: var(--spacing-xs);
}
gr-vote-chip {
--gr-vote-chip-width: 14px;
--gr-vote-chip-height: 14px;
margin-right: var(--spacing-s);
}
`,
];
}
override render() {
if (showNewSubmitRequirements(this.flagsService, this.change)) {
return this.renderNewSubmitRequirements();
} else {
return this.renderOldSubmitRequirements();
}
}
private renderNewSubmitRequirements() {
const labelInfo = this.labelInfo;
if (!labelInfo) return;
const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
.filter(
reviewer =>
(this.showAllReviewers && canVote(labelInfo, reviewer)) ||
(!this.showAllReviewers && hasVoted(labelInfo, reviewer))
)
.sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
return html`<div>
${reviewers.map(reviewer => this.renderReviewerVote(reviewer))}
</div>`;
}
private renderOldSubmitRequirements() {
const labelInfo = this.labelInfo;
return html` <p
class="placeholder ${this.computeShowPlaceholder(
labelInfo,
this.change?.labels
)}"
>
No votes
</p>
<table>
${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
mappedLabel => this.renderLabel(mappedLabel)
)}
</table>`;
}
renderReviewerVote(reviewer: AccountInfo) {
const labelInfo = this.labelInfo;
if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
const approvalInfo = getApprovalInfo(labelInfo, reviewer);
const noVoteYet =
!approvalInfo || hasNeutralStatus(labelInfo, approvalInfo);
return html`<div class="reviewer-row">
<gr-account-chip .account="${reviewer}" .change="${this.change}">
<gr-vote-chip
slot="vote-chip"
.vote="${approvalInfo}"
.label="${labelInfo}"
></gr-vote-chip
></gr-account-chip>
${noVoteYet
? this.renderVoteAbility(reviewer)
: html`${this.renderRemoveVote(reviewer)}`}
</div>`;
}
renderLabel(mappedLabel: FormattedLabel) {
const {labelInfo, change} = this;
return html` <tr class="labelValueContainer">
<td>
<gr-tooltip-content
has-tooltip
title="${this._computeValueTooltip(labelInfo, mappedLabel.value)}"
>
<gr-label class="${mappedLabel.className} voteChip font-small">
${mappedLabel.value}
</gr-label>
</gr-tooltip-content>
</td>
<td>
<gr-account-link
.account="${mappedLabel.account}"
.change="${change}"
></gr-account-link>
</td>
<td>${this.renderRemoveVote(mappedLabel.account)}</td>
</tr>`;
}
private renderVoteAbility(reviewer: AccountInfo) {
if (this.labelInfo && isDetailedLabelInfo(this.labelInfo)) {
const approvalInfo = getApprovalInfo(this.labelInfo, reviewer);
if (approvalInfo?.permitted_voting_range) {
const {min, max} = approvalInfo?.permitted_voting_range;
return html`<span class="no-votes"
>Can vote ${valueString(min)}/${valueString(max)}</span
>`;
}
}
return html`<span class="no-votes">No votes</span>`;
}
private renderRemoveVote(reviewer: AccountInfo) {
return html`<gr-tooltip-content has-tooltip title="Remove vote">
<gr-button
link
aria-label="Remove vote"
@click="${this.onDeleteVote}"
data-account-id="${ifDefined(
reviewer._account_id as number | undefined
)}"
class="deleteBtn ${this.computeDeleteClass(
reviewer,
this.mutable,
this.change
)}"
>
<iron-icon icon="gr-icons:delete"></iron-icon>
</gr-button>
</gr-tooltip-content>`;
}
/**
* This method also listens on change.labels.*,
* to trigger computation when a label is removed from the change.
*
* The third parameter is just for *triggering* computation.
*/
private mapLabelInfo(
labelInfo?: LabelInfo,
account?: AccountInfo,
_?: LabelNameToInfoMap
): FormattedLabel[] {
const result: FormattedLabel[] = [];
if (!labelInfo) {
return result;
}
if (!isDetailedLabelInfo(labelInfo)) {
if (
isQuickLabelInfo(labelInfo) &&
(labelInfo.rejected || labelInfo.approved)
) {
const ok = labelInfo.approved || !labelInfo.rejected;
return [
{
value: ok ? '👍️' : '👎️',
className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
// executed only if approved or rejected is not undefined
account: ok ? labelInfo.approved! : labelInfo.rejected!,
},
];
}
return result;
}
// Sort votes by positivity.
// TODO(TS): maybe mark value as required if always present
const votes = (labelInfo.all || []).sort(
(a, b) => (a.value || 0) - (b.value || 0)
);
const votingRange = getVotingRangeOrDefault(labelInfo);
for (const label of votes) {
if (
label.value &&
(!isQuickLabelInfo(labelInfo) ||
label.value !== labelInfo.default_value)
) {
let labelClassName;
let labelValPrefix = '';
if (label.value > 0) {
labelValPrefix = '+';
if (label.value === votingRange.max) {
labelClassName = LabelClassName.MAX;
} else {
labelClassName = LabelClassName.POSITIVE;
}
} else if (label.value < 0) {
if (label.value === votingRange.min) {
labelClassName = LabelClassName.MIN;
} else {
labelClassName = LabelClassName.NEGATIVE;
}
}
const formattedLabel: FormattedLabel = {
value: `${labelValPrefix}${label.value}`,
className: labelClassName,
account: label,
};
if (label._account_id === account?._account_id) {
// Put self-votes at the top.
result.unshift(formattedLabel);
} else {
result.push(formattedLabel);
}
}
}
return result;
}
/**
* A user is able to delete a vote iff the mutable property is true and the
* reviewer that left the vote exists in the list of removable_reviewers
* received from the backend.
*
* @param reviewer An object describing the reviewer that left the
* vote.
*/
private computeDeleteClass(
reviewer: ApprovalInfo,
mutable: boolean,
change?: ParsedChangeInfo
) {
if (!mutable || !change || !change.removable_reviewers) {
return 'hidden';
}
const removable = change.removable_reviewers;
if (removable.find(r => r._account_id === reviewer?._account_id)) {
return '';
}
return 'hidden';
}
/**
* Closure annotation for Polymer.prototype.splice is off.
* For now, suppressing annotations.
*/
private onDeleteVote(e: MouseEvent) {
if (!this.change) return;
e.preventDefault();
let target = (dom(e) as EventApi).rootTarget as GrButton;
while (!target.classList.contains('deleteBtn')) {
if (!target.parentElement) {
return;
}
target = target.parentElement as GrButton;
}
target.disabled = true;
const accountID = Number(
`${target.getAttribute('data-account-id')}`
) as AccountId;
this._xhrPromise = this.restApiService
.deleteVote(this.change._number, accountID, this.label)
.then(response => {
target.disabled = false;
if (!response.ok) {
return;
}
if (this.change) {
fireReload(this);
}
})
.catch(err => {
this.reporting.error(err);
target.disabled = false;
return;
});
}
_computeValueTooltip(labelInfo: LabelInfo | undefined, score: string) {
if (
!labelInfo ||
!isDetailedLabelInfo(labelInfo) ||
!labelInfo.values?.[score]
) {
return '';
}
return labelInfo.values[score];
}
/**
* This method also listens change.labels.* in
* order to trigger computation when a label is removed from the change.
*
* The second parameter is just for *triggering* computation.
*/
private computeShowPlaceholder(
labelInfo?: LabelInfo,
_?: LabelNameToInfoMap
) {
if (!labelInfo) {
return '';
}
if (
!isDetailedLabelInfo(labelInfo) &&
isQuickLabelInfo(labelInfo) &&
(labelInfo.rejected || labelInfo.approved)
) {
return 'hidden';
}
if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
for (const label of labelInfo.all) {
if (
label.value &&
(!isQuickLabelInfo(labelInfo) ||
label.value !== labelInfo.default_value)
) {
return 'hidden';
}
}
}
return '';
}
}