CartoDB/cartodb20

View on GitHub
lib/assets/javascripts/dashboard/views/public-dataset/table-view/table-view.js

Summary

Maintainability
F
6 days
Test Coverage
const $ = require('jquery');
const _ = require('underscore');
const CoreView = require('backbone/core-view');
const Table = require('dashboard/components/table/table');
const templateSQLViewNotice = require('./sql-view-notice.tpl');
const templateSQLLoading = require('./sql-loading.tpl');
const templateEmptySQL = require('./empty-sql.tpl');
const templateTablePaginationLoaders = require('./table-pagination-loaders.tpl');
const RowView = require('./row-view');
const checkAndBuildOpts = require('builder/helpers/required-opts');

const REQUIRED_OPTS = [
  'configModel'
];

module.exports = Table.extend({

  classLabel: 'cdb.admin.TableView',

  events: CoreView.extendEvents({
    'click .clearview': '_clearView',
    'click .sqlview .export_query': '_tableFromQuery',
    'click .noRows': 'addEmptyRow'
  }),

  rowView: RowView,

  initialize: function (opts) {
    checkAndBuildOpts(opts, REQUIRED_OPTS, this);
    Table.prototype.initialize.call(this);
    this.options.row_header = true;
    this.globalError = this.options.globalError;
    this.vis = this.options.vis;
    this.user = this.options.user;
    this._editorsOpened = null;

    this.initializeBindings();

    this.initPaginationAndScroll();
  },

  /**
   * Append all the bindings needed for this view
   * @return undefined
   */
  initializeBindings: function () {
    _.bindAll(this, 'render', 'rowSaving', 'addEmptyRow',
      '_checkEmptyTable', '_forceScroll', '_scrollMagic',
      'rowChanged', 'rowSynched', '_startPagination', '_finishPagination',
      'rowFailed', 'rowDestroyed', 'emptyTable');

    this.model.data().bind('newPage', this.newPage, this);

    // this.model.data().bind('loadingRows', this._startPagination);
    this.model.data().bind('endLoadingRows', this._finishPagination);

    this.bind('cellDblClick', this._editCell, this);
    this.bind('createRow', () => {
      this._checkEmptyTable();
    });

    this.model.bind('change:dataSource', this._onSQLView, this);
    // when model changes the header is re rendered so the notice should be added
    // this.model.bind('change', this._onSQLView, this);
    this.model.bind('dataLoaded', () => {
      // this._checkEmptyTable();
      this._forceScroll();
    }, this);

    this.model.bind('change:permission', this._checkEmptyTable, this);

    this.model.bind('change:isSync', this._swicthEnabled, this);
    this._swicthEnabled();

    // Geocoder binding
    this.options.geocoder.bind('geocodingComplete geocodingError geocodingCanceled', function () {
      this.notice(_t('loaded'));
    }, this);
    this.add_related_model(this.options.geocoder);
  },

  initPaginationAndScroll: function () {
    var topReached = false;
    var bottomReached = false;

    // Initialize moving header and loaders when scrolls
    this.scroll_position = { x: $(window).scrollLeft(), y: $(window).scrollTop(), last: 'vertical' };
    $(window).scroll(this._scrollMagic);

    // Pagination
    var SCROLL_BACK_PIXELS = 2;
    this.checkScrollTimer = setInterval(() => {
      if (!this.$el.is(':visible') || this.model.data().isFetchingPage()) {
        return;
      }
      var pos = $(this).scrollTop();
      var d = this.model.data();
      // do not let to fetch previous pages
      // until the user dont scroll back a little bit
      // see comments below
      if (pos > SCROLL_BACK_PIXELS) {
        topReached = false;
      }
      var pageSize = $(window).height() - this.$el.offset().top;
      var tableHeight = this.$('tbody').height();
      var realPos = pos + pageSize;
      if (tableHeight < pageSize) {
        return;
      }
      // do not let to fetch previous pages
      // until the user dont scroll back a little bit
      // if we dont do this when the user reach the end of the page
      // and there are more rows than max_rows, the rows form the beggining
      // are removed and the scroll keeps at the bottom so a new page is loaded
      // doing this the user have to move the scroll a little bit (2 px)
      // in order to load the page again
      if (realPos < tableHeight - SCROLL_BACK_PIXELS) {
        bottomReached = false;
      }
      if (realPos >= tableHeight) {
        if (!bottomReached) {
          // Simulating loadingRows event
          if (!d.lastPage) this._startPagination('down');

          setTimeout(function () {
            d.loadPageAtBottom();
          }, 600);
        }
        bottomReached = true;
      } else if (pos <= 0) {
        if (!topReached) {
          // Simulating loadingRows event
          if (d.pages && d.pages[0] != 0) this._startPagination('up'); // eslint-disable-line eqeqeq

          setTimeout(function () {
            d.loadPageAtTop();
          }, 600);
        }
        topReached = true;
      }

      this._setUpPagination(d);
    }, 300);
    this.bind('clean', function () {
      clearInterval(this.checkScrollTimer);
    }, this);
  },

  needsRender: function (table) {
    if (!table) return true;
    var ca = table.changedAttributes();
    if (ca.geometry_types && _.keys(ca).length === 1) {
      return false;
    }
    return true;
  },

  render: function (args) {
    if (!this.needsRender(args)) return;
    Table.prototype.render.call(this, args);
    if (this.model.isInSQLView()) {
      this._onSQLView();
    }
    this._swicthEnabled();
    this.trigger('render');
  },

  _renderHeader: function () {
    var thead = Table.prototype._renderHeader.apply(this);
    // New custom shadow (better performance)
    thead.append($('<div>').addClass('shadow'));
    return thead;
  },

  addColumn: function (column) {
    this.newColumnName = 'column_' + new Date().getTime();

    this.model.addColumn(this.newColumnName, 'string');

    this.unbind('render', this._highlightColumn, this);
    this.bind('render', this._highlightColumn, this);
  },

  _highlightColumn: function () {
    if (this.newColumnName) {
      var $th = this.$("a[href='#" + this.newColumnName + "']").parents('th');
      var position = $th.index();

      if (position) {
        setTimeout(function () {
          var windowWidth = $(window).width();
          if ($th && $th.position()) {
            var centerPosition = $th.position().left - windowWidth / 2 + $th.width() / 2;
            $(window).scrollLeft(centerPosition);
          }
          this.$("[data-x='" + position + "']").addClass('is-highlighted');
        }, 300);

        this.unbind('render', this._highlightColumn, this);
      }
    }
  },

  /**
   *  Take care if the table needs space at top and bottom
   *  to show the loaders.
   */
  _setUpPagination: function (d) {
    var pages = d.pages;

    // Check if the table is not in the first page
    if (pages.length > 0 && pages[0] > 0) {
      // In that case, add the paginator-up loader and leave it ready
      // when it is necessary
      if (this.$el.find('tfoot.page_loader.up').length == 0) { // eslint-disable-line eqeqeq
        this.$el.append(templateTablePaginationLoaders({ direction: 'up' }));
      }
      // Table now needs some space at the top to show the loader
      this.$el.parent().addClass('page_up');
    } else {
      // Loader is not needed and table doesn't need any space at the top
      this.$el.parent().removeClass('page_up');
    }

    // Checks if we are in the last page
    if (!d.lastPage) {
      // If not, let's prepare the paginator-down
      if (this.$el.find('tfoot.page_loader.down').length == 0) { // eslint-disable-line eqeqeq
        this.$el.append(templateTablePaginationLoaders({ direction: 'down' }));
      }
      // Let's say to the table that we have paginator-down
      this.$el.parent().addClass('page_down');
    } else {
      // Loader is not needed and table doesn't need any space at the bottom
      this.$el.parent().removeClass('page_down');
    }
  },

  /**
   *  What to do when a pagination starts
   */
  _startPagination: function (updown) {
    // Loader... move on buddy!
    this.$el.find('.page_loader.' + updown + '').addClass('active');
  },

  /**
   *  What to do when a pagination finishes
   */
  _finishPagination: function (page, updown) {
    // If we are in a different page than 0, and we are paginating up
    // let's move a little bit the scroll to hide the loader again
    // HACKY
    if (page != 0 && updown == 'up') { // eslint-disable-line eqeqeq
      setTimeout(function () {
        $(window).scrollTop(180);
      }, 300);
    }

    this.$el.find('.page_loader.active').removeClass('active');
  },

  _onSQLView: function () {
    // bind each time we change dataSource because table unbind
    // all the events from sqlView object each time useSQLView is called
    this.$('.sqlview').remove();

    this.options.sqlView.unbind('reset error', this._renderSQLHeader, this);
    this.options.sqlView.unbind('loading', this._renderLoading, this);

    this.options.sqlView.bind('loading', this._renderLoading, this);
    this.options.sqlView.bind('reset', this._renderSQLHeader, this);
    this.options.sqlView.bind('error', this._renderSQLHeader, this);
    this._renderSQLHeader();
  },

  _renderLoading: function (opts) {
    opts = opts || {};
    this.cleanEmptyTableInfo();
    if (!opts.add) {
      this._renderBodyTemplate(templateSQLLoading);
    }
  },

  _renderSQLHeader: function () {
    if (this.model.isInSQLView()) {
      var empty = this.isEmptyTable();
      this.$('thead').find('.sqlview').remove();
      this.$('thead').append(
        templateSQLViewNotice({
          empty: empty,
          isVisualization: this.vis.isVisualization(),
          warnMsg: null
        })
      );

      this.$('thead > tr').css('height', 64 + 42);
      if (this.isEmptyTable()) {
        this.addEmptySQLIfo();
      }

      this._moveInfo();
    }
  },

  // depending if the sync is enabled add or remove a class
  _swicthEnabled: function () {
    // Synced?
    this.$el[ this.model.isSync() ? 'addClass' : 'removeClass' ]('synced');
    // Visualization?
    this.$el[ this.vis.isVisualization() ? 'addClass' : 'removeClass' ]('vis');
  },

  _clearView: function (e) {
    if (e) e.preventDefault();
    this.options.layer.clearSQLView();
    return false;
  },

  _tableFromQuery: function (e) {
    throw new Error('Method not migrated, check original implementation');
  },

  /**
   *  Function to control the scroll in the table (horizontal and vertical)
   */
  _scrollMagic: function (ev) {
    var actual_scroll_position = { x: $(window).scrollLeft(), y: $(window).scrollTop() };

    if (this.scroll_position.x != actual_scroll_position.x) { // eslint-disable-line eqeqeq
      this.scroll_position.x = actual_scroll_position.x;
      this.$el.find('thead').addClass('horizontal');

      // If last change was vertical
      if (this.scroll_position.last == 'vertical') { // eslint-disable-line eqeqeq
        this.scroll_position.x = actual_scroll_position.x;

        this.$el.find('thead > tr > th > div > div:not(.dropdown)')
          .removeAttr('style')
          .css('top', actual_scroll_position.y + 'px');

        this.scroll_position.last = 'horizontal';
      }
    } else if (this.scroll_position.y != actual_scroll_position.y) { // eslint-disable-line eqeqeq
      this.scroll_position.y = actual_scroll_position.y;
      this.$el.find('thead').removeClass('horizontal');

      // If last change was horizontal
      if (this.scroll_position.last == 'horizontal') { // eslint-disable-line eqeqeq
        this.$el.find('thead > tr > th > div > div:not(.dropdown)')
          .removeAttr('style')
          .css({'marginLeft': '-' + actual_scroll_position.x + 'px'});

        this.scroll_position.last = 'vertical';
      }
    }
  },

  /**
   *  Move the info content if the panel is opened or hidden.
   *  - Query info if query is applied
   *  - Query loader if query is appliying in that moment
   *  - Add some padding to last column of the content to show them
   */
  _moveInfo: function (type) {
    if (type == 'show') { // eslint-disable-line eqeqeq
      this.$el
        .removeClass('narrow')
        .addClass('displaced');
    } else if (type == 'narrow') { // eslint-disable-line eqeqeq
      this.$el.addClass('displaced narrow');
    } else if (type == 'hide') { // eslint-disable-line eqeqeq
      this.$el.removeClass('displaced narrow');
    } else {
      // Check from the beginning if the right menu is openned, isOpen from
      // the menu is not working properly
      if ($('.table_panel').length > 0) {
        var opened = $('.table_panel').css('right').replace('px', '') == 0; // eslint-disable-line eqeqeq
        if (!opened) {
          this.$el.removeClass('displaced');
        }
      }
    }
  },

  _getEditor: function (columnType, opts) {
    throw new Error('Method not migrated, check original implementation');
  },

  closeEditor: function () {
    if (this._editorsOpened) {
      this._editorsOpened.hide();
      this._editorsOpened.clean();
    }
  },

  _editCell: function (e, cell, x, y) {
    throw new Error('Method not migrated, check original implementation');
  },

  headerView: function (column) {
    throw new Error('Method not migrated, check original implementation');
  },

  /**
  * Checks if the table has any rows, and if not, launch the method for showing the appropiate view elements
  * @method _checkEmptyTable
  */
  _checkEmptyTable: function () {
    if (this.isEmptyTable()) {
      this.addEmptyTableInfo();
    } else {
      this.cleanEmptyTableInfo();
    }
  },

  /**
  * Force the table to be at the beginning
  * @method _forceScroll
  */
  _forceScroll: function (ev) {
    $(window).scrollLeft(0);
  },

  _renderEmpty: function () {
    this.addEmptyTableInfo();
  },

  /**
  * Adds the view elements associated with no content in the table
  * @method addemptyTableInfo
  */
  addEmptyTableInfo: function () {
    throw new Error('Method not migrated, check original implementation');
  },

  /**
  * Adds the view elements associated with no content in the table when a SQL is applied
  * @method addEmptySQLIfo
  */
  addEmptySQLIfo: function () {
    if (this.model.isInSQLView()) {
      this._renderBodyTemplate(templateEmptySQL);
    }
  },

  _renderBodyTemplate: function (template) {
    this.$('tbody').html('');
    this.$('tfoot').remove();
    this.$el.hide();

    // Check if panel is opened to move the loader some bit left
    var panel_opened = false;
    if ($('.table_panel').length > 0) {
      panel_opened = $('.table_panel').css('right').replace('px', '') == 0; // eslint-disable-line eqeqeq
    }

    var content = template({ config: this._configModel, panel_opened });
    var $footer = $('<tfoot class="sql_loader"><tr><td colspan="100">' + content + '</td></tr></tfoot>');

    this.$el.append($footer);
    this.$el.fadeIn();
  },

  /**
  * Removes from the view the no-content elements
  * @method cleanEmptyTableInfo
  */
  cleanEmptyTableInfo: function () {
    this.$('tfoot').fadeOut('fast', function () {
      $(this).remove();
    });
    this.$('.noRows').slideUp('fast', function () {
      $(this).remove();
    });
  },

  notice: function (text, type, time) {
    this.globalError.showError(text, type, time);
  },

  /**
  * Add a new row and removes the empty table view elemetns
  * @method addEmptyRow
  * @todo: (xabel) refactor this to include a "addRow" method in _models[0]
  */
  addEmptyRow: function () {
    this.dataModel.addRow({ at: 0 });
    this.cleanEmptyTableInfo();
  },

  /**
  * Captures the saving event from the row and produces a notification
  * @todo (xabel) i'm pretty sure there has to be a less convulted way of doing this, without capturing a event
  * to throw another event in the model to be captured by some view
  */
  rowSaving: function () {
    this.notice('Saving your edit', 'load', -1);
  },

  /**
  * Captures the change event from the row and produces a notification
  * @method rowSynched
  * @todo (xabel) i'm pretty sure there has to be a less convulted way of doing this, without capturing a event
  * to throw another event in the model to be captured by some view
  */
  rowSynched: function () {
    this.notice('Sucessfully saved');
  },

  /**
  * Captures the change event from the row and produces a notification
  * @method rowSynched
  * @todo (xabel) i'm pretty sure there has to be a less convulted way of doing this, without capturing a event
  * to throw another event in the model to be captured by some view
  */
  rowFailed: function () {
    this.notice('Oops, there has been an error saving your changes.', 'error');
  },

  /**
  * Captures the destroy event from the row and produces a notification
  * @method rowDestroyed
  */

  rowDestroying: function () {
    this.notice('Deleting row', 'load', -1);
  },

  /**
  * Captures the sync after a destroy event from the row and produces a notification
  * @method rowDestroyed
  */

  rowDestroyed: function () {
    this.notice('Sucessfully deleted');
    this._checkEmptyTable();
  }
});