Search code examples
javascriptpolymerpolymer-1.0observers

Polymer 1.x: Observers


Ultimately, I want to select individual states from this geochart. But this question is limited to getting the observer labeled _computeData to fire in response to mutating the array of selected states.

Reproduce the problem with the following steps:

  1. Open this jsBin.
  2. Clear the console.
  3. Select the state of Texas.

Note the console reads:

You selected: Colorado,South Dakota,Texas

which is expected per this line:

console.log('You selected: ' + this.selected); // Logs properly

However, I expect the console to also read:

selected

per this line:

_computeData: function() {
  console.log('selected'); // Does not log properly; function not called?
  ...

which should be called by the following set of observers.

http://jsbin.com/wuqugigeha/1/edit?html,console,output
...
observers: [
  '_computeData(items.*, selected.*)',
  '_dataChanged(data.*)',
],
...

Question

What's going on here? Why isn't the observer calling the _computeData method? What can be done to get the method to fire after mutating the selected array?

http://jsbin.com/wuqugigeha/1/edit?html,console,output
<!DOCTYPE html>

<head>
  <meta charset="utf-8">
  <base href="https://polygit.org/components/">
  <script src="webcomponentsjs/webcomponents-lite.min.js"></script>
  <link href="polymer/polymer.html" rel="import">
  <link href="google-chart/google-chart.html" rel="import"> </head>

<body>
  <dom-module id="x-element"> <template>
      <style>
        google-chart {
          width: 100%;
        }
      </style>
    <br><br><br><br>
    <button on-tap="_show">Show Values</button>
    <button on-tap="clearAll">Clear All</button>
    <button on-tap="selectAll">Select All</button>
      <div>[[selected]]</div>
      <google-chart
        id="geochart"
        type="geo"
        options="[[options]]"
        data="[[data]]"
        xon-google-chart-select="_onGoogleChartSelect">
      </google-chart>
    </template>
    <script>
      (function() {
        Polymer({
          is: 'x-element',
          properties: {
            items: {
              type: Array,
              value: function() {
                return [ 'Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', 'Colorado', 'Connecticut', 'Delaware', 'Florida', 'Georgia', 'Hawaii', 'Idaho', 'Illinois', 'Indiana', 'Iowa', 'Kansas', 'Kentucky', 'Louisiana', 'Maine', 'Maryland', 'Massachusetts', 'Michigan', 'Minnesota', 'Mississippi', 'Missouri', 'Montana', 'Nebraska', 'Nevada', 'New Hampshire', 'New Jersey', 'New Mexico', 'New York', 'North Carolina', 'North Dakota', 'Ohio', 'Oklahoma', 'Oregon', 'Pennsylvania', 'Rhode Island', 'South Carolina', 'South Dakota', 'Tennessee', 'Texas', 'Utah', 'Vermont', 'Virginia', 'Washington', 'West Virginia', 'Wisconsin', 'Wyoming', ].sort();
              },
            },
            color: {
              type: String, // '#455A64'
              value: function() {
                return 'blue';
              }
            },
            options: {
              type: Object,
              notify: true,
              reflectToAttribute: true,
              computed: '_computeOptions(color)',
            },
            selected: {
              type: Array,
              notify: true,
              reflectToAttribute: true,
              value: function() {
                return [];
              },
              //observer: '_computeData', // Unsuccessfully tried this
            },
            data: {
              type: Array,
              notify: true,
              reflectToAttribute: true,
              //computed: '_computeData(items.*, selected.*)', // Unsuccessfully tried this
            },
          },
          observers: [
            '_computeData(items.*, selected.*)',
            '_dataChanged(data.*)',
          ],
          // Bind select event listener to chart
          ready: function() {
            var _this = this;
            this.$.geochart.addEventListener('google-chart-select', function(e) {
              this._onGoogleChartSelect(e);
            }.bind(_this));
          },
          _computeOptions: function() {
            return {
              region: 'US',
              displayMode: 'regions',
              resolution: 'provinces',
              legend: 'none',
              defaultColor: 'white',
              colorAxis: {
                colors: ['#E0E0E0', this.color],
                minValue: 0,  
                maxValue: 1,
              }
            }
          },    
          // On select event, compute 'selected'
          _onGoogleChartSelect: function(e) {
            var string = e.path[0].textContent.split('Select')[0].trim(), // e.g. 'Ohio'
                selected = this.selected, // Array of selected items
                index = selected.indexOf(string);
            // If 'string' is not in 'selected' array, add it; else delete it
            if (index === -1) {
              selected.push(string);
              selected.sort();
            } else {
              selected.splice(index, 1);
            }
            this.set('selected', selected);
            console.log('You selected: ' + this.selected); // Logs properly
            // Next step should be '_computeData' per observers
          },
          // After 'items' populates or 'selected' changes, compute 'data'
          _computeData: function() {
            console.log('selected'); // Does not log properly; function not called?
            var data = [],
                items = this.items,
                selected = this.selected,
                i = items.length;
            while (i--) {
              data.unshift([items[i], selected.indexOf(items[i]) > -1 ? 1 : 0]);
            }
            data.unshift(['State', 'Select']);
            this.set('data', data);
          },
          // After 'data' changes, redraw chart
          // Add delay to avoid 'google not defined' error
          _dataChanged: function() {
            var _this = this;
            setTimeout(function() {
              _this._drawChart();
            }.bind(_this), 100)
          },
          // After delay, draw chart
          _drawChart: function() {
            var data = this.data,
                dataTable = this.$.geochart._createDataTable(data);
            console.log(dataTable);
            this.$.geochart._chartObject.draw(dataTable, this.options);
          },
          clearAll: function() {
            this.set('selected', []);
          },
          selectAll: function() {
            this.set('selected', this.items);
          },
          _show: function() {
            console.log('items: ' + this.items);
            console.log('selected: ' + this.selected);
            console.log('data: ' + this.data);
          },
        });
      })();
    </script>
  </dom-module>
  <x-element color="red" selected='["Colorado", "South Dakota"]'></x-element>
</body>

</html>

Solution

  • Your problem is here:

    if (index === -1) {
        selected.push(string);
        selected.sort();
    } else {
        selected.splice(index, 1);
    }
    this.set('selected', selected);
    

    Polymer's data-handling methods like set allow you to give Polymer specific information about how your data is changing, allowing Polymer to make very fast DOM updates.

    In this case, you are doing work where Polymer cannot see it (i.e. the array manipulations), and then asking set to figure out what happened. However, when you call this.set('selected', selected);, Polymer sees that the identity of selected hasn't changed (that is, it's the same Array object as before) and it simply stops processing. (Fwiw, this is a common problem, so we are considering a modification that will go ahead and examine the array anyway.)

    The solution is two-fold:

    1) In the case where you are sorting the array, create a fresh array reference to for set via slice() :

    if (index === -1) {
        selected.push(string);
        selected.sort();
        this.set('selected', selected.slice());
    

    2) In the case where you are simply splicing, use the splice helper function :

    } else {
        this.splice('selected', index, 1);
    }
    

    Ideally you avoid sorting your array, then you can use this.push directly.

    Note: with these changes _computeData is being called, but now it's being called way too many times. Partly this is due to observing selected.* which will fire for selected, selected.length, and selected.splices. Observing selected.length instead of selected.* might help.

    UPDATE

    There were three other major problems with your example:

    1. data is bound to to the google-chart (i.e. data="[[data]]") so the chart will redraw itself when data changes and we can remove _drawChart completely.
    2. _computeData(items.*, selected.*) is too aggressive, as selected.* will fire for changes in 'selected.length', 'selected.splices', and selected. Instead use _computeData(items, selected.length).
    3. google-chart itself appears to be buggy. In particular, it's own drawChart is not set up to be properly re-entrant. The most obvious problem is that every time the chart draws, it adds an additional selection listener (which causes multiplying chart-select events to fire on your application). I would appreciate it if you would file a bug on google-chart and link back to this SO. :)

    Here is a modified version where I've monkey patched google-chart.drawChart, fixed the other two major problems, and made a variety of smaller repairs.

    <!DOCTYPE html>
    
    <head>
      <meta charset="utf-8">
      <base href="https://polygit.org/components/">
      <script src="webcomponentsjs/webcomponents-lite.min.js"></script>
      <link href="polymer/polymer.html" rel="import">
      <link href="google-chart/google-chart.html" rel="import"> </head>
    
    <body>
      <dom-module id="x-element"> <template>
          <style>
            google-chart {
              width: 100%;
            }
          </style>
        <br><br><br><br>
        <button on-tap="_show">Show Values</button>
        <button on-tap="clearAll">Clear All</button>
        <button on-tap="selectAll">Select All</button>
          <div>[[selected]]</div>
          <google-chart
            id="geochart"
            type="geo"
            options="[[options]]"
            data="[[data]]"
    		on-google-chart-select="_onGoogleChartSelect">
          </google-chart>
        </template>
        <script>
          (function() {
            
            // monkey-patching google-chart
            var gcp = Object.getPrototypeOf(document.createElement('google-chart'));
            gcp.drawChart = function() {
              if (this._canDraw) {
                if (!this.options) {
                  this.options = {};
                }
                if (!this._chartObject) {
                  var chartClass = this._chartTypes[this.type];
                  if (chartClass) {
                    this._chartObject = new chartClass(this.$.chartdiv);
                    google.visualization.events.addOneTimeListener(this._chartObject,
                        'ready', function() {
                            this.fire('google-chart-render');
                        }.bind(this));
    
                    google.visualization.events.addListener(this._chartObject,
                        'select', function() {
                            this.selection = this._chartObject.getSelection();
                            this.fire('google-chart-select', { selection: this.selection });
                        }.bind(this));
                    if (this._chartObject.setSelection){
                      this._chartObject.setSelection(this.selection);
                    }
                  }
                }
                if (this._chartObject) {
                  this._chartObject.draw(this._dataTable, this.options);
                } else {
                  this.$.chartdiv.innerHTML = 'Undefined chart type';
                }
              }
            };
            
            Polymer({
              is: 'x-element',
              properties: {
                items: {
                  type: Array,
                  value: function() {
                    return [ 'Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', 'Colorado', 'Connecticut', 'Delaware', 'Florida', 'Georgia', 'Hawaii', 'Idaho', 'Illinois', 'Indiana', 'Iowa', 'Kansas', 'Kentucky', 'Louisiana', 'Maine', 'Maryland', 'Massachusetts', 'Michigan', 'Minnesota', 'Mississippi', 'Missouri', 'Montana', 'Nebraska', 'Nevada', 'New Hampshire', 'New Jersey', 'New Mexico', 'New York', 'North Carolina', 'North Dakota', 'Ohio', 'Oklahoma', 'Oregon', 'Pennsylvania', 'Rhode Island', 'South Carolina', 'South Dakota', 'Tennessee', 'Texas', 'Utah', 'Vermont', 'Virginia', 'Washington', 'West Virginia', 'Wisconsin', 'Wyoming', ].sort();
                  },
                },
                color: {
                  type: String, // '#455A64'
                  value: 'blue'
                },
                options: {
                  type: Object,
                  computed: '_computeOptions(color)',
                },
                selected: {
                  type: Array,
                  value: function() {
                    return [];
                  }
                },
                data: {
                  type: Array,
                  computed: '_computeData(items, selected.length)'
                },
              },
              _computeOptions: function() {
                return {
                  region: 'US',
                  displayMode: 'regions',
                  resolution: 'provinces',
                  legend: 'none',
                  defaultColor: 'white',
                  colorAxis: {
                    colors: ['#E0E0E0', this.color],
                    minValue: 0,  
                    maxValue: 1,
                  }
                }
              },	
    		  // On select event, compute 'selected'
              _onGoogleChartSelect: function(e) {
                console.log('_onGoogleChartSelect: ', e.detail)
                var string = e.path[0].textContent.split('Select')[0].trim(), // e.g. 'Ohio'
                    selected = this.selected, // Array of selected items
                    index = selected.indexOf(string);
                // If 'string' is not in 'selected' array, add it; else delete it
                if (index === -1) {
                  this.push('selected', string);
                } else {
                  this.splice('selected', index, 1);
                }
                // Next step should be '_computeData' per observers
                console.log('_select:', this.selected);
              },
              // After 'items' populates or 'selected' changes, compute 'data'
              _computeData: function(items, selectedInfo) {
                console.log('_computeData');
                var data = [],
                    selected = this.selected,
                    i = items.length;
                while (i--) {
                  data.unshift([items[i], selected.indexOf(items[i]) > -1 ? 1 : 0]);
                }
                data.unshift(['State', 'Select']);
                return data;
              },
              clearAll: function() {
                this.set('selected', []);
              },
              selectAll: function() {
                this.set('selected', this.items);
              },
              _show: function() {
                console.log('items: ' + this.items);
                console.log('selected: ' + this.selected);
                console.log('data: ' + this.data);
              },
            });
          })();
        </script>
      </dom-module>
      <x-element color="red" selected='["Colorado", "South Dakota"]'></x-element>
    </body>
    
    </html>

    HTH


    Random extra stuff:

    var _this = this;
    setTimeout(function() {
        _this._drawChart();
    }.bind(_this), 100)
    

    You need to either capture the value of this (_this) or use bind, but it doesn't make sense to do both.

    setTimeout(function() {
        this._drawChart();
    }.bind(this), 100)
    

    ... is enough.