blob: 47d722d1efaf67fb499342835fff9064f73a3b2e [file] [log] [blame]
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/gr-font-styles';
import '../../../styles/gr-voting-styles';
import '../../../styles/shared-styles';
import '../gr-icon/gr-icon';
import '../gr-vote-chip/gr-vote-chip';
import '../gr-account-chip/gr-account-chip';
import '../gr-button/gr-button';
import '../gr-tooltip-content/gr-tooltip-content';
import {
AccountInfo,
LabelInfo,
ApprovalInfo,
AccountId,
isDetailedLabelInfo,
} from '../../../types/common';
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {GrButton} from '../gr-button/gr-button';
import {
canVote,
getApprovalInfo,
hasNeutralStatus,
hasVoted,
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.js';
import {fireReload} from '../../../utils/event-util';
import {sortReviewers} from '../../../utils/attention-set-util';
declare global {
interface HTMLElementTagNameMap {
'gr-label-info': GrLabelInfo;
}
}
@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;
// TODO(TS): not used, remove later
_xhrPromise?: Promise<void>;
static override get styles() {
return [
sharedStyles,
fontStyles,
votingStyles,
css`
.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;
}
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] gr-icon {
color: var(--border-color);
}
gr-icon {
font-size: calc(var(--line-height-normal) - 2px);
}
.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;
}
`,
];
}
override render() {
const labelInfo = this.labelInfo;
if (!labelInfo) return;
const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
.filter(reviewer => {
if (this.showAllReviewers) {
if (isDetailedLabelInfo(labelInfo)) {
return canVote(labelInfo, reviewer);
} else {
// isQuickLabelInfo
return hasVoted(labelInfo, reviewer);
}
} else {
// !showAllReviewers
return hasVoted(labelInfo, reviewer);
}
})
.sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
return html`<div>
${reviewers.map(reviewer => this.renderReviewerVote(reviewer))}
</div>`;
}
renderReviewerVote(reviewer: AccountInfo) {
const labelInfo = this.labelInfo;
if (!labelInfo) return;
const approvalInfo = isDetailedLabelInfo(labelInfo)
? getApprovalInfo(labelInfo, reviewer)
: undefined;
const noVoteYet =
!hasVoted(labelInfo, reviewer) ||
(isDetailedLabelInfo(labelInfo) &&
hasNeutralStatus(labelInfo, approvalInfo));
return html`<div class="reviewer-row">
<gr-account-chip
.account=${reviewer}
.change=${this.change}
.vote=${approvalInfo}
.label=${labelInfo}
>
<gr-vote-chip
slot="vote-chip"
.vote=${approvalInfo}
.label=${labelInfo}
circle-shape
></gr-vote-chip
></gr-account-chip>
${noVoteYet
? this.renderVoteAbility(reviewer)
: html`${this.renderRemoveVote(reviewer)}`}
</div>`;
}
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
)}"
>
<gr-icon icon="delete" filled></gr-icon>
</gr-button>
</gr-tooltip-content>`;
}
/**
* 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 = e.composedPath()[0] 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('Delete vote', err);
target.disabled = false;
return;
});
}
_computeValueTooltip(labelInfo: LabelInfo | undefined, score: string) {
if (
!labelInfo ||
!isDetailedLabelInfo(labelInfo) ||
!labelInfo.values?.[score]
) {
return '';
}
return labelInfo.values[score];
}
}