CartoDB/cartodb20

View on GitHub
lib/assets/javascripts/builder/components/table/body/table-body-view.js

Summary

Maintainability
F
3 days
Test Coverage
var CoreView = require('backbone/core-view');
var $ = require('jquery');
var _ = require('underscore');
var Clipboard = require('clipboard');
var TableBodyRowView = require('./table-body-row-view');
var ContextMenuView = require('builder/components/context-menu/context-menu-view');
var CustomListCollection = require('builder/components/custom-list/custom-list-collection');
var addTableRowOperation = require('builder/components/table/operations/table-add-row');
var removeTableRowOperation = require('builder/components/table/operations/table-remove-row');
var editCellOperation = require('builder/components/table/operations/table-edit-cell');
var ConfirmationModalView = require('builder/components/modals/confirmation/modal-confirmation-view');
var TablePaginatorView = require('builder/components/table/paginator/table-paginator-view');
var tableBodyTemplate = require('./table-body.tpl');
var renderLoading = require('builder/components/loading/render-loading');
var ErrorView = require('builder/components/error/error-view');
var tableNoRowsTemplate = require('./table-no-rows.tpl');
var EditorsServiceModel = require('builder/components/table/editors/editors-service-model');
var EditorsModel = require('builder/components/table/editors/types/editor-model');
var errorParser = require('builder/helpers/error-parser');
var magicPositioner = require('builder/helpers/magic-positioner');
var checkAndBuildOpts = require('builder/helpers/required-opts');

var EDITORS_MAP = {
  'string': require('builder/components/table/editors/types/editor-string-view'),
  'number': require('builder/components/table/editors/types/editor-base-view'),
  'boolean': require('builder/components/table/editors/types/editor-boolean-view'),
  'date': require('builder/components/table/editors/types/editor-date-view'),
  'default': require('builder/components/table/editors/types/editor-string-view')
};

var REQUIRED_OPTS = [
  'columnsCollection',
  'modals',
  'queryGeometryModel',
  'querySchemaModel',
  'rowsCollection',
  'canHideColumns',
  'tableViewModel'
];

/*
 *  Table body view
 */

module.exports = CoreView.extend({

  className: 'Table-body',
  tagName: 'div',

  events: {
    'click': '_onClick',
    'dblclick': '_onDblClick'
  },

  initialize: function (opts) {
    checkAndBuildOpts(opts, REQUIRED_OPTS, this);

    this._editors = new EditorsServiceModel();

    this._closeEditor = this._closeEditor.bind(this);
    this._hideContextMenu = this._hideContextMenu.bind(this);

    this._initBinds();
  },

  render: function () {
    this.clearSubViews();
    this._destroyScrollBinding();
    this.$el.empty();

    // Render results when we have the schema and goemetry is not being fetched
    if (this._querySchemaModel.isFetched() && !this._queryGeometryModel.isFetching() && !this._rowsCollection.isFetching()) {
      if (!this._rowsCollection.size()) {
        this._renderNoRows();
      } else {
        this.$el.html(tableBodyTemplate());
        this._rowsCollection.each(this._renderBodyRow, this);
        this._initPaginator();
      }
    } else {
      this._renderQueryState();
    }

    this.$el.toggleClass('Table-body--relative', !!this._tableViewModel.get('relativePositionated'));

    return this;
  },

  _initBinds: function () {
    this._queryGeometryModel.bind('change:status', this.render, this);
    this._querySchemaModel.bind('change:status', this.render, this);
    this._rowsCollection.bind('reset', _.debounce(this.render.bind(this), 20), this);
    this._rowsCollection.bind('add', function (model) {
      if (this._rowsCollection.size() === 1) {
        this.render();
      } else {
        this._renderBodyRow(model);
      }
    }, this);
    this._rowsCollection.bind('remove', this._onRemoveRow, this);
    this._rowsCollection.bind('fail', function (mdl, response) {
      if (!response || (response && response.statusText !== 'abort')) {
        this._renderError(errorParser(response));
      }
    }, this);
    this.add_related_model(this._queryGeometryModel);
    this.add_related_model(this._querySchemaModel);
    this.add_related_model(this._rowsCollection);
  },

  _renderQueryState: function () {
    var querySchemaStatus = this._querySchemaModel.get('status');
    var nodeReady = this._querySchemaModel.get('ready');
    var geometryStatus = this._queryGeometryModel.get('status');
    var rowsCollectionStatus = this._rowsCollection.getStatusValue();

    if (nodeReady) {
      if (querySchemaStatus === 'unavailable' && geometryStatus === 'unavailable' ||
          rowsCollectionStatus === 'unavailable') {
        this._renderError(this._querySchemaModel.get('query_errors'));
      } else {
        this._renderLoading();
      }
    } else {
      this._renderLoading();
    }
  },

  _renderLoading: function () {
    this.$el.html(
      renderLoading({
        title: _t('components.table.rows.loading.title')
      })
    );
  },

  _renderError: function (desc) {
    var view = new ErrorView({
      title: _t('components.table.rows.error.title'),
      desc: desc || _t('components.table.rows.error.desc')
    });
    this.addView(view);
    this.$el.html(view.render().el);
  },

  _renderNoRows: function () {
    this.$el.html(
      tableNoRowsTemplate({
        page: this._tableViewModel.get('page'),
        customQuery: this._tableViewModel.isCustomQueryApplied()
      })
    );
  },

  _initPaginator: function () {
    var paginatorView = new TablePaginatorView({
      rowsCollection: this._rowsCollection,
      tableViewModel: this._tableViewModel,
      scrollToBottom: this._scrollToBottom.bind(this)
    });

    // Bug in Chrome with position:fixed :(, so we have to choose body as
    // parent
    var $el = $('body');
    // But if we have chosen relativePositionated, we should add close
    // to the table view
    if (this._tableViewModel.get('relativePositionated')) {
      $el = this.$el.closest('.Table').parent();
    }

    $el.append(paginatorView.render().el);
    this.addView(paginatorView);
  },

  _initScrollBinding: function () {
    $('.Table').scroll(this._hideContextMenu);
    this.$('.js-tbody').scroll(this._hideContextMenu);
  },

  _destroyScrollBinding: function () {
    $('.Table').off('scroll', this._hideContextMenu);
    this.$('.js-tbody').off('scroll', this._hideContextMenu);
  },

  _renderBodyRow: function (mdl) {
    var view = new TableBodyRowView({
      model: mdl,
      columnsCollection: this._columnsCollection,
      simpleGeometry: this._queryGeometryModel.get('simple_geom'),
      canHideColumns: this._canHideColumns,
      tableViewModel: this._tableViewModel
    });
    this.addView(view);
    this.$('.js-tbody').append(view.render().el);
  },

  _onRemoveRow: function () {
    if (!this._rowsCollection.size()) {
      this._rowsCollection.resetFetch();
      this._queryGeometryModel.resetFetch();

      var page = this._tableViewModel.get('page');
      if (page > 0) {
        this._tableViewModel.set('page', page - 1);
      } else {
        this.render();
      }
    }
  },

  _hasContextMenu: function () {
    return this._menuView;
  },

  _hideContextMenu: function () {
    this._unhighlightCell();
    this._destroyScrollBinding();
    this._menuView.collection.unbind(null, null, this);
    this.removeView(this._menuView);
    this._menuView.clean();
    delete this._menuView;
  },

  _highlightCell: function ($tableCellItem, $tableRow) {
    $tableCellItem.addClass('is-highlighted');
    $tableRow.addClass('is-highlighted');
  },

  _unhighlightCell: function () {
    this.$('.Table-cellItem.is-highlighted, .Table-row.is-highlighted').removeClass('is-highlighted');
  },

  _showContextMenu: function (ev) {
    var self = this;
    var position = { x: ev.clientX, y: ev.clientY };
    var $tableRow = $(ev.target).closest('.Table-row');
    var $tableCellItem = $(ev.target).closest('.Table-cellItem');
    var modelCID = $tableRow.data('model');
    var attribute = $tableCellItem.data('attribute');
    var rowModel = self._rowsCollection.get({ cid: modelCID });
    var menuItems = [];

    menuItems.push({
      label: _t('components.table.rows.options.copy'),
      val: 'copy',
      action: function () {
        self._copyValue($tableCellItem);
      }
    });

    if (!this._tableViewModel.isDisabled()) {
      menuItems = [
        {
          label: _t('components.table.rows.options.edit'),
          val: 'edit',
          action: function () {
            self._editCell(rowModel, attribute);
          }
        }, {
          label: _t('components.table.rows.options.create'),
          val: 'create',
          action: function () {
            self._addRow();
          }
        }
      ].concat(menuItems);

      menuItems.push({
        label: _t('components.table.rows.options.delete'),
        val: 'delete',
        destructive: true,
        action: function () {
          self._removeRow(rowModel);
        }
      });
    }

    // No options?, don't open anything
    if (!menuItems.length) {
      return false;
    }

    var collection = new CustomListCollection(menuItems);

    this._menuView = new ContextMenuView({
      className: 'Table-rowMenu ' + ContextMenuView.prototype.className,
      collection: collection,
      triggerElementID: modelCID,
      position: position
    });

    this._menuView.$el.css(
      magicPositioner({
        parentView: $('body'),
        posX: position.x,
        posY: position.y
      })
    );

    collection.bind('change:selected', function (menuItem) {
      var action = menuItem.get('action');
      action && action();
    }, this);

    this._menuView.model.bind('change:visible', function (model, isContextMenuVisible) {
      if (this._hasContextMenu() && !isContextMenuVisible) {
        this._hideContextMenu();
      }
    }, this);

    this._menuView.show();
    this.addView(this._menuView);

    this._highlightCell($tableCellItem, $tableRow);
    this._initScrollBinding();
  },

  _onClick: function (ev) {
    var isCellOptions = $(ev.target).hasClass('js-cellOptions');
    if (isCellOptions) {
      if (this._hasContextMenu()) {
        this._hideContextMenu();
      } else {
        this._showContextMenu(ev);
      }
    }
  },

  _onDblClick: function (ev) {
    var $tableCellItem = $(ev.target).closest('.Table-cellItem');
    var isCellOptions = $(ev.target).hasClass('js-cellOptions');

    if ($tableCellItem && !isCellOptions) {
      var $tableRow = $tableCellItem.closest('.Table-row');
      var modelCID = $tableRow.data('model');
      var attribute = $tableCellItem.data('attribute');
      var rowModel = this._rowsCollection.get({ cid: modelCID });

      if (!this._tableViewModel.isDisabled() && rowModel && attribute && attribute !== 'cartodb_id') {
        this._editCell(rowModel, attribute);
      }
    }
  },

  _copyValue: function ($el) {
    // Work-around for Clipboard \o/
    this._clipboard = new Clipboard($el.get(0));
    $el.click();
    this._clipboard.destroy();
  },

  _addRow: function () {
    addTableRowOperation({
      rowsCollection: this._rowsCollection
    });
  },

  _initEditorScrollBinding: function () {
    $('.Table').scroll(this._closeEditor);
    this.$('.js-tbody').scroll(this._closeEditor);
  },

  _destroyEditorScrollBinding: function () {
    $('.Table').unbind('scroll', this._closeEditor);
    this.$('.js-tbody').unbind('scroll', this._closeEditor);
  },

  _closeEditor: function () {
    this._unhighlightCell();
    this._destroyEditorScrollBinding();
    this._editors.unbind(null, null, this);
    this._editors.destroy();
  },

  _saveValue: function (rowModel, attribute, newValue) {
    if (rowModel.get(attribute) !== newValue) {
      editCellOperation({
        rowModel: rowModel,
        attribute: attribute,
        newValue: newValue
      });
    }
  },

  _doCellEdition: function (rowModel, attribute) {
    var $tableRow = this.$('[data-model="' + rowModel.cid + '"]');
    var $tableCell = $tableRow.find('[data-attribute="' + attribute + '"]');
    var $options = $tableCell.find('.js-cellOptions');
    var columnModel = _.first(this._columnsCollection.where({ name: attribute }));
    var type = columnModel.get('type');

    this._highlightCell($tableCell, $tableRow);
    this._initEditorScrollBinding();

    var model = new EditorsModel({
      type: type,
      value: rowModel.get(attribute)
    });

    this._editors.bind('destroyedEditor', this._closeEditor, this);
    this._editors.bind('confirmedEditor', function () {
      if (model.isValid()) {
        this._saveValue(
          rowModel,
          attribute,
          model.get('value')
        );
      }
    }, this);

    var position = $options.offset();

    if ($tableCell.index() > 1) {
      position.right = window.innerWidth - position.left;
      delete position.left;
    }

    if (this._rowsCollection.size() > 4 && ($tableRow.index() + 2) >= (this._rowsCollection.size() - 1)) {
      position.bottom = window.innerHeight - position.top;
      delete position.top;
    } else {
      position.top = position.top + 20;
    }

    var View = EDITORS_MAP[type];

    if (!View) {
      View = EDITORS_MAP['default'];
    }

    this._editors.create(
      function (editorModel) {
        return new View({
          editorModel: editorModel,
          model: model
        });
      },
      position
    );
  },

  _editCell: function (rowModel, attribute) {
    var callback = this._doCellEdition.bind(this, rowModel, attribute);
    rowModel.fetchRowIfGeomIsNotLoaded(callback);
  },

  _removeRow: function (rowModel) {
    var self = this;

    this._modals.create(
      function (modalModel) {
        return new ConfirmationModalView({
          modalModel: modalModel,
          template: require('./modals-templates/remove-table-row.tpl'),
          renderOpts: {
            cartodb_id: rowModel.get('cartodb_id')
          },
          loadingTitle: _t('components.table.rows.destroy.loading', {
            cartodb_id: rowModel.get('cartodb_id')
          }),
          runAction: function () {
            removeTableRowOperation({
              tableViewModel: self._tableViewModel,
              rowModel: rowModel,
              onSuccess: function () {
                modalModel.destroy();
              },
              onError: function (e) {
                modalModel.destroy();
              }
            });
          }
        });
      }
    );
  },

  _scrollToBottom: function () {
    var tbodyHeight = this.$('.js-tbody').get(0).scrollHeight;
    this.$('.js-tbody').animate({
      scrollTop: tbodyHeight
    }, 'slow');
  },

  clean: function () {
    this._destroyScrollBinding();
    CoreView.prototype.clean.apply(this);
  }

});