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() {