Low-level helper plugin API for data binding

Utility wrapper for tracking Polymer element properties updates.

Usage example:

``` js
Gerrit.install(plugin => {
  plugin.getDomHook('change-view').onAttached(element => {
    if (!element.content) { return; }
    plugin.attributeHelper(element.content)
      .get('change')
      .then(change => {
        // Is executed once on switching to change view.
      });
    });

  plugin.getDomHook('reply-text').onAttached(element => {
    if (!element.content) { return; }
    plugin.attributeHelper(element.content)
      .bind('text', replyText => {
        // Is called every time reply text changes.
      });
    });

  });
```

Change-Id: Ia95364df58489f71ea1fd591a160b73ac1d60e96
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
new file mode 100644
index 0000000..c495c94
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
@@ -0,0 +1,21 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-attribute-helper">
+  <script src="gr-attribute-helper.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
new file mode 100644
index 0000000..301c12e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
@@ -0,0 +1,87 @@
+// 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.
+(function(window) {
+  'use strict';
+
+  function GrAttributeHelper(element) {
+    this.element = element;
+    this._promises = {};
+  }
+
+  GrAttributeHelper.prototype._getChangedEventName = function(name) {
+    return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
+  };
+
+  /**
+   * Returns true if the property is defined on wrapped element.
+   * @param {string} name
+   * @return {boolean}
+   */
+  GrAttributeHelper.prototype._elementHasProperty = function(name) {
+    return this.element[name] !== undefined;
+  };
+
+  GrAttributeHelper.prototype._reportValue = function(callback, value) {
+    try {
+      callback(value);
+    } catch (e) {
+      console.info(e);
+    }
+  };
+
+  /**
+   * Binds callback to property updates.
+   *
+   * @param {string} name Property name.
+   * @param {function(?)} callback
+   * @return {function()} Unbind function.
+   */
+  GrAttributeHelper.prototype.bind = function(name, callback) {
+    const attributeChangedEventName = this._getChangedEventName(name);
+    const changedHandler = e => this._reportValue(callback, e.detail.value);
+    const unbind = () => this.element.removeEventListener(
+        attributeChangedEventName, changedHandler);
+    this.element.addEventListener(
+        attributeChangedEventName, changedHandler);
+    if (this._elementHasProperty(name)) {
+      this._reportValue(callback, this.element[name]);
+    }
+    return unbind;
+  };
+
+  /**
+   * Get value of the property from wrapped object. Waits for the property
+   * to be initialized if it isn't defined.
+   *
+   * @param {string} name Property name.
+   * @return {!Promise<?>}
+   */
+  GrAttributeHelper.prototype.get = function(name) {
+    if (this._elementHasProperty(name)) {
+      return Promise.resolve(this.element[name]);
+    }
+    if (!this._promises[name]) {
+      let resolve;
+      const promise = new Promise(r => resolve = r);
+      const unbind = this.bind(name, value => {
+        resolve(value);
+        unbind();
+      });
+      this._promises[name] = promise;
+    }
+    return this._promises[name];
+  };
+
+  window.GrAttributeHelper = GrAttributeHelper;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
new file mode 100644
index 0000000..5dababe
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-attribute-helper</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-attribute-helper.html"/>
+
+<script>void(0);</script>
+
+<dom-element id="some-element">
+  <script>
+    Polymer({
+      is: 'some-element',
+      properties: {
+        fooBar: {
+          type: Object,
+          notify: true,
+        },
+      },
+    });
+  </script>
+</dom-element>
+
+<test-fixture id="basic">
+  <template>
+    <some-element></some-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-attribute-helper tests', () => {
+    let element;
+    let instance;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      instance = new GrAttributeHelper(element);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('resolved on value change from undefined', () => {
+      const promise = instance.get('fooBar').then(value => {
+        assert.equal(value, 'foo! bar!');
+      });
+      element.fooBar = 'foo! bar!';
+      return promise;
+    });
+
+    test('resolves to current attribute value', () => {
+      element.fooBar = 'foo-foo-bar';
+      const promise = instance.get('fooBar').then(value => {
+        assert.equal(value, 'foo-foo-bar');
+      });
+      element.fooBar = 'no bar';
+      return promise;
+    });
+
+    test('bind', () => {
+      const stub = sandbox.stub();
+      element.fooBar = 'bar foo';
+      const unbind = instance.bind('fooBar', stub);
+      element.fooBar = 'partridge in a foo tree';
+      element.fooBar = 'five gold bars';
+      assert.equal(stub.callCount, 3);
+      assert.deepEqual(stub.args[0], ['bar foo']);
+      assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
+      assert.deepEqual(stub.args[2], ['five gold bars']);
+      stub.reset();
+      unbind();
+      instance.fooBar = 'ladies dancing';
+      assert.isFalse(stub.called);
+    });
+  });
+</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 f73f731..f6e2b64 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 @@
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../plugins/gr-attribute-helper/gr-attribute-helper.html">
 <link rel="import" href="../../plugins/gr-dom-hooks/gr-dom-hooks.html">
 <link rel="import" href="../../plugins/gr-theme-api/gr-theme-api.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index 1236ca4..ca0f372 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -318,6 +318,10 @@
       }
     });
 
+    test('attributeHelper', () => {
+      assert.isOk(plugin.attributeHelper());
+    });
+
     suite('test plugin with base url', () => {
       setup(() => {
         sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
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 97b67ae..a631c2f 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
@@ -158,6 +158,10 @@
     return new GrThemeApi(this);
   };
 
+  Plugin.prototype.attributeHelper = function(element) {
+    return new GrAttributeHelper(element);
+  };
+
   Plugin.prototype.getDomHook = function(endpointName, opt_options) {
     const hook = this._domHooks.getDomHook(endpointName);
     const moduleName = hook.getModuleName();
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index e503812..ed898f9 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -92,6 +92,7 @@
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
     'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
+    'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
     'plugins/gr-external-style/gr-external-style_test.html',
     'plugins/gr-plugin-host/gr-plugin-host_test.html',
     'settings/gr-account-info/gr-account-info_test.html',