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
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index 9102bdb..34ca0ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -46,6 +46,8 @@
<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 -->
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
new file mode 100644
index 0000000..67537cf
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
@@ -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>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
new file mode 100644
index 0000000..750ba1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -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 '';
+ },
+ });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
new file mode 100644
index 0000000..8bc358d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
@@ -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>
\ No newline at end of file
diff --git a/polygerrit-ui/app/test/common-test-setup.html b/polygerrit-ui/app/test/common-test-setup.html
index 6b3a4d1..92b99e3 100644
--- a/polygerrit-ui/app/test/common-test-setup.html
+++ b/polygerrit-ui/app/test/common-test-setup.html
@@ -35,7 +35,7 @@
});
</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 @@
promise.resolve = res;
return promise;
};
+ const isHidden = el => getComputedStyle(el).display === 'none';
+ /* eslint-enable no-unused-vars */
</script>
<script>
(function() {