CartoDB/cartodb20

View on GitHub
lib/assets/javascripts/cartodb/models/wizard.js

Summary

Maintainability
D
2 days
Test Coverage
// form validation

var alwaysTrueValidator = function(form) { return true };

function columnExistsValidatorFor(column_name) {
  return function(form) {
    var field = form[column_name];
    return field.form.property.extra.length > 0;
  };
}
var columnExistsValidator = columnExistsValidatorFor('Column');

//
// defines a form schema, what fields contains and so on
//
cdb.admin.FormSchema = cdb.core.Model.extend({

  validators: {
    polygon: alwaysTrueValidator,
    cluster: alwaysTrueValidator,
    intensity: alwaysTrueValidator,
    bubble: columnExistsValidator,
    choropleth: columnExistsValidator,
    color: columnExistsValidator,
    category: columnExistsValidator,
    density: alwaysTrueValidator,
    torque: columnExistsValidatorFor('Time Column'),
    torque_cat: columnExistsValidatorFor('Time Column'),
    torque_heat: columnExistsValidatorFor('Time Column')
  },

  initialize: function() {
    this.table = this.get('table');
    this.unset('table');
    if(!this.table) throw new Error('table is undefined');

    // validate type
    // it should be polygon, bubble or some of the defined wizard types
    var type = this.get('type');
    if(!type) {
      throw new Error('type is undefined');
    }

    // get the default values
    var form_data = this.defaultFor(type);
    if (!form_data) {
      throw new Error('invalid type: ' + type);
    }
    // assign index to be able to compose the order
    form_data.forEach(function(v, i) { v.index = i });
    this.set(_.object(_.pluck(form_data, 'name'), form_data),  { silent: true });

    this._fillColumns();

    this.table.bind('change:schema', function() {
      var opts = {};
      if (!this.table.previous('schema')) {
        opts.silent = true;
      }
      this._fillColumns(opts);
      if (opts.silent) {
        this._previousAttributes = _.clone(this.attributes);
      }
    }, this);

  },

  toJSON: function() {
    var form_data = _.values(_.omit(this.attributes, 'type'));
    form_data.sort(function(a, b) { return a.index - b.index; });
    return form_data;
  },

  _fillColumns: function(opts) {
    var self = this;
    // lazy shallow copy
    var attrs = JSON.parse(JSON.stringify(this.attributes));
    _.each(attrs, function(field) {
      for (var k in field.form) {
        var f = field.form[k];
        if (f.columns) {
          var types = f.columns.split('|');
          var extra = [];
          if (f.extra_default) extra = f.extra_default.slice();
          for(var i in types) {
            var type = types[i];
            var columns = self.table.columnNamesByType(type);
            extra = extra.concat(
              _.without(columns, 'cartodb_id')
            )
            if (f.default_column === type) {
              var customColumns = _.without(columns, 'cartodb_id', 'created_at', 'updated_at');
              if (customColumns.length) {
                f.value = customColumns[0];
              }
            }
          }
          if (!f.value) f.value = extra[0];
          else if (!_.contains(extra, f.value)) {
            f.value = extra[0];
          }
          f.extra = extra;
        }
      }
    });
    this.set(attrs, opts);
  },

  defaultFor: function(type) {
    var form_data = cdb.admin.forms.get(type)[this.table.geomColumnTypes()[0] || 'point'];
    return form_data;
  },

  // return the default style properties
  // based on forms value
  style: function(props) {
    var default_data = {};
    _(this.attributes).each(function(field) {
      if (props && !_.contains(props, field)) return;
      _(field.form).each(function(v, k) {
        default_data[k] =  v.value;
      });
    });
    return default_data;
  },

  isValid: function(type) {
    return this.validators[type || 'polygon'](this.attributes);
  },

  // return true if this form was valid before the current change
  // this method should be only called during a change event
  wasValid: function(type) {
    return this.validators[type](this.previousAttributes());
  },

  dynamicProperties: function() {
    var props = [];
    _.each(this.attributes, function(field) {
      for (var k in field.form) {
        var f = field.form[k];
        if (f.columns) {
          props.push(field);
        }
      }
    });
    return props;
  },

  // return true is some property used to regenerate style has been changed
  changedDinamycProperty: function() {
    var changed = [];
    var d = this.dynamicProperties();
    for(var i in d) {
      if (this.changedAttributes(d[i])) {
        changed.push(d[i]);
      }
    }
    return changed;
  },

  dinamycProperty: function(c) {
    return _.keys(this.get(c.name).form)[0];
  },

  dinamycValues: function(c) {
    var v = this.get(c.name);
    var k = this.dinamycProperty(c);
    return v.form[k].extra;
  }


});

cdb.admin.WizardProperties = cdb.core.Model.extend({

  initialize: function() {
    // params
    this.table = this.get('table');
    this.unset('table');
    if(!this.table) throw new Error('table is undefined');

    this.layer = this.get('layer');
    this.unset('layer');
    if(!this.layer) throw new Error('layer is undefined');

    // stores forms for geometrys and type
    this.forms = {};
    this._savedStates = {};

    this.cartoStylesGeneration = new cdb.admin.CartoStyles(_.extend({},
      this.layer.get('wizard_properties'), {
      table: this.table
    })
    );

    if (this.attributes.properties && _.keys(this.attributes.properties).length !== 0) {
      this.properties(this.attributes);
    }
    delete this.attributes.properties;

    // bind loading and load
    this.cartoStylesGeneration.bind('load', function() { this.trigger('load'); }, this)
    this.cartoStylesGeneration.bind('loading', function() { this.trigger('loading'); }, this)

    this.table.bind('columnRename', function(newName, oldName) {
      if (this.isDisabled()) return;
      var attrs = {};
      // search for columns
      for(var k in this.attributes) {
        if(this.get(k) === oldName) {
          attrs[k] = newName;
        }
      }
      this.set(attrs);
    }, this);
    // when table schema changes regenerate styles
    // notice this not update properties, only regenerate
    // the style
    this.table.bind('change:schema', function() {
      if (!this.isDisabled() && this.table.previous('schema') !== undefined) this.cartoStylesGeneration.regenerate();
    }, this);

    this.table.bind('change:geometry_types', function() {
      if(!this.table.changedAttributes()) {
        return;
      }
      var geoTypeChanged = this.table.geometryTypeChanged();
      if(geoTypeChanged) this.trigger('change:form');
      var prev = this.table.previous('geometry_types');
      var current = this.table.geomColumnTypes();
      // wizard non initialized
      if((!prev || prev.length === 0) && !this.get('type')) {
        this.active('polygon');
        return;
      }
      if (!current || current.length === 0) {
        if (!this.table.isInSQLView()) {
          // empty table
          this.unset('type', { silent: true });
        }
        return;
      }
      if (!prev || prev.length === 0) return;
      if (geoTypeChanged) {
        this.active('polygon', {}, { persist: false });
      }
    }, this);

    this.linkLayer(this.layer);

    this.bindGenerator();

    // unbind previous form and bind the new one
    this.bind('change:type', this._updateForm);
    this.table.bind('change:geometry_types', this._updateForm, this);
    this._updateForm();

    // generator should be always filled in case sql
    // or table schema is changed
    this._fillGenerator({ silent: true });

  },

  _updateForm: function() {
    //unbind all forms
    for(var k in this.forms) {
      var forms = this.forms[k];
      for(var f in forms) {
        var form = forms[f];
        form.unbind(null, null, this);
      }
    }

    var t = this.get('type');
    if (t) {
      var f = this._form(t);
      f.bind('change', function() {
        if (!f.isValid(this.get('type'))) {
          this.active('polygon');
        }
        else if(!f.wasValid(this.get('type'))) {
          if(!this.isDisabled()) {
            // when the form had no column previously
            // that means the wizard was invalid
            this.active(this.get('type'), null, { persist: false, restore: false });
          }
        } else {
          var self = this;
          var c = f.changedDinamycProperty();
          var propertiesChanged = [];
          if(c.length) {
            _.each(c, function(form_p) {
              var k = f.dinamycProperty(form_p);
              if (self.has(k) && !_.contains(f.dinamycValues(form_p), self.get(k))) {
                propertiesChanged.push(form_p);
              }
            });
            if (propertiesChanged.length) {
              var st = f.style(propertiesChanged);
              this.set(st);
            }
          }
        }
        this.trigger('change:form');
      }, this);
    }
  },

  _form: function(type, geomType) {
    var form = this.forms[type] || (this.forms[type] = {});
    geomType = geomType || this.table.geomColumnTypes()[0] || 'point';
    if (!form[geomType]) {
      form[geomType] = new cdb.admin.FormSchema({
        table: this.table,
        type: type || 'polygon'
      });
      form[geomType].__geomType = geomType;
    }
    return form[geomType];
  },

  formData: function(type) {
    var self = this;
    var form = this._form(type);
    return form.toJSON();
  },

  defaultStyleForType: function(type) {
    return this._form(type).style();
  },

  // save current state
  saveCurrent: function(type, geom) {
    var k = type + "_" + geom;
    this._savedStates[k] = _.clone(this.attributes);
  },

  getSaved: function(type, geom) {
    var k = type + "_" + geom;
    return this._savedStates[k] || {};
  },

  // active a wizard type
  active: function(type, props, opts) {
    opts = _.defaults(opts || {}, { persist: true });

    // if the geometry is undefined the wizard can't be applied
    var currentGeom = this.table.geomColumnTypes()[0];
    if (!currentGeom) {
      return;
    }
    opts = _.defaults(opts || {}, { persist: true, restore: true });

    // previously category map was called color. this avoids
    // color wizard is enabled since it's compatible with category
    if (type === "color") type = 'category';

    // if the geometry type has changed do not allow to persist previous
    // properties. This avoids cartocss properties from different
    // geometries are mixed
    if (this.get('geometry_type') && currentGeom !== this.get('geometry_type')) {
      opts.persist = false;
    }

    // get the default props for current type and use previously saved
    // attributes to override them
    var geomForm = this.defaultStyleForType(type);
    var current = (opts.persist && type === this.get('type')) ? this.attributes: {};
    _.extend(geomForm, opts.restore ? this.getSaved(type, currentGeom): {}, current, props);
    geomForm.type = type;
    geomForm.geometry_type = currentGeom;

    // if the geometry is invalid, do not save previous attributes
    var t = this.get('type');
    var gt = this.get('geometry_type');
    if(t && gt && this._form(t, gt).isValid(t)) {
      this.saveCurrent(t, gt);
    }
    this.clear({ silent: true });
    this.cartoStylesGeneration.unset('metadata', {silent: true});
    this.cartoStylesGeneration.unset('properties', { silent: true });
    // set layer as enabled to change style
    this.enableGeneration();
    this.set(geomForm);
  },

  enableGeneration: function() {
    this.layer.set('tile_style_custom', false, { silent: true });
  },

  // the style generation can be disabled because of a custom style
  isDisabled: function() {
    return this.layer.get('tile_style_custom');
  },

  properties: function(props) {
    if (!props) return this;
    var t = props.type === 'color' ? 'category': props.type;
    var vars = _.extend(
      { type: t },
      props.properties
    );
    return this.set(vars);
  },

  _fillGenerator: function(opts) {
      opts = opts || {}
      this.cartoStylesGeneration.set({
        'properties': _.clone(this.attributes),
        'type': this.get('type')
      }, opts);
  },

  _updateGenerator: function() {
      var t = this.get('type');
      var isValid = this._form(t).isValid(t);
      this._fillGenerator({ silent: !isValid || this.isDisabled() });
  },

  bindGenerator: function() {
    // every time properties change update the generator
    this.bind('change', this._updateGenerator, this);
  },

  unbindGenerator: function() {
    this.unbind('change', this._updateGenerator, this);
  },

  toJSON: function() {
    return {
      type: this.get('type'),
      properties: _.omit(this.attributes, 'type', 'metadata')
    };
  },

  linkLayer: function(layer) {
    var self = this;
    /*
     * this is disabled because we need to improve propertiesFromStyle method
     * in order to not override properties which shouldn't be, see CDB-1566
     *
     layer.bind('change:tile_style', function() {
      if(this.isDisabled()) {
        this.unbindGenerator();
        this.set(this.propertiesFromStyle(layer.get('tile_style')));
        this.bindGenerator();
      }
    }, this);
    */

    layer.bind('change:query', function() {
      if(!this.isDisabled()) this.cartoStylesGeneration.regenerate();
    }, this);

    var changeLayerStyle = function(st, sql, layerType) {
      layerType = layerType || 'CartoDB';

      // update metadata from cartocss generation
      self.unbindGenerator();
      var meta = self.cartoStylesGeneration.get('metadata');
      if (meta) {
        self.set('metadata', meta);
      } else {
        self.unset('metadata');
      }
      self.bindGenerator();

      var attrs = {
        tile_style: st,
        type: layerType,
        tile_style_custom: false
      };

      if(sql) {
        attrs.query_wrapper = sql.replace(/__wrapped/g, '(<%= sql %>)');//"with __wrapped as (<%= sql %>) " + sql;
      } else {
        attrs.query_wrapper = null;
      }
      attrs.query_generated = attrs.query_wrapper !== null;

      // update the layer model
      if (layer.isNew() || !layer.collection) {
        layer.set(attrs);
      } else {
        layer.save(attrs);
      }
    };

    // this is the sole entry point where the cartocss is changed.
    this.cartoStylesGeneration.bind('change:style change:sql', function() {
      var st = this.cartoStylesGeneration.get('style');
      if(st) {
        changeLayerStyle(
          st,
          this.cartoStylesGeneration.get('sql'),
          this.get('layer-type')
        );
      }
    }, this);


  },

  unlinkLayer: function(layer) {
    this.unbind(null, null, layer);
    layer.unbind(null, null, this);
  },

  getEnabledWizards: function() {
    var _enableMap = {
      'point': ['polygon', 'cluster', 'choropleth', 'bubble', 'density', 'category', 'intensity', 'torque', 'torque_cat', 'torque_heat'],
      'line':['polygon', 'choropleth', 'category', 'bubble'],
      'polygon': ['polygon', 'choropleth', 'category', 'bubble']
    };
    return _enableMap[this.table.geomColumnTypes()[0] || 'point'];
  },

  //MOVE to the model
  propertiesFromStyle: function(cartocss) {
    var parser = new cdb.admin.CartoParser();
    var parsed = parser.parse(cartocss);
    if (!parsed) return {};
    var rules = parsed.getDefaultRules();
    if(parser.errors().length) return {};
    var props = {};
    var t = this._getTypeFromCSS(cartocss);
    var valid_attrs =_.uniq(_.keys(this.attributes).concat(_.keys(this._form(t).style())));
    if (rules) {
      for(var p in valid_attrs) {
        var prop = valid_attrs[p];
        var rule = rules[prop];
        if (rule) {
          rule = rule.ev();
          if (!carto.tree.Reference.validValue(parser.parse_env, rule.name, rule.value)) {
            return {};
          }
          var v = rule.value.ev(this.parse_env);
          if (v.is === 'color') {
            v = v.toString();
          } else if (v.is === 'uri') {
            v = 'url(' + v.toString() + ')';
          } else {
            v = v.value;
          }
          props[prop] = v;
        }
      }
      if("image-filters" in props && !props["image-filters"]){
        props["image-filters"] = rules["image-filters"].value.value[0].value[0]
      }
      return props;
    }
    return {};
  },

  _getTypeFromCSS: function(css) {
    if (css.indexOf("colorize-alpha") > -1) {
      return "torque_heat";
    }
    else if (css.indexOf("torque-time-attribute") > -1) {
      return "torque";
    }
    else {
      return this.get('type');
    } 
  },

  // returns true if current wizard supports user
  // interaction
  supportsInteractivity: function() {
    var t = this.get('type');
    if (_.contains(['torque', 'cluster', 'density', 'torque_cat'], t)) {
      return false;
    }
    return true;
  }

});