CartoDB/cartodb20

View on GitHub
lib/assets/javascripts/cartodb/table/menu_modules/carto_editor.js

Summary

Maintainability
F
1 wk
Test Coverage

  /**
   *  Carto CSS editor module
   *
   *  new cdb.admin.mod.CartoCSSEditor({
   *    model: dataLayer
   *    table: table
   *  })
   *
   */


cdb.admin.mod = cdb.admin.mod || {};
cdb.admin.mod.CartoCSSEditor = cdb.admin.Module.extend({

  _TEXTS: {
    tip: '<strong>Ctrl + SPACE</strong> to autocomplete. <strong><%- key %> + S</strong> to apply your styles.'
  },

  _ACTION: {
    type: 'show',
    width: 600
  },

  buttonClass: 'cartocss_mod',
  type: 'tool',

  events: {
    'click .actions button':  'applyStyle',
    'click .actions a.next':  '_do',
    'click .actions a.back':  '_undo',
    'click .doc_info':        '_showDoc'
  },

  initialize: function() {
    _.bindAll(this, '_onKeyUpEditor');

    this.template = this.getTemplate('table/menu_modules/views/carto_editor');

    this.model.bind('change',      this._updateStyle, this);
    this.add_related_model(this.model);
    this.add_related_model(this.model.table);

    // Set query position from history array and last sql applied
    var history   = this.model.get('tile_style_history')
      , position  = this.model.tile_style_history_position
      , style     = this.model.get('tile_style');

    // Model doesn't persist last change, let's add in the history
    if (style && style != "" && history && _.indexOf(history, style) === -1) {
      history.push(style);
      this.model.set({ "tile_style_history": history }, { silent:true });
    }

    // Get history position
    this.model.tile_style_history_position =
      _.indexOf(history, style) !== -1
      ? _.indexOf(history, style)
      : 0;

    this.model.bind('parseError', this._showErrorFromServer, this);
    /*
    this.model.bind('tileError', this._renderError, this);
    */
    this.model.bind('tileOk', this._checkLocalErrors, this);
    this.model.table.bind('change:schema', this._checkLocalErrors, this);

    //this.buildAutocomplete();
    this._initBinds();

    cdb.god.bind('end_show', this.activated, this)
    this.add_related_model(cdb.god);
  },

  /** builds autocomplete from cartcss reference */
  buildAutocomplete: function() {
    this.autocomplete = [];
    if (typeof(window._mapnik_reference_latest) !== 'undefined') {
      var symbolizers = _mapnik_reference_latest.symbolizers;
      for (var s in symbolizers) {
        var sym = symbolizers[s];
        for (var p in sym) {
          var css = sym[p].css;
          if (css && css.length) {
            this.autocomplete.push(css);
          }
        }
      }
    }
  },

  activated: function() {
    if(this.codeEditor) {
      this.codeEditor.refresh();
      this.codeEditor.focus();
      this._adjustCodeEditorSize();
    }
  },

  render: function() {
    var self = this;
    this.clearSubViews();

    this.$el.append(this.template({}));

    this._initHelp();
    this._initEditor();

    this._updateStyle();
    this._adjustCodeEditorSize();

    return this;
  },

  _initBinds: function() {
    // Codemirror extrakey
    // Add save keymap
    // PC & LINUX -> Ctrl + s
    // MAC        -> Cmd + s
    var ua      = navigator.userAgent.toLowerCase()
      , so      = "rest"
      , keymap  = "ctrl+s"
      , self    = this;

    if (/mac os/.test(ua)) {
      keymap = "meta+s";
      so = "mac";
    }

    this.$el.bind('keydown', keymap, function(ev) {
      if (((so=="mac" && ev.metaKey) || (so=="rest" && ev.ctrlKey)) && ev.keyCode == 83 ) {
        ev.preventDefault();
        self.applyStyle();
      }
    });
  },

  _initEditor: function() {
    var self = this;
    this.codeEditor = CodeMirror.fromTextArea(this.$('textarea')[0], {
      mode: "text/x-carto",
      tabMode: "indent",
      matchBrackets: true,
      lineNumbers: true,
      lineWrapping: true,
      onKeyEvent: this._onKeyUpEditor,
      extraKeys: {
        "Ctrl-Space": function(cm) { self._showAutocomplete(cm) }
      }
    });

    var color_picker = new cdb.admin.CodemirrorColorPicker({
      editor: this.codeEditor,
      model:  this.model
    });
    color_picker.bind('colorChosen', this.applyStyle, this);
    this.addView(color_picker);

    // Add tooltip for undo/redo buttons
    this.$("a.next, a.back").tipsy({
      gravity: "s",
      fade: true
    });
  },

  _initHelp: function() {
    var so = "rest";
    var ua = navigator.userAgent.toLowerCase();

    if (/mac os/.test(ua)) {
      so = "mac";
    }

    var help = new cdb.admin.mod.HTMLEditorHelp({
      localStorageKey: this._STORAGE_NAMESPACE + this.model.table.get('id'),
      text: _.template(this._TEXTS.tip)({ key: (so == "mac") ? "CMD" : "Ctrl" })
    }).bind("hide show", this._adjustCodeEditorSize, this);
    this.$el.append(help.render().$el);
    this.addView(help);
  },


  _showAutocomplete: function(cm) {
    CodeMirror.showHint(cm, CodeMirror.hint['custom-list-with-type'], {
      completeSingle: false,
      list: _.union( this._getTableName(), this._getSQLColumns())
    });
  },

  _getTableName: function() {
    return [ [ this.model.table.get('name'), "T" ] ]
  },

  _getSQLColumns: function() {
    return _.map(
      this.model.table.get('schema'),
      function(pair) {
        // Column name and type
        return [pair[0], "C"]
      });
  },

  _onKeyUpEditor: function(cm, e) {
    var code = (e.keyCode ? e.keyCode : e.which);

    if (e.type == "keyup" && code != 27 ) {
      var self = this;

      if (this.autocomplete_timeout) clearTimeout(this.autocomplete_timeout);

      this.autocomplete_timeout = setTimeout(function() {
        var cur = cm.getCursor();
        var str = cm.getTokenAt(cur).string;
        var schema = self.model.table.get('schema');

        if (schema && str.length > 2) {
          var arr = _.union(self.model.table.get('schema'), self._getTableName());
          var list = _.compact(_.map(arr, function(pair) {
            if (pair[0].search(str) != -1)
              return pair[0];
            return null;
          }));

          if (!cm.state.completionActive && str.length > 2 && list.length > 0) {
            self._showAutocomplete(cm)
          }
        }

      }, 150);
    }
  },

  /** hack used to format the old styles transformed to cartodb 2.0*/
  formatStyle: function(s) {
    try {
      if (s && s.length) {
        s = s.replace(/{/g,'{\n')
            .replace(/}/g,'}\n')
            .replace(/;/g,';\n')
        var t = s.split('\n');
        var lines = [];
        var c = 0;
        for(var i = 0; i < t.length; ++i) {
          lines.push(c);
          if (t[i].indexOf('{')  != -1) {
            ++c;
          }
          if (t[i].indexOf('}')  != -1) {
            --c;
          }
        }
        var r = [];
        for(var i = 0; i < t.length; ++i) {
          var spaces = '';
          if(t[i].indexOf('}') >= 0) lines[i]-=1;
          for(var j = 0; j < lines[i]; ++j) {
            spaces = spaces + '  ';
          }
          r.push(spaces + t[i]);
        }
        return '/** this cartoCSS has been processed in order to be compatible with the new cartodb 2.0 */\n\n' + r.join('\n');
      }
    } catch(e) {
    }
    return s;
  },

  _updateStyle: function(){
    var st        = this.model.get('tile_style')
      , editor_st = this.codeEditor ? this.codeEditor.getValue() : null;


      if(this.codeEditor && st && st != editor_st) {
        if(st.indexOf('\n') === -1) {
          st = this.formatStyle(st);
        }
        this.codeEditor.setValue(st);
        this.codeEditor.refresh();
      }

    // If model is using history, check buttons
    if (this.model.get('tile_style_history'))
      this._checkDoButtons();
  },

  /**
   * gets an array of parse errors from windshaft
   * and returns an array of {line:1, error: 'string'] with user friendly
   * strings. Parses errors in format:
   *
   *  'style.mss:7:2 Invalid code: asdasdasda'
   */
  _parseError: function(errors) {
    var parsedErrors = [];
    for(var i in errors) {
      var err = errors[i];
      if(err) {
        if (err.length > 0) {
          var g = err.match(/.*:(\d+):(\d+)\s*(.*)/);
          if(g) {
            parsedErrors.push({
              line: parseInt(g[1], 10),
              message: g[3]
            });
          } else {
            parsedErrors.push({
              line: null,
              message: err
            })
          }
        } else if(err.line) {
          parsedErrors.push(err);
        }
      }
    }
    // sort by line
    parsedErrors.sort(function(a, b) { return a.line - b.line; });
    parsedErrors = _.uniq(parsedErrors, true, function(a) { return a.line + a.message; });
    return parsedErrors;
  },

  _showErrorFromServer: function(err) {
    this._showError(this._parseError(err));
  },

  _showError: function(err) {
    var parsedErrors = err;
    if(parsedErrors.length > 0) {
      var errors = _(parsedErrors).map(function(e) {
        if(e.line) {
          return "line " + e.line + ": " + e.message;
        }
        return e.error || e.message;
      })
      this._renderError(errors.join('</br>'));
    }
  },

  _renderError: function(errors) {
    this.trigger('hasErrors');

    // Get actions block height
    var actions_h = this.$('.actions').outerHeight();

    // Add error text
    this.$('.info')
      .addClass('error')
      .html("<p>" + errors + "</p>")
      // If layer is not visible, we need to move error message
      .css({
        bottom: actions_h + (!this.model.get('visible') ? 57 : 0)
      })
      .show();

    this._adjustCodeEditorSize();
  },

  _adjustCodeEditorSize: function() {
    // Fit editor with the error
    var info_h = this.$('.info').is(':visible') ? this.$('.info').outerHeight() : 0;
    var help_h = this.$('.help-tip').is(':visible') ? 36 : 0 ;
    // If layer is not visible, we need to take into account
    var vis_msg_h = !this.model.get('visible') ? 57 : 0 ;

    this.$('.CodeMirror-wrap').css({
      bottom: info_h + vis_msg_h + 80, /* the space we need to show the action buttons */
      top: help_h
    });
  },

  _checkLocalErrors: function() {
    var style = this.model.get('tile_style');
    var cartoParser = new cdb.admin.CartoParser(style);
    if(cartoParser.errors().length) {
      this._showError(this._parseError(cartoParser.errors()));
    } else {
      // check variables used
      var err = this.checkVariables(cartoParser.variablesUsed());
      if(err.length) {
        this._showError(err);
        return;
      }
    }
    this._clearErrors();
  },

  _clearErrors: function() {
    this.trigger('clearError');

    // Hide info
    this.$('.info')
      .html('')
      .removeClass('error')
      .hide();

    this._adjustCodeEditorSize();
  },

  _do: function(e) {
    e.preventDefault();
    var newCarto = this.model.redoHistory('tile_style');
    if(this.codeEditor) this.codeEditor.setValue(newCarto);
    this._checkDoButtons();
    return false;
  },

  _undo: function(e) {
    e.preventDefault();
    var newCarto = this.model.undoHistory('tile_style');
    if(this.codeEditor) this.codeEditor.setValue(newCarto);
    this._checkDoButtons();
    return false;
  },

  /**
   * checks variabels used in cartocss are in the schem
   */
  checkVariables: function(vars) {
    var columns = this.model.table.columnNames();
    var err = [];
    for(var i in vars) {
      if(!_.contains(columns, vars[i])) {
        err.push({
          error: "sql/table must contain " + vars[i] + " variable"
        });
      }
    }
    return err;
  },

  applyStyle: function() {
    this._clearErrors();
    var style = this.codeEditor.getValue();
    var cartoParser = new cdb.admin.CartoParser(style);
    if(cartoParser.errors().length) {
      var errors = this._parseError(cartoParser.errors());
      if(errors) this._showError(errors);
    } else {
      // check variables used
      var err = this.checkVariables(cartoParser.variablesUsed());
      if(err.length) {
        this._showError(err)
        return;
      }
      this.model.addToHistory('tile_style', style);
      //TODO: check if the style has been changed
      this.model.save({
        tile_style: style,
        tile_style_custom: true
      });

      // we save the new applied query on the history array
      this.trigger('applyStyle', style);
    }

    // Event tracking "Applied CartoCSS style manually"
    cdb.god.trigger('metrics', 'cartocss_manually', {
      email: window.user_data.email
    });
  },

  /**
   * Check if the editor is different from the saved value
   * @return {Boolean}
   */
  hasChanges: function() {
    return this.model.get('tile_style') != this.codeEditor.getValue();
  },

  _checkDoButtons: function() {
      // Redo
      if (!this.model.isHistoryAtLastPosition('tile_style')) {
        this.$el.find('a.next').removeClass("disabled")
      } else {
        this.$el.find('a.next').addClass("disabled")
      }
      // Undo
      if (!this.model.isHistoryAtFirstPosition('tile_style')) {
        this.$el.find('a.back').removeClass("disabled")
      } else {
        this.$el.find('a.back').addClass("disabled")
      }
  },

  _showDoc: function(ev) {
    ev.preventDefault();
    cdb.editor.ViewFactory.createDialogByTemplate('common/dialogs/help/carto_css').appendToBody();
  }
});