cartodb-4.42/lib/assets/javascripts/cartodb/table/tableview.js

824 lines
26 KiB
JavaScript
Raw Normal View History

2024-04-06 13:25:13 +08:00
(function() {
/**
* table view shown in admin
*/
cdb.admin.TableView = cdb.ui.common.Table.extend({
classLabel: 'cdb.admin.TableView',
events: cdb.core.View.extendEvents({
'click .clearview': '_clearView',
'click .sqlview .export_query': '_tableFromQuery',
'click .noRows': 'addEmptyRow'
}),
rowView: cdb.admin.RowView,
initialize: function() {
var self = this;
this.elder('initialize');
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() {
var self = this;
_.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', function() {
self._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', function() {
//self._checkEmptyTable();
self._forceScroll();
}, this);
this.model.bind('change:permission', this._checkEmptyTable, this);
this.model.bind('change:isSync', this._swicthEnabled, this);
this._swicthEnabled();
// Actions triggered in the right panel
cdb.god.bind("panel_action", function(action) {
self._moveInfo(action);
}, this);
this.add_related_model(cdb.god);
// 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 self = this;
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(function() {
if(!self.$el.is(":visible") || self.model.data().isFetchingPage()) {
return;
}
var pos = $(this).scrollTop();
var d = self.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() - self.$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) self._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) self._startPagination('up');
setTimeout(function() {
d.loadPageAtTop()
},600);
}
topReached = true;
}
self._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;
this.elder('render', args);
if (this.model.isInSQLView()) {
this._onSQLView();
}
this._swicthEnabled();
this.trigger('render');
},
_renderHeader: function() {
var thead = cdb.ui.common.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) {
this.$el.append(this.getTemplate('table/views/table_pagination_loaders')({ 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) {
this.$el.append(this.getTemplate('table/views/table_pagination_loaders')({ 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") {
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('table/views/sql_loading');
}
},
_renderSQLHeader: function() {
var self = this;
if(self.model.isInSQLView()) {
var empty = self.isEmptyTable();
self.$('thead').find('.sqlview').remove();
self.$('thead').append(
self.getTemplate('table/views/sql_view_notice')({
empty: empty,
isVisualization: self.vis.isVisualization(),
warnMsg: null
})
);
self.$('thead > tr').css('height', 64 + 42);
if(self.isEmptyTable()) {
self.addEmptySQLIfo();
}
self._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) {
e.preventDefault();
var duplicate_dialog = new cdb.editor.DuplicateDatasetView({
model: this.model,
user: this.user,
clean_on_hide: true
});
duplicate_dialog.appendToBody();
},
/**
* 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) {
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") {
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) {
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") {
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") {
this.$el
.removeClass('narrow')
.addClass('displaced');
} else if (type == "narrow") {
this.$el.addClass('displaced narrow')
} else if (type == "hide") {
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 ? true : false;
if (!opened) {
this.$el.removeClass('displaced');
}
}
}
},
_getEditor: function(columnType, opts) {
var editors = {
'string': cdb.admin.StringField,
'number': cdb.admin.NumberField,
'date': cdb.admin.DateField,
'geometry': cdb.admin.GeometryField,
'timestamp with time zone': cdb.admin.DateField,
'timestamp without time zone': cdb.admin.DateField,
'boolean': cdb.admin.BooleanField
};
var editorExists = _.filter(editors, function(a,i) { return i === columnType }).length > 0;
if(columnType !== "undefined" && editorExists) {
return editors[columnType];
} else {
return editors['string']
}
},
closeEditor: function() {
if (this._editorsOpened) {
this._editorsOpened.hide();
this._editorsOpened.clean();
}
},
_editCell: function(e, cell, x, y) {
var self = this;
// Clean and close previous cell editor
this.closeEditor();
var column = self.model.columnName(x-1);
var columnType = this.model.getColumnType(column);
if (this.model.isReservedColumn(column) && !this.model.isReadOnly() && columnType!='geometry') {
return;
}
var row = self.model.data().getRowAt(y);
var initial_value = '';
if(self.model.data().getCell(y, column) === 0 || self.model.data().getCell(y, column) === '0') {
initial_value = '0';
} else if (self.model.data().getCell(y, column) !== undefined) {
initial_value = self.model.data().getCell(y, column);
}
// dont let generic editor
if(column == 'the_geom') {
columnType = 'geometry'
}
var prevRow = _.clone(row.toJSON());
var dlg = this._editorsOpened = new cdb.admin.SmallEditorDialog({
value: initial_value,
column: column,
row: row,
rowNumber: y,
readOnly: this.model.isReadOnly(),
editorField: this._getEditor(columnType),
res: function(new_value) {
if(!_.isEqual(new_value, prevRow[column])) {
// do not use save error callback since it avoid model error method to be called
row.bind('error', function editError() {
row.unbind('error', editError);
// restore previopis on error
row.set(column, prevRow[column]);
});
row
.save(column, new_value)
.done(function(a){
self.model.trigger('data:saved');
});
}
}
});
if(!dlg) {
cdb.log.error("editor not defined for column type " + columnType);
return;
}
// auto add to table view
// Check first if the row is the first or the cell is the last :)
var $td = $(e.target).closest("td")
, offset = $td.offset()
, $tr = $(e.target).closest("tr")
, width = Math.min($td.outerWidth(), 278);
// Remove header spacing from top offset
offset.top = offset.top - this.$el.offset().top;
if ($td.parent().index() == 0) {
offset.top += 5;
} else {
offset.top -= 11;
}
if ($td.index() == ($tr.find("td").size() - 1) && $tr.find("td").size() < 2) {
offset.left -= width/2;
} else {
offset.left -= 11;
}
dlg.showAt(offset.left, offset.top, width, true);
},
headerView: function(column) {
var self = this;
if(column[1] !== 'header') {
var v = new cdb.admin.HeaderView({
column: column,
table: this.model,
sqlView: this.options.sqlView,
user: this.user,
vis: this.vis
})
.bind('clearView', this._clearView, this)
.bind('georeference', function(column) {
var dlg;
var bkgPollingModel = this.options.backgroundPollingModel;
var tableIsReadOnly = this.model.isSync();
var canAddGeocoding = bkgPollingModel !== "" ? bkgPollingModel.canAddGeocoding() : true; // With new modals
if (!this.options.geocoder.isGeocoding() && !tableIsReadOnly && canAddGeocoding) {
var dlg = new cdb.editor.GeoreferenceView({
table: this.model,
user: this.user,
tabs: ['lonlat', 'city', 'admin', 'postal', 'ip', 'address'],
option: 'lonlat',
data: { longitude: column }
});
} else if (this.options.geocoder.isGeocoding() || ( !canAddGeocoding && !tableIsReadOnly )) {
dlg = cdb.editor.ViewFactory.createDialogByTemplate('common/background_polling/views/geocodings/geocoding_in_progress');
} else {
// If table can't geocode == is synched, return!
return;
}
dlg.appendToBody();
}, this)
.bind('applyFilter', function(column) {
self.options.menu.show('filters_mod');
self.options.layer.trigger('applyFilter',column);
}, this)
this.addView(v);
if (this.newColumnName == column[0]) {
setTimeout(function() {
v.renameColumn();
self.newColumnName = null;
}, 300);
}
return v.render().el;
} else {
return '<div><div></div></div>';
}
},
/**
* 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() {
if(this.$('.noRows').length == 0 && !this.model.isInSQLView() && this.model.get('schema')) {
this.elder('addEmptyTableInfo');
this.$el.hide();
// Fake empty row if the table is not readonly
if (!this.model.isReadOnly()) {
//TODO: use row view instead of custom HTML
var columnsNumber = this.model.get('schema').length;
var columns = '<tr class="placeholder noRows"><td class="addNewRow">+</td>';
for(var i = 0; i < columnsNumber; i++) {
columns += '<td></td>';
}
columns += '</tr>';
var columnsFooter = '<tr class="placeholder noRows decoration"><td></td>';
for(var i = 0; i < columnsNumber; i++) {
columnsFooter += '<td></td>';
}
columnsFooter += '</tr>';
var $columns = $(columns+columnsFooter)
this.$el.append($columns);
}
this.template_base = cdb.templates.getTemplate( this.model.isReadOnly() ? 'table/views/empty_readtable_info' : 'table/views/empty_table');
var content = this.template_base();
var $footer = $('<tfoot><tr><td colspan="100">' + content + '</td></tr></tfoot>');
this.$el.append($footer);
this.$el.fadeIn();
}
},
/**
* 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('table/views/empty_sql');
}
},
_renderBodyTemplate: function(tmpl) {
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 ? true : false;
}
var content = cdb.templates.getTemplate(tmpl)({ panel_opened: panel_opened })
, $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();
}
});
/**
* table tab controller
*/
cdb.admin.TableTab = cdb.core.View.extend({
className: 'table',
initialize: function() {
this.user = this.options.user;
this.sqlView = this.options.sqlView;
this.geocoder = this.options.geocoder;
this.backgroundPollingModel = this.options.backgroundPollingModel;
this._initBinds();
},
setActiveLayer: function(layerView) {
var recreate = !!this.tableView;
this.deactivated();
this.model = layerView.table;
this.layer = layerView.model;
this.sqlView = layerView.sqlView;
if(recreate) {
this.activated();
}
},
_initBinds: function() {
// Geocoder binding
this.geocoder.bind('geocodingComplete geocodingError geocodingCanceled', function() {
if (this.model.data) {
this.model.data().refresh()
}
}, this);
this.add_related_model(this.geocoder);
},
_createTable: function() {
this.tableView = new cdb.admin.TableView({
dataModel: this.model.data(),
model: this.model,
sqlView: this.sqlView,
layer: this.layer,
geocoder: this.options.geocoder,
backgroundPollingModel: this.backgroundPollingModel,
vis: this.options.vis,
menu: this.options.menu,
user: this.user,
globalError: this.options.globalError
});
},
activated: function() {
if(!this.tableView) {
this._createTable();
this.tableView.render();
this.render();
}
},
deactivated: function() {
if(this.tableView) {
this.tableView.clean();
this.tableView = null;
this.hasRenderedTableView = false;
}
},
render: function() {
// Since render should be idempotent (i.e. should not append the tableView twice when called multiple times)
if(this.tableView && !this.hasRenderedTableView) {
this.hasRenderedTableView = true;
this.$el.append(this.tableView.el);
}
return this;
}
});
})();