blob: bfd5f281dae36c69e60039a38e39de79f6ab2556 [file] [log] [blame]
/**
* @license
* Copyright (C) 2017 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 '@polymer/iron-selector/iron-selector';
import '../../shared/gr-button/gr-button';
import {sharedStyles} from '../../../styles/shared-styles';
import {css, html, nothing, LitElement} from 'lit';
import {customElement, property, query, state} from 'lit/decorators';
import {ifDefined} from 'lit/directives/if-defined';
import {IronSelectorElement} from '@polymer/iron-selector/iron-selector';
import {
LabelNameToValueMap,
LabelNameToInfoMap,
QuickLabelInfo,
DetailedLabelInfo,
} from '../../../types/common';
import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
export interface Label {
name: string;
value: string | null;
}
// TODO(TS): add description to explain what this is after moving
// gr-label-scores to ts
export interface LabelValuesMap {
[key: number]: number;
}
declare global {
interface HTMLElementTagNameMap {
'gr-label-score-row': GrLabelScoreRow;
}
}
@customElement('gr-label-score-row')
export class GrLabelScoreRow extends LitElement {
/**
* Fired when any label is changed.
*
* @event labels-changed
*/
@query('#labelSelector')
labelSelector?: IronSelectorElement;
@property({type: Object})
label: Label | undefined | null;
@property({type: Object})
labels?: LabelNameToInfoMap;
@property({type: String, reflect: true})
name?: string;
@property({type: Object})
permittedLabels: LabelNameToValueMap | undefined | null;
@property({type: Object})
labelValues?: LabelValuesMap;
@state()
private selectedValueText = 'No value selected';
static override get styles() {
return [
sharedStyles,
css`
.labelNameCell,
.buttonsCell,
.selectedValueCell {
padding: var(--spacing-s) var(--spacing-m);
display: table-cell;
}
/* We want the :hover highlight to extend to the border of the dialog. */
.labelNameCell {
padding-left: var(--spacing-xl);
}
.selectedValueCell {
padding-right: var(--spacing-xl);
}
/* This is a trick to let the selectedValueCell take the remaining width. */
.labelNameCell,
.buttonsCell {
white-space: nowrap;
}
.selectedValueCell {
width: 75%;
}
.labelMessage {
color: var(--deemphasized-text-color);
}
gr-button {
min-width: 42px;
box-sizing: border-box;
}
gr-button::part(paper-button) {
background-color: var(
--button-background-color,
var(--table-header-background-color)
);
padding: 0 var(--spacing-m);
}
gr-button[vote='max'].iron-selected {
--button-background-color: var(--vote-color-approved);
}
gr-button[vote='positive'].iron-selected {
--button-background-color: var(--vote-color-recommended);
}
gr-button[vote='min'].iron-selected {
--button-background-color: var(--vote-color-rejected);
}
gr-button[vote='negative'].iron-selected {
--button-background-color: var(--vote-color-disliked);
}
gr-button[vote='neutral'].iron-selected {
--button-background-color: var(--vote-color-neutral);
}
gr-button[vote='positive'].iron-selected::part(paper-button) {
border-color: var(--vote-outline-recommended);
}
gr-button[vote='negative'].iron-selected::part(paper-button) {
border-color: var(--vote-outline-disliked);
}
gr-button > gr-tooltip-content {
margin: 0px -10px;
padding: 0px 10px;
}
.placeholder {
display: inline-block;
width: 42px;
height: 1px;
}
.placeholder::before {
content: ' ';
}
.selectedValueCell {
color: var(--deemphasized-text-color);
font-style: italic;
}
.selectedValueCell.hidden {
display: none;
}
@media only screen and (max-width: 50em) {
.selectedValueCell {
display: none;
}
}
`,
];
}
override render() {
return html`
<span class="labelNameCell" id="labelName" aria-hidden="true"
>${this.label?.name ?? ''}</span
>
${this.renderButtonsCell()} ${this.renderSelectedValue()}
`;
}
private renderButtonsCell() {
return html`
<div class="buttonsCell">
${this.renderBlankItems('start')} ${this.renderLabelSelector()}
${this.renderBlankItems('end')}
${!this._computeAnyPermittedLabelValues()
? html` <span class="labelMessage">
You don't have permission to edit this label.
</span>`
: nothing}
</div>
`;
}
private renderBlankItems(position: string) {
const blankItems = this._computeBlankItems(position);
return blankItems.map(
_value => html`
<span class="placeholder" data-label="${this.label?.name ?? ''}">
</span>
`
);
}
private renderLabelSelector() {
return html`
<iron-selector
id="labelSelector"
attr-for-selected="data-value"
?hidden="${!this._computeAnyPermittedLabelValues()}"
selected="${ifDefined(this._computeLabelValue())}"
@selected-item-changed=${this.setSelectedValueText}
role="radiogroup"
aria-labelledby="labelName"
>
${this.renderPermittedLabels()}
</iron-selector>
`;
}
private renderPermittedLabels() {
const items = this.computePermittedLabelValues();
return items.map(
(value, index) => html`
<gr-button
role="radio"
vote="${this._computeVoteAttribute(
Number(value),
index,
items.length
)}"
title="${ifDefined(this.computeLabelValueTitle(value))}"
data-name="${ifDefined(this.label?.name)}"
data-value="${value}"
aria-label="${value}"
voteChip
>
<gr-tooltip-content
has-tooltip
title="${ifDefined(this.computeLabelValueTitle(value))}"
>
${value}
</gr-tooltip-content>
</gr-button>
`
);
}
private renderSelectedValue() {
return html`
<div class="selectedValueCell ${this.computeHiddenClass()}">
<span id="selectedValueLabel">${this.selectedValueText}</span>
</div>
`;
}
get selectedItem(): IronSelectorElement | undefined {
if (!this.labelSelector) {
return undefined;
}
return this.labelSelector.selectedItem as IronSelectorElement;
}
get selectedValue() {
if (!this.labelSelector) {
return undefined;
}
return this.labelSelector.selected;
}
setSelectedValue(value: string) {
// The selector may not be present if it’s not at the latest patch set.
if (!this.labelSelector) {
return;
}
this.labelSelector.select(value);
}
// Private but used in tests.
_computeBlankItems(side: string) {
if (
!this.label ||
!this.permittedLabels?.[this.label.name] ||
!this.permittedLabels[this.label.name].length ||
!this.labelValues ||
!Object.keys(this.labelValues).length
) {
return [];
}
const permittedLabel = this.permittedLabels[this.label.name];
const startPosition = this.labelValues[Number(permittedLabel[0])];
if (side === 'start') {
return new Array(startPosition).fill('');
}
const endPosition =
this.labelValues[Number(permittedLabel[permittedLabel.length - 1])];
const length = Object.keys(this.labelValues).length - endPosition - 1;
return new Array(length).fill('');
}
private getLabelValue() {
assertIsDefined(this.labels);
assertIsDefined(this.label);
assertIsDefined(this.permittedLabels);
if (this.label.value) {
return this.label.value;
} else if (
hasOwnProperty(this.labels[this.label.name], 'default_value') &&
hasOwnProperty(this.permittedLabels, this.label.name)
) {
// default_value is an int, convert it to string label, e.g. "+1".
return this.permittedLabels[this.label.name].find(
value =>
Number(value) ===
(this.labels![this.label!.name] as QuickLabelInfo).default_value
);
}
return;
}
/**
* Private but used in tests.
* Maps the label value to exactly one of: min, max, positive, negative,
* neutral. Used for the 'vote' attribute, because we don't want to
* interfere with <iron-selector> using the 'class' attribute for setting
* 'iron-selected'.
*/
_computeVoteAttribute(value: number, index: number, totalItems: number) {
if (value < 0 && index === 0) {
return 'min';
} else if (value < 0) {
return 'negative';
} else if (value > 0 && index === totalItems - 1) {
return 'max';
} else if (value > 0) {
return 'positive';
} else {
return 'neutral';
}
}
// Private but used in tests.
_computeLabelValue() {
// Polymer 2+ undefined check
if (!this.labels || !this.permittedLabels || !this.label) {
return undefined;
}
if (!this.labels[this.label.name]) {
return undefined;
}
const labelValue = this.getLabelValue();
const permittedLabel = this.permittedLabels[this.label.name];
const len = permittedLabel ? permittedLabel.length : 0;
for (let i = 0; i < len; i++) {
const val = permittedLabel[i];
if (val === labelValue) {
return val;
}
}
return undefined;
}
private setSelectedValueText = (e: Event) => {
// Needed because when the selected item changes, it first changes to
// nothing and then to the new item.
const selectedItem = (e.target as IronSelectorElement)
.selectedItem as HTMLElement;
if (!selectedItem) {
return;
}
if (!this.labelSelector?.items) {
return;
}
for (const item of this.labelSelector.items) {
if (selectedItem === item) {
item.setAttribute('aria-checked', 'true');
} else {
item.removeAttribute('aria-checked');
}
}
this.selectedValueText = selectedItem.getAttribute('title') || '';
const name = selectedItem.dataset['name'];
const value = selectedItem.dataset['value'];
this.dispatchEvent(
new CustomEvent('labels-changed', {
detail: {name, value},
bubbles: true,
composed: true,
})
);
};
_computeAnyPermittedLabelValues() {
return (
this.permittedLabels &&
this.label &&
hasOwnProperty(this.permittedLabels, this.label.name) &&
this.permittedLabels[this.label.name].length
);
}
private computeHiddenClass() {
return !this._computeAnyPermittedLabelValues() ? 'hidden' : '';
}
private computePermittedLabelValues() {
if (!this.permittedLabels || !this.label) {
return [];
}
return this.permittedLabels[this.label.name] || [];
}
private computeLabelValueTitle(value: string) {
if (!this.labels || !this.label) return '';
const label = this.labels[this.label.name];
if (label && (label as DetailedLabelInfo).values) {
// TODO(TS): maybe add a type guard for DetailedLabelInfo and
// QuickLabelInfo
return (label as DetailedLabelInfo).values![value];
} else {
return '';
}
}
}