Add change view actions JS interface

This provides a simple interface for gr-change-actions for use by
the JS API. It allows the author to not have to worry about
implementation details of the element itself, while still
providing a reduced-surface API contract that can be tested as
the underlying element evolves.

Feature: Issue 3915
Change-Id: I2f82060ea14adef93a7018b79bf7cf10b84e5735
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index d2026f6..b741784 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -59,7 +59,7 @@
       }
     </style>
     <div>
-      <section hidden$="[[!_keyCount(actions)]]" hidden>
+      <section hidden$="[[!_actionCount(actions.*, _additionalActions.*)]]">
         <div class="groupLabel">Change</div>
         <template is="dom-repeat" items="[[_changeActionValues]]" as="action">
           <gr-button title$="[[action.title]]"
@@ -72,7 +72,7 @@
               on-tap="_handleActionTap"></gr-button>
         </template>
       </section>
-      <section hidden$="[[!_keyCount(_revisionActions)]]" hidden>
+      <section hidden$="[[!_actionCount(_revisionActions.*, _additionalActions.*)]]">
         <div class="groupLabel">Revision</div>
         <template is="dom-repeat" items="[[_revisionActionValues]]" as="action">
           <gr-button title$="[[action.title]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index d195241..9783831 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -59,7 +59,10 @@
      */
 
     properties: {
-      actions: Object,
+      actions: {
+        type: Object,
+        value: function() { return {}; },
+      },
       primaryActionKeys: {
         type: Array,
         value: function() {
@@ -77,7 +80,10 @@
         type: Boolean,
         value: true,
       },
-      _revisionActions: Object,
+      _revisionActions: {
+        type: Object,
+        value: function() { return {}; },
+      },
       _revisionActionValues: {
         type: Array,
         computed: '_computeRevisionActionValues(_revisionActions.*, ' +
@@ -103,7 +109,7 @@
     ],
 
     observers: [
-      '_actionsChanged(actions, _revisionActions, _additionalActions)',
+      '_actionsChanged(actions.*, _revisionActions.*, _additionalActions.*)',
     ],
 
     ready: function() {
@@ -129,7 +135,7 @@
       }.bind(this));
     },
 
-    addActionButton: function(key, type, label) {
+    addActionButton: function(type, label) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
         throw Error('Invalid action type: ' + type);
       }
@@ -137,39 +143,59 @@
         enabled: true,
         label: label,
         __type: type,
-        __key: ADDITIONAL_ACTION_KEY_PREFIX + key + Math.random().toString(36),
+        __key: ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36),
       };
       this.push('_additionalActions', action);
       return action.__key;
     },
 
     removeActionButton: function(key) {
-      var idx = -1;
-      for (var i = 0; i < this._additionalActions.length; i++) {
-        if (this._additionalActions[i].__key === key) {
-          idx = i;
-          break;
-        }
-      }
+      var idx = this._indexOfActionButtonWithKey(key);
       if (idx === -1) {
-        console.error('Could not find action button with key:', key);
+        return;
       }
       this.splice('_additionalActions', idx, 1);
     },
 
+    setActionButtonProp: function(key, prop, value) {
+      this.set([
+        '_additionalActions',
+        this._indexOfActionButtonWithKey(key),
+        prop,
+      ], value);
+    },
+
+    _indexOfActionButtonWithKey: function(key) {
+      for (var i = 0; i < this._additionalActions.length; i++) {
+        if (this._additionalActions[i].__key === key) {
+          return i;
+        }
+      }
+      return -1;
+    },
+
     _getRevisionActions: function() {
       return this.$.restAPI.getChangeRevisionActions(this.changeNum,
           this.patchNum);
     },
 
-    _keyCount: function(obj) {
-      return Object.keys(obj).length;
+    _actionCount: function(actionsChangeRecord, additionalActionsChangeRecord) {
+      var additionalActions = (additionalActionsChangeRecord &&
+          additionalActionsChangeRecord.base) || [];
+      return this._keyCount(actionsChangeRecord) + additionalActions.length;
     },
 
-    _actionsChanged: function(actions, revisionActions, additionalActions) {
-      this.hidden = this._keyCount(actions) === 0 &&
-          this._keyCount(revisionActions) === 0 &&
-              this._keyCount(additionalActions) === 0;
+    _keyCount: function(changeRecord) {
+      return Object.keys((changeRecord && changeRecord.base) || {}).length;
+    },
+
+    _actionsChanged: function(actionsChangeRecord, revisionActionsChangeRecord,
+        additionalActionsChangeRecord) {
+      var additionalActions = (additionalActionsChangeRecord &&
+          additionalActionsChangeRecord.base) || [];
+      this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
+          this._keyCount(revisionActionsChangeRecord) === 0 &&
+              additionalActions.length === 0;
     },
 
     _getValuesFor: function(obj) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index fe04843..462b01b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -203,8 +203,7 @@
     test('custom actions', function(done) {
       // Add a button with the same key as a server-based one to ensure
       // collisions are taken care of.
-      var key = element.addActionButton('submit', element.ActionType.REVISION,
-          'Bork!');
+      var key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
       element.addEventListener(key + '-tap', function(e) {
         assert.equal(e.detail.node.getAttribute('data-action-key'), key);
         element.removeActionButton(key);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
new file mode 100644
index 0000000..f7c337b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 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(window) {
+  'use strict';
+
+  function GrChangeActionsInterface(el) {
+    this._el = el;
+    this.RevisionActions = el.RevisionActions;
+    this.ChangeActions = el.ChangeActions;
+    this.ActionType = el.ActionType;
+  }
+
+  GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
+    if (this._el.primaryActionKeys.indexOf(key) !== -1) { return; }
+
+    this._el.push('primaryActionKeys', key);
+  };
+
+  GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
+    this._el.primaryActionKeys = this._el.primaryActionKeys.filter(function(k) {
+      return k !== key;
+    });
+  };
+
+  GrChangeActionsInterface.prototype.add = function(type, label) {
+    return this._el.addActionButton(type, label);
+  };
+
+  GrChangeActionsInterface.prototype.remove = function(key) {
+    return this._el.removeActionButton(key);
+  };
+
+  GrChangeActionsInterface.prototype.addTapListener = function(key, handler) {
+    this._el.addEventListener(key + '-tap', handler);
+  };
+
+  GrChangeActionsInterface.prototype.removeTapListener = function(key,
+      handler) {
+    this._el.removeEventListener(key + '-tap', handler);
+  };
+
+  GrChangeActionsInterface.prototype.setLabel = function(key, text) {
+    this._el.setActionButtonProp(key, 'label', text);
+  };
+
+  GrChangeActionsInterface.prototype.setEnabled = function(key, enabled) {
+    this._el.setActionButtonProp(key, 'enabled', enabled);
+  };
+
+  window.GrChangeActionsInterface = GrChangeActionsInterface;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
new file mode 100644
index 0000000..3030870
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -0,0 +1,123 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 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-change-actions-js-api</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<!--
+This must refer to the element this interface is wrapping around. Otherwise
+breaking changes to gr-change-actions won’t be noticed.
+-->
+<link rel="import" href="../../change/gr-change-actions/gr-change-actions.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-actions></gr-change-actions>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-js-api-interface tests', function() {
+    var element;
+    var changeActions;
+
+    setup(function() {
+      element = fixture('basic');
+      var plugin;
+      Gerrit.install(function(p) { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeActions = plugin.changeActions();
+    });
+
+    teardown(function() {
+      changeActions = null;
+    });
+
+    test('property existence', function() {
+      [
+        'ActionType',
+        'ChangeActions',
+        'RevisionActions',
+      ].forEach(function(p) {
+        assertArraysEqual(changeActions[p], element[p]);
+      });
+    });
+
+    // Because deepEqual doesn’t behave in Safari.
+    function assertArraysEqual(actual, expected) {
+      assert.equal(actual.length, expected.length);
+      for (var i = 0; i < actual.length; i++) {
+        assert.equal(actual[i], expected[i]);
+      }
+    }
+
+    test('add/remove primary action keys', function() {
+      element.primaryActionKeys = [];
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
+      changeActions.removePrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('baz');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, []);
+    });
+
+    test('action buttons', function(done) {
+      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      var handler = sinon.spy();
+      changeActions.addTapListener(key, handler);
+      flush(function() {
+        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.removeTapListener(key, handler);
+        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.remove(key);
+        flush(function() {
+          assert.isNull(element.$$('[data-action-key="' + key + '"]'));
+          done();
+        });
+      });
+    });
+
+    test('action button properties', function(done) {
+      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(function() {
+        var button = element.$$('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.equal(button.getAttribute('data-label'), 'Bork!');
+        assert.isFalse(button.disabled);
+        changeActions.setLabel(key, 'Yo');
+        changeActions.setEnabled(key, false);
+        flush(function() {
+          assert.equal(button.getAttribute('data-label'), 'Yo');
+          assert.isTrue(button.disabled);
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index a3b489d..a77a4ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -17,6 +17,7 @@
 
 <dom-module id="gr-js-api-interface">
   <template></template>
+  <script src="gr-change-actions-js-api.js"></script>
   <script src="gr-js-api-interface.js"></script>
   <script src="gr-public-js-api.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index c63221d..e60876c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -52,9 +52,9 @@
     return this._url.origin + '/plugins/' + this._name + (opt_path || '/');
   };
 
-  Plugin.prototype.getChangeActionsElement = function() {
-    return Plugin._sharedAPIElement.getElement(
-        Plugin._sharedAPIElement.Element.CHANGE_ACTIONS);
+  Plugin.prototype.changeActions = function() {
+    return new GrChangeActionsInterface(Plugin._sharedAPIElement.getElement(
+        Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
   };
 
   var Gerrit = window.Gerrit || {};
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 96b97cc..8757ddf 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -74,6 +74,7 @@
     'shared/gr-date-formatter/gr-date-formatter_test.html',
     'shared/gr-editable-content/gr-editable-content_test.html',
     'shared/gr-editable-label/gr-editable-label_test.html',
+    'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',