Introduce gr-label-info element

This element will be embedded inside a gr-hovercard, and will replace a
large amount of functionality in the change metadata.

Some of the code is copied from the change metadata. It will be deleted
from there in a descendant change.

Change-Id: I10a158dfd7e303e2a15b918e1980680b7f917e59
This commit is contained in:
Kasper Nilsson 2018-06-08 12:07:32 -07:00
parent 26d96c4c95
commit 5072d85704
5 changed files with 553 additions and 1 deletions

View File

@ -46,6 +46,8 @@ limitations under the License.
<g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
<!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
<g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"/></g>
<!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
<g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
<!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
<g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"/><path d="M0 0h24v24H0V0z" fill="none"/></g>
<!-- This is a custom PolyGerrit SVG -->

View File

@ -0,0 +1,132 @@
<!--
@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.
-->
<link rel="import" href="../../../bower_components/polymer/polymer.html">
<link rel="import" href="../../../styles/gr-voting-styles.html">
<link rel="import" href="../../../styles/shared-styles.html">
<link rel="import" href="../gr-account-label/gr-account-label.html">
<link rel="import" href="../gr-button/gr-button.html">
<link rel="import" href="../gr-icons/gr-icons.html">
<link rel="import" href="../gr-label/gr-label.html">
<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
<dom-module id="gr-label-info">
<template strip-whitespace>
<style include="gr-voting-styles"></style>
<style include="shared-styles">
.title {
font-size: var(--font-size-large);
font-weight: bold;
}
.placeholder {
color: var(--deemphasized-text-color);
padding-top: .5em;
}
.hidden {
display: none;
}
.voteChip {
display: flex;
justify-content: center;
margin-right: .3em;
padding: .2em .85em;
@apply --vote-chip-styles;
}
.max {
background-color: var(--vote-color-approved);
}
.min {
background-color: var(--vote-color-rejected);
}
.positive {
background-color: var(--vote-color-recommended);
}
.negative {
background-color: var(--vote-color-disliked);
}
.hidden {
display: none;
}
td {
vertical-align: middle;
}
tr {
min-height: 2.25em;
}
tr td {
padding-top: .35em;
}
tr.currentUser td {
padding-bottom: .5em;
}
tr.currentUser + tr td {
border-top: 1px solid var(--border-color);
padding-top: .5em;
}
gr-button {
--gr-button: {
height: 2em;
padding: 0;
width: 2em;
}
}
gr-account-chip {
margin-right: 1.5em;
}
</style>
<p class="title">[[label]]</p>
<p class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]">
No votes for this label.
</p>
<table>
<template
is="dom-repeat"
items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
as="mappedLabel">
<tr class$="labelValueContainer [[_computeLabelContainerClass(mappedLabel)]]">
<td>
<gr-account-chip
account="[[mappedLabel.account]]"
transparent-background></gr-account-chip>
</td>
<td>
<gr-label
has-tooltip
title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
class$="[[mappedLabel.className]] voteChip">
[[mappedLabel.value]]
</gr-label>
</td>
<td>
<gr-button
link
aria-label="Remove"
on-tap="_onDeleteVote"
tooltip="Remove vote"
data-account-id$="[[mappedLabel.account._account_id]]"
class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]">
<iron-icon icon="gr-icons:delete"></iron-icon>
</gr-button>
</td>
</tr>
</template>
</table>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
</template>
<script src="gr-label-info.js"></script>
</dom-module>

View File

@ -0,0 +1,186 @@
/**
* @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.
*/
(function() {
'use strict';
Polymer({
is: 'gr-label-info',
properties: {
labelInfo: Object,
label: String,
/** @type {?} */
change: Object,
account: Object,
mutable: Boolean,
},
/**
* @param {!Object} labelInfo
* @param {!Object} account
* @param {Object} changeLabelsRecord not used, but added as a parameter in
* order to trigger computation when a label is removed from the change.
*/
_mapLabelInfo(labelInfo, account, changeLabelsRecord) {
const result = [];
if (!labelInfo) { return result; }
if (!labelInfo.values) {
if (labelInfo.rejected || labelInfo.approved) {
const ok = labelInfo.approved || !labelInfo.rejected;
return [{
value: ok ? '👍️' : '👎️',
className: ok ? 'positive' : 'negative',
account: ok ? labelInfo.approved : labelInfo.rejected,
}];
}
return result;
}
// Sort votes by positivity.
const votes = (labelInfo.all || []).sort((a, b) => a.value - b.value);
const values = Object.keys(labelInfo.values);
for (const label of votes) {
if (label.value && label.value != labelInfo.default_value) {
let labelClassName;
let labelValPrefix = '';
if (label.value > 0) {
labelValPrefix = '+';
if (parseInt(label.value, 10) ===
parseInt(values[values.length - 1], 10)) {
labelClassName = 'max';
} else {
labelClassName = 'positive';
}
} else if (label.value < 0) {
if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
labelClassName = 'min';
} else {
labelClassName = 'negative';
}
}
if (label._account_id === account._account_id) {
// Put self-votes at the top, and add a flag.
result.unshift({
value: labelValPrefix + label.value,
className: labelClassName,
account: label,
isCurrentUser: true,
});
} else {
result.push({
value: labelValPrefix + label.value,
className: labelClassName,
account: label,
});
}
}
}
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 {!Object} reviewer An object describing the reviewer that left the
* vote.
* @param {Boolean} mutable
* @param {!Object} change
*/
_computeDeleteClass(reviewer, mutable, change) {
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, supressing annotations.
*
* @suppress {checkTypes} */
_onDeleteVote(e) {
e.preventDefault();
let target = Polymer.dom(e).rootTarget;
while (!target.classList.contains('deleteBtn')) {
if (!target.parentElement) { return; }
target = target.parentElement;
}
target.disabled = true;
const accountID = parseInt(target.getAttribute('data-account-id'), 10);
this._xhrPromise =
this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
.then(response => {
target.disabled = false;
if (!response.ok) { return response; }
const label = this.change.labels[this.label];
const labels = label.all || [];
let wasChanged = false;
for (let i = 0; i < labels.length; i++) {
if (labels[i]._account_id === accountID) {
for (const key in label) {
if (label.hasOwnProperty(key) &&
label[key]._account_id === accountID) {
// Remove special label field, keeping change label values
// in sync with the backend.
this.change.labels[this.label][key] = null;
}
}
this.change.labels[this.label].all.splice(i, 1);
wasChanged = true;
break;
}
}
if (wasChanged) { this.notifySplices('change.labels'); }
}).catch(err => {
target.disabled = false;
return;
});
},
_computeValueTooltip(labelInfo, score) {
if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
return '';
}
return labelInfo.values[score];
},
_computeLabelContainerClass(label) {
return label.isCurrentUser ? 'currentUser' : '';
},
/**
* @param {!Object} labelInfo
* @param {Object} changeLabelsRecord not used, but added as a parameter in
* order to trigger computation when a label is removed from the change.
*/
_computeShowPlaceholder(labelInfo, changeLabelsRecord) {
if (labelInfo.all) {
for (const label of labelInfo.all) {
if (label.value) { return 'hidden'; }
}
}
return '';
},
});
})();

View File

@ -0,0 +1,230 @@
<!--
@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.
-->
<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
<title>gr-label-info</title>
<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
<script src="../../../bower_components/web-component-tester/browser.js"></script>
<link rel="import" href="../../../test/common-test-setup.html"/>
<link rel="import" href="gr-label-info.html">
<script>void(0);</script>
<test-fixture id="basic">
<template>
<gr-label-info></gr-label-info>
</template>
</test-fixture>
<script>
suite('gr-account-link tests', () => {
let element;
let sandbox;
setup(() => {
element = fixture('basic');
sandbox = sinon.sandbox.create();
// Needed to trigger computed bindings.
element.account = {};
element.change = {labels: {}};
});
teardown(() => {
sandbox.restore();
});
suite('remove reviewer votes', () => {
setup(() => {
sandbox.stub(element, '_computeValueTooltip').returns('');
element.account = {
_account_id: 1,
name: 'bojack',
};
const test = {
all: [{_account_id: 1, name: 'bojack', value: 1}],
default_value: 0,
values: [],
};
element.change = {
_number: 42,
change_id: 'the id',
actions: [],
topic: 'the topic',
status: 'NEW',
submit_type: 'CHERRY_PICK',
labels: {test},
removable_reviewers: [],
};
element.labelInfo = test;
element.label = 'test';
flushAsynchronousOperations();
});
test('_computeCanDeleteVote', () => {
element.mutable = false;
const button = element.$$('gr-button');
assert.isTrue(isHidden(button));
element.change.removable_reviewers = [element.account];
element.mutable = true;
assert.isFalse(isHidden(button));
});
test('deletes votes', () => {
const deleteResponse = Promise.resolve({ok: true});
const deleteStub = sandbox.stub(
element.$.restAPI, 'deleteVote').returns(deleteResponse);
element.change.removable_reviewers = [element.account];
element.change.labels.test.recommended = {_account_id: 1};
element.mutable = true;
const button = element.$$('gr-button');
MockInteractions.tap(button);
assert.isTrue(button.disabled);
return deleteResponse.then(() => {
assert.isFalse(button.disabled);
assert.notOk(element.change.labels.test.recommended);
assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
});
});
});
suite('label color and order', () => {
test('valueless label rejected', () => {
element.labelInfo = {rejected: {name: 'someone'}};
flushAsynchronousOperations();
const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('negative'));
});
test('valueless label approved', () => {
element.labelInfo = {approved: {name: 'someone'}};
flushAsynchronousOperations();
const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('positive'));
});
test('-2 to +2', () => {
element.labelInfo = {
all: [
{value: 2, name: 'user 2'},
{value: 1, name: 'user 1'},
{value: -1, name: 'user 3'},
{value: -2, name: 'user 4'},
],
values: {
'-2': 'Awful',
'-1': 'Don\'t submit as-is',
' 0': 'No score',
'+1': 'Looks good to me',
'+2': 'Ready to submit',
},
};
flushAsynchronousOperations();
const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('max'));
assert.isTrue(labels[1].classList.contains('positive'));
assert.isTrue(labels[2].classList.contains('negative'));
assert.isTrue(labels[3].classList.contains('min'));
});
test('-1 to +1', () => {
element.labelInfo = {
all: [
{value: 1, name: 'user 1'},
{value: -1, name: 'user 2'},
],
values: {
'-1': 'Don\'t submit as-is',
' 0': 'No score',
'+1': 'Looks good to me',
},
};
flushAsynchronousOperations();
const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('max'));
assert.isTrue(labels[1].classList.contains('min'));
});
test('0 to +2', () => {
element.labelInfo = {
all: [
{value: 1, name: 'user 2'},
{value: 2, name: 'user '},
],
values: {
' 0': 'Don\'t submit as-is',
'+1': 'No score',
'+2': 'Looks good to me',
},
};
flushAsynchronousOperations();
const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('max'));
assert.isTrue(labels[1].classList.contains('positive'));
});
test('self votes at top', () => {
element.account = {
_account_id: 1,
name: 'bojack',
};
element.labelInfo = {
all: [
{value: 1, name: 'user 1', _account_id: 2},
{value: -1, name: 'bojack', _account_id: 1},
],
values: {
'-1': 'Don\'t submit as-is',
' 0': 'No score',
'+1': 'Looks good to me',
},
};
flushAsynchronousOperations();
const chips =
Polymer.dom(element.root).querySelectorAll('gr-account-chip');
assert.equal(chips[0].account._account_id, element.account._account_id);
});
});
test('_computeValueTooltip', () => {
// Existing label.
let labelInfo = {values: {0: 'Baz'}};
let score = '0';
assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
// Non-exsistent score.
score = '2';
assert.equal(element._computeValueTooltip(labelInfo, score), '');
// No values on label.
labelInfo = {values: {}};
score = '0';
assert.equal(element._computeValueTooltip(labelInfo, score), '');
});
test('placeholder', () => {
element.labelInfo = {};
assert.isFalse(isHidden(element.$$('.placeholder')));
element.labelInfo = {all: []};
assert.isFalse(isHidden(element.$$('.placeholder')));
element.labelInfo = {all: [{value: 1}]};
assert.isTrue(isHidden(element.$$('.placeholder')));
});
});
</script>

View File

@ -35,7 +35,7 @@ limitations under the License.
});
</script>
<script>
// eslint-disable-next-line no-unused-vars
/* eslint-disable no-unused-vars */
const mockPromise = () => {
let res;
const promise = new Promise(resolve => {
@ -44,6 +44,8 @@ limitations under the License.
promise.resolve = res;
return promise;
};
const isHidden = el => getComputedStyle(el).display === 'none';
/* eslint-enable no-unused-vars */
</script>
<script>
(function() {