Initial commit

This commit is contained in:
zhongjin
2020-06-15 10:58:47 +08:00
commit 4f1dfe7564
8590 changed files with 1516878 additions and 0 deletions
@@ -0,0 +1,177 @@
var _ = require('underscore');
var $ = require('jquery');
var Backbone = require('backbone');
var CoreView = require('backbone/core-view');
var createTuplesItems = require('./create-tuples-items');
var widgetsTypes = require('./widgets-types');
var BodyView = require('./body-view');
var template = require('./add-widgets.tpl');
var renderLoading = require('builder/components/loading/render-loading');
var TableStats = require('./tablestats.js');
var ENTER_KEY_CODE = 13;
/**
* View to add new widgets.
* Expected to be rendered in a modal.
*/
module.exports = CoreView.extend({
module: 'components/modals/add-widgets/add-widgets-view',
className: 'Dialog-content Dialog-content--expanded',
events: {
'click .js-continue': '_onContinue'
},
initialize: function (opts) {
if (!opts.userActions) throw new Error('userActions is required');
if (!opts.modalModel) throw new Error('modalModel is required');
if (!opts.layerDefinitionsCollection) throw new Error('layerDefinitionsCollection is required');
if (!opts.widgetDefinitionsCollection) throw new Error('widgetDefinitionsCollection is required');
if (!opts.analysisDefinitionNodesCollection) throw new Error('analysisDefinitionNodesCollection is required');
this._userActions = opts.userActions;
this._modalModel = opts.modalModel;
this._layerDefinitionsCollection = opts.layerDefinitionsCollection;
this._widgetDefinitionsCollection = opts.widgetDefinitionsCollection;
this._analysisDefinitionNodesCollection = opts.analysisDefinitionNodesCollection;
this._optionsCollection = new Backbone.Collection();
this.tableStats = new TableStats({
configModel: opts.configModel,
userModel: opts.userModel
});
this._analysisDefinitionNodesCollection.each(function (analysisDefinitionNode) {
analysisDefinitionNode = analysisDefinitionNode.querySchemaModel;
analysisDefinitionNode.on('change', this._onQuerySchemaChange, this);
this.add_related_model(analysisDefinitionNode);
analysisDefinitionNode.fetch();
}, this);
this.listenTo(this._optionsCollection, 'change:selected', this._updateContinueButtonState);
this.add_related_model(this._optionsCollection);
this._onKeyDown = this._onKeyDown.bind(this);
this._initBinds();
},
render: function () {
this.clearSubViews();
this.$el.html(template());
if (this._hasFetchedAllQuerySchemas()) {
this._renderBodyView();
} else {
this._renderLoadingView();
}
this._updateContinueButtonState();
return this;
},
_initBinds: function () {
$(document).bind('keydown', this._onKeyDown);
},
_disableBinds: function () {
$(document).unbind('keydown', this._onKeyDown);
},
_onKeyDown: function (event) {
event.preventDefault();
if (event.which === ENTER_KEY_CODE) {
this._onContinue();
}
},
_onQuerySchemaChange: function () {
if (this._hasFetchedAllQuerySchemas()) {
this.render();
}
},
_hasFetchedAllQuerySchemas: function () {
return this._analysisDefinitionNodesCollection.all(function (analysisDefinitionNode) {
return analysisDefinitionNode.querySchemaModel.get('status') !== 'fetching';
});
},
_onContinue: function () {
var self = this;
var selectedOptionModels = this._optionsCollection.filter(this._isSelected);
if (selectedOptionModels.length > 0) {
this._saveSelectedWidgets(selectedOptionModels)
.then(function (widgets) {
self._userActions.goToEditWidget(widgets);
self._userActions.updateWidgetsOrder(selectedOptionModels);
})
.catch(function () {
self._userActions.updateWidgetsOrder(selectedOptionModels);
});
this._modalModel.destroy();
}
},
_saveSelectedWidgets: function (selectedOptionModels) {
var saveWidgetOptionsPromises = [];
_.map(selectedOptionModels, function (selectedOptionModel) {
saveWidgetOptionsPromises.push(this._userActions.saveWidgetOption(selectedOptionModel));
}, this);
return Promise.all(saveWidgetOptionsPromises);
},
_renderBodyView: function () {
this._createOptionsModels();
var view = new BodyView({
el: this._$body(),
optionsCollection: this._optionsCollection,
widgetsTypes: widgetsTypes
});
this.addView(view.render());
},
_renderLoadingView: function () {
this._$body().html(
renderLoading({
title: _t('components.modals.add-widgets.loading-title')
})
);
},
_$body: function () {
return this.$('.js-body');
},
_updateContinueButtonState: function () {
this.$('.js-continue').toggleClass('is-disabled', !this._optionsCollection.any(this._isSelected));
},
_isSelected: function (model) {
return !!model.get('selected');
},
_createOptionsModels: function () {
var self = this;
this._optionsCollection.reset();
var tuplesItems = createTuplesItems(this._analysisDefinitionNodesCollection, this._layerDefinitionsCollection);
_.each(widgetsTypes, function (widgetType) {
var models = widgetType.createOptionModels(tuplesItems, this._widgetDefinitionsCollection);
models = models.map(function (model) { model.stats = self.tableStats; return model; });
this._optionsCollection.add(models);
}, this);
},
clean: function () {
this._disableBinds();
CoreView.prototype.clean.apply(this);
}
});
@@ -0,0 +1,18 @@
<div class="Modal">
<div class="Modal-header">
<div class="Modal-headerContainer">
<h2 class="CDB-Text CDB-Size-huge is-light u-mainTextColor u-bSpace"><%- _t('components.modals.add-widgets.modal-title') %></h2>
<h3 class="CDB-Text CDB-Size-medium u-altTextColor"><%- _t('components.modals.add-widgets.modal-desc') %></h3>
</div>
</div>
<div class="Modal-container js-body"></div>
<div class="Modal-footer">
<div class="Modal-footerContainer u-flex u-justifyEnd">
<button class="CDB-Button CDB-Button--primary is-disabled js-continue">
<span class="CDB-Button-Text CDB-Text is-semibold CDB-Size-medium u-upperCase"><%- _t('components.modals.add-widgets.continue-btn') %></span>
</button>
</div>
</div>
</div>
@@ -0,0 +1,52 @@
var _ = require('underscore');
var CoreView = require('backbone/core-view');
var createTemplateTabPane = require('builder/components/tab-pane/create-template-tab-pane');
var tabPaneButtonTemplate = require('./tab-pane-button-template.tpl');
var tabPaneTemplate = require('./tab-pane-template.tpl');
/**
* View to select widget options to create.
*/
module.exports = CoreView.extend({
module: 'components/modals/add-widgets/body-view',
initialize: function (opts) {
if (!opts.optionsCollection) throw new Error('optionsCollection is required');
if (!opts.widgetsTypes) throw new Error('widgetsTypes is required');
// Only render tab items for types that have option models available
var availableTypes = _.unique(opts.optionsCollection.pluck('type'));
this._tabPaneItems = _
.reduce(opts.widgetsTypes, function (memo, d) {
if (_.contains(availableTypes, d.type)) {
var tabPaneItem = d.createTabPaneItem(opts.optionsCollection);
memo.push(tabPaneItem);
}
return memo;
}, []);
},
render: function () {
this.clearSubViews();
var options = {
tabPaneOptions: {
template: tabPaneTemplate,
tabPaneItemOptions: {
tagName: 'li',
klassName: 'CDB-NavMenu-item'
}
},
tabPaneTemplateOptions: {
tagName: 'button',
className: 'CDB-NavMenu-link u-upperCase',
template: tabPaneButtonTemplate
}
};
var view = createTemplateTabPane(this._tabPaneItems, options);
this.addView(view);
this.$el.append(view.render().el);
return this;
}
});
@@ -0,0 +1,2 @@
<p class="Widget-categoryFakeText"></p>
<div class="Widget-categoryFake"></div>
@@ -0,0 +1,41 @@
var _ = require('underscore');
var WidgetOptionModel = require('builder/components/modals/add-widgets/widget-option-model');
var WidgetDefinitionModel = require('builder/data/widget-definition-model');
var CATEGORY_TYPE = 'category';
module.exports = WidgetOptionModel.extend({
defaults: _.defaults({type: CATEGORY_TYPE}, WidgetOptionModel.defaults),
save: function (widgetDefinitionsCollection) {
var model = this;
var columnName = this.columnName();
var styleModel = this.layerDefinitionModel() && this.layerDefinitionModel().styleModel;
var isAllowed = styleModel && styleModel.canApplyAutoStyle() || false;
var attrs = {
type: CATEGORY_TYPE,
layer_id: this.layerDefinitionModel().id,
source: {
id: this.analysisDefinitionNodeModel().id
},
options: {
column: columnName,
aggregation_column: columnName,
aggregation: this.get('aggregation'),
title: this.get('title')
},
style: {
widget_style: {
definition: WidgetDefinitionModel.getDefaultWidgetStyle(CATEGORY_TYPE)
},
auto_style: {
allowed: isAllowed,
custom: false
}
}
};
return widgetDefinitionsCollection.addWidget(model, attrs);
}
});
@@ -0,0 +1,80 @@
var CoreView = require('backbone/core-view');
var LayerSelectorView = require('builder/components/modals/add-widgets/layer-selector-view');
var template = require('./category-option.tpl');
var categoryFake = require('./category-fake.tpl');
/**
* View for an individual category option.
*/
module.exports = CoreView.extend({
module: 'components/modals/add-widgets/category/category-option-view',
events: {
'click': '_onSelect'
},
initialize: function (opts) {
this._initBinds();
},
render: function () {
this.clearSubViews();
this.$el.html(this._html());
this._renderLayerSelector();
var tableStats = this.options.model.stats;
if (tableStats) {
this._drawGraph();
}
return this;
},
_html: function () {
var isSelected = !!this.model.get('selected');
this.$el.toggleClass('is-selected', isSelected);
return template({
columnName: this.model.columnName(),
isSelected: isSelected
});
},
_renderLayerSelector: function () {
var view = new LayerSelectorView({
model: this.model
});
this.addView(view);
this.$('.js-inner').append(view.render().el);
},
_onSelect: function () {
this.model.set('selected', !this.model.get('selected'));
},
_drawGraph: function () {
var self = this;
this.options.model.stats.graphFor(
this.model.analysisDefinitionNodeModel().get('table_name'),
this.model.get('name'), function (graph) {
if (graph.stats && graph.stats.freqs) {
self.$('.js-Category-bar').append(graph.getCategory({
color: '#9DE0AD',
width: 240,
height: 10
}));
var stats = self.$('.js-catstats').children();
self.$(stats[0]).text(graph.getNullsPercentage() + '% null');
self.$(stats[1]).text((graph.getPercentageInTopCategories() * 100).toFixed(2) + _t('components.modals.add-widgets.percentage-in-top-cats'));
self.$('.js-catstats').css('display', 'flex');
} else {
self.$('.js-Category-bar').append(categoryFake());
}
}
);
},
_initBinds: function () {
this.listenTo(this.model, 'change:layer_index', this.render);
this.listenTo(this.model, 'change:selected', this.render);
}
});
@@ -0,0 +1,14 @@
<div class="WidgetList-option">
<input class="CDB-Checkbox js-checkbox" type="checkbox" <% if (isSelected) { %>checked="checked"<% } %> />
<span class="u-iBlock CDB-Checkbox-face"></span>
</div>
<div class="WidgetList-inner js-inner">
<h3 class="u-ellipsis CDB-Text CDB-Size-large u-bSpace--m"><%- columnName %></h3>
<ul class="js-catstats u-flex CDB-Text CDB-Size-small u-secondaryTextColor u-upperCase">
<li class='u-rSpace--m'></li>
<li class='u-rSpace--m'></li>
</ul>
<div class="u-bSpace--m js-Category-bar">
</div>
</div>
@@ -0,0 +1,37 @@
var CoreView = require('backbone/core-view');
var CategoryOptionView = require('./category-option-view.js');
/**
* View to select category widget options
*/
module.exports = CoreView.extend({
module: 'components/modals/add-widgets/category/category-options-view',
tagName: 'ul',
className: 'WidgetList',
render: function () {
this.clearSubViews();
this.$el.empty();
this.collection
.chain()
.filter(this._isCategory)
.each(this._renderOption, this);
return this;
},
_renderOption: function (m) {
var view = new CategoryOptionView({
tagName: 'li',
className: 'WidgetList-item js-WidgetList-item',
model: m
});
this.addView(view);
this.$el.append(view.render().el);
},
_isCategory: function (m) {
return m.get('type') === 'category';
}
});
@@ -0,0 +1,50 @@
var _ = require('underscore');
var BLACKLISTED_COLUMNS = ['created_at', 'the_geom', 'the_geom_webmercator', 'updated_at'];
/**
* Tmp data structure with column and layer-def models for each unique name+type tuple.
* Each bucket contains a columnModel and layerDefinitionModel tuple,
* since they are unique even if the columns' name+type happen to be the same
* From this widget definition options for specific needs
*
* @param {Object} layerDefinitionsCollection
* @return {Object} e.g.
* e.g.
* {
* foobar-string: [{
* columnModel: M({name: 'foobar', type: 'string', …}),
* layerDefinitionModel: M({ id: 'abc-123', … })
* }]
* }
*/
module.exports = function (analysisDefinitionNodesCollection, layerDefinitionsCollection) {
return analysisDefinitionNodesCollection
.reduce(function (tuplesItems, nodeDefModel) {
var layerDefinitionModel = layerDefinitionsCollection.find(function (l) { return l.isOwnerOfAnalysisNode(nodeDefModel); });
if (!layerDefinitionModel) return tuplesItems; // e.g. if the node is one of those temporary tables
var querySchemaModel = nodeDefModel.querySchemaModel;
var queryGeometryModel = nodeDefModel.queryGeometryModel;
if (!querySchemaModel || !queryGeometryModel) return tuplesItems;
if (!queryGeometryModel.isDone() || !queryGeometryModel.get('simple_geom')) {
return tuplesItems;
}
querySchemaModel.columnsCollection.each(function (m) {
var columnName = m.get('name');
if (!_.contains(BLACKLISTED_COLUMNS, columnName)) {
var key = columnName + '-' + m.get('type');
var tuples = tuplesItems[key] = tuplesItems[key] || [];
tuples.push({
analysisDefinitionNodeModel: nodeDefModel,
layerDefinitionModel: layerDefinitionModel,
columnModel: m
});
}
});
return tuplesItems;
}, {});
};
@@ -0,0 +1,4 @@
<div class='Widget-formulaFakeContainer'>
<p class='Widget-categoryFakeText'></p>
<div class='Widget-formulaFake'></div>
</div>
@@ -0,0 +1,28 @@
var _ = require('underscore');
var WidgetOptionModel = require('builder/components/modals/add-widgets/widget-option-model');
var FORMULA_TYPE = 'formula';
module.exports = WidgetOptionModel.extend({
defaults: _.defaults({type: FORMULA_TYPE}, WidgetOptionModel.defaults),
save: function (widgetDefinitionsCollection) {
var model = this;
var columnName = this.columnName();
var attrs = {
type: FORMULA_TYPE,
layer_id: this.layerDefinitionModel().id,
source: {
id: this.analysisDefinitionNodeModel().id
},
options: {
column: columnName,
title: this.get('title'),
operation: this.get('operation')
}
};
return widgetDefinitionsCollection.addWidget(model, attrs);
}
});
@@ -0,0 +1,90 @@
var CoreView = require('backbone/core-view');
var LayerSelectorView = require('builder/components/modals/add-widgets/layer-selector-view');
var template = require('./formula-option.tpl');
var formulaFake = require('./formula-fake.tpl');
var _ = require('underscore');
/**
* View for an individual formula option.
*/
module.exports = CoreView.extend({
events: {
'click': '_onSelect'
},
initialize: function (opts) {
this.listenTo(this.model, 'change:layer_index', this.render);
this.listenTo(this.model, 'change:selected', this.render);
this.aggregation = this.model.get('operation');
},
render: function () {
this.clearSubViews();
this.$el.html(this._html());
this._renderLayerSelector();
var tableStats = this.options.model.stats;
if (tableStats) {
this._drawGraph();
}
return this;
},
_html: function () {
var isSelected = !!this.model.get('selected');
this.$el.toggleClass('is-selected', isSelected);
return template({
columnName: this.model.get('title'),
isSelected: isSelected
});
},
_renderLayerSelector: function () {
var view = new LayerSelectorView({
model: this.model
});
this.addView(view);
this.$('.js-inner').append(view.render().el);
},
_onSelect: function () {
this.model.set('selected', !this.model.get('selected'));
},
_drawGraph: function () {
var self = this;
this.options.model.stats.graphFor(
this.model.analysisDefinitionNodeModel().get('table_name'),
this.model.get('name'), function (graph) {
if (graph.stats) {
var stats = self.$('.js-formulastats').children();
var aggregation = graph[{ // eslint-disable-line
'avg': 'getAverage',
'sum': 'getSum',
'min': 'getMin',
'max': 'getMax',
'count': 'getCount'
}[self.aggregation]]();
if (_.isNumber(aggregation)) {
self.$(stats[0]).text(graph.getNullsPercentage() + '% null');
if (self.aggregation === 'count') {
self.$(stats[1]).text(aggregation.toFixed().toString());
} else {
self.$(stats[1]).text(aggregation.toFixed(2).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','));
}
self.$('.js-formulastats').show();
} else {
self.$('.js-formulastats').append(formulaFake());
self.$('.js-formulastats').show();
}
} else {
self.$('.js-formulastats').append(formulaFake());
self.$('.js-formulastats').show();
}
}
);
}
});
@@ -0,0 +1,14 @@
<div class="WidgetList-option">
<input class="CDB-Checkbox js-checkbox" type="checkbox" <% if (isSelected) { %>checked="checked"<% } %> />
<span class="u-iBlock CDB-Checkbox-face"></span>
</div>
<div class="WidgetList-inner js-inner">
<h3 class="u-ellipsis CDB-Text CDB-Size-large u-bSpace--m"><%- columnName %></h3>
<div class="js-formulastats" style="display: none;">
<ul class="u-flex CDB-Text CDB-Size-small u-secondaryTextColor u-upperCase">
<li class='u-rSpace'></li>
</ul>
<h4 class="CDB-Text CDB-Size-huge u-bSpace--m"></h4>
</div>
</div>
@@ -0,0 +1,34 @@
var CoreView = require('backbone/core-view');
var FormulaOptionView = require('./formula-option-view.js');
/**
* View to select formula widget options
*/
module.exports = CoreView.extend({
className: 'WidgetList',
render: function () {
this.clearSubViews();
this.$el.empty();
this.collection
.chain()
.filter(this._isFormula)
.each(this._renderOption, this);
return this;
},
_renderOption: function (m) {
var view = new FormulaOptionView({
className: 'WidgetList-item js-WidgetList-item',
model: m
});
this.addView(view);
this.$el.append(view.render().el);
},
_isFormula: function (m) {
return m.get('type') === 'formula';
}
});
@@ -0,0 +1,4 @@
<div class="Widget-histogramFakeContainer">
<p class="Widget-categoryFakeText"></p>
<div class="Widget-histogramFake"></div>
</div>
@@ -0,0 +1,40 @@
var _ = require('underscore');
var WidgetOptionModel = require('builder/components/modals/add-widgets/widget-option-model');
var WidgetDefinitionModel = require('builder/data/widget-definition-model');
var HISTOGRAM_TYPE = 'histogram';
module.exports = WidgetOptionModel.extend({
defaults: _.defaults({type: HISTOGRAM_TYPE}, WidgetOptionModel.defaults),
save: function (widgetDefinitionsCollection) {
var model = this;
var columnName = this.columnName();
var styleModel = this.layerDefinitionModel() && this.layerDefinitionModel().styleModel;
var isAllowed = styleModel && styleModel.canApplyAutoStyle() || false;
var attrs = {
type: HISTOGRAM_TYPE,
layer_id: this.layerDefinitionModel().id,
source: {
id: this.analysisDefinitionNodeModel().id
},
options: {
column: columnName,
title: this.get('title'),
bins: this.get('bins')
},
style: {
widget_style: {
definition: WidgetDefinitionModel.getDefaultWidgetStyle(HISTOGRAM_TYPE)
},
auto_style: {
allowed: isAllowed,
custom: false
}
}
};
return widgetDefinitionsCollection.addWidget(model, attrs);
}
});
@@ -0,0 +1,76 @@
var CoreView = require('backbone/core-view');
var LayerSelectorView = require('builder/components/modals/add-widgets/layer-selector-view');
var template = require('./histogram-option.tpl');
var histogramFake = require('./histogram-fake.tpl');
/**
* View for an individual histogram option.
*/
module.exports = CoreView.extend({
events: {
'click': '_onSelect'
},
initialize: function (opts) {
this.listenTo(this.model, 'change:layer_index', this.render);
this.listenTo(this.model, 'change:selected', this.render);
},
render: function () {
this.clearSubViews();
this.$el.html(this._html());
this._renderLayerSelector();
var tableStats = this.options.model.stats;
if (tableStats) {
this._drawGraph();
}
return this;
},
_html: function () {
var isSelected = !!this.model.get('selected');
this.$el.toggleClass('is-selected', isSelected);
return template({
columnName: this.model.columnName(),
isSelected: isSelected
});
},
_renderLayerSelector: function () {
var view = new LayerSelectorView({
model: this.model
});
this.addView(view);
this.$('.js-inner').append(view.render().el);
},
_onSelect: function () {
this.model.set('selected', !this.model.get('selected'));
},
_drawGraph: function () {
var self = this;
this.options.model.stats.graphFor(
this.model.analysisDefinitionNodeModel().get('table_name'),
this.model.get('name'), function (graph) {
if (graph.stats) {
self.$('.js-Histogram').append(graph.getHistogram({
color: '#9DE0AD',
width: 240,
height: 20,
bins: 20
}));
var stats = self.$('.js-histstats').children();
self.$(stats[0]).text(graph.getNullsPercentage() + '% null');
self.$('.js-histstats').css('display', 'flex');
} else {
self.$('.js-Histogram').append(histogramFake());
}
}
);
}
});
@@ -0,0 +1,12 @@
<div class="WidgetList-option">
<input class="CDB-Checkbox js-checkbox" type="checkbox" <% if (isSelected) { %>checked="checked"<% } %> />
<span class="u-iBlock CDB-Checkbox-face"></span>
</div>
<div class="WidgetList-inner js-inner">
<h3 class="u-ellipsis CDB-Text CDB-Size-large u-bSpace--m"><%- columnName %></h3>
<ul class="js-histstats u-flex CDB-Text CDB-Size-small u-upperCase" style="display: none;">
<li class='u-rSpace'></li>
</ul>
<div class="u-tSpace--m u-bSpace--m js-Histogram"></div>
</div>
@@ -0,0 +1,34 @@
var CoreView = require('backbone/core-view');
var HistogramOptionView = require('./histogram-option-view.js');
/**
* View to select histogram widget options
*/
module.exports = CoreView.extend({
className: 'WidgetList',
render: function () {
this.clearSubViews();
this.$el.empty();
this.collection
.chain()
.filter(this._isHistogram)
.each(this._renderOption, this);
return this;
},
_renderOption: function (m) {
var view = new HistogramOptionView({
className: 'WidgetList-item js-WidgetList-item',
model: m
});
this.addView(view);
this.$el.append(view.render().el);
},
_isHistogram: function (m) {
return m.get('type') === 'histogram';
}
});
@@ -0,0 +1,123 @@
var _ = require('underscore');
var Backbone = require('backbone');
var CoreView = require('backbone/core-view');
var analyses = require('builder/data/analyses');
require('builder/components/form-components/index');
var CustomListItemView = require('builder/components/form-components/editors/select/select-layer-list-item-view');
var itemListTemplate = require('builder/components/form-components/editors/select/select-layer-item.tpl');
var selectedItemTemplate = require('builder/components/form-components/editors/select/select-layer-list-item.tpl');
/**
* View for selecting the layer through which to load the column data.
*/
module.exports = CoreView.extend({
className: 'CDB-SelectorLayer',
events: {
'click': '_onClick',
'change': '_onChange'
},
initialize: function (opts) {
this._stateModel = new Backbone.Model({
highlighted: false
});
this._initBinds();
},
render: function () {
this._unbindEvents();
this._initViews();
this._bindEvents();
return this;
},
_initBinds: function () {
this.listenTo(this._stateModel, 'change:highlighted', this._toggleHover);
},
_initViews: function () {
var self = this;
var options = this._buildOptions();
this._selectView = new Backbone.Form.editors.Select({
className: 'Widget-select u-flex u-alignCenter',
key: 'name',
schema: {
options: options
},
model: self.model,
showSearch: false,
template: require('./select.tpl'),
selectedItemTemplate: selectedItemTemplate,
customListItemView: CustomListItemView,
itemListTemplate: itemListTemplate,
mouseOverAction: this._onMouseOver.bind(this),
mouseOutAction: this._onMouseOut.bind(this)
});
this._selectView.setValue(this.model.get('layer_index') || 0);
this.$el.html(this._selectView.render().el);
},
_onMouseOver: function () {
this._stateModel.set('highlighted', true);
},
_onMouseOut: function () {
this._stateModel.set('highlighted', false);
},
_toggleHover: function () {
var $widget = this.$el.closest('.js-WidgetList-item');
$widget.toggleClass('is-hover', this._stateModel.get('highlighted'));
},
_buildOptions: function () {
return _.map(this.model.get('tuples'), function (item, index) {
var layerDefModel = item.layerDefinitionModel;
var nodeDefModel = item.analysisDefinitionNodeModel;
var layerName = nodeDefModel.isSourceType()
? layerDefModel.getTableName()
: layerDefModel.getName();
return {
val: index,
layerName: layerName,
nodeTitle: analyses.short_title(nodeDefModel),
layer_id: nodeDefModel.id,
color: layerDefModel.getColor(),
isSourceType: nodeDefModel.isSourceType()
};
});
},
_bindEvents: function () {
if (this._selectView) {
this._selectView.on('change', this._onChange, this);
}
},
_unbindEvents: function () {
if (this._selectView) {
this._selectView.off('change', this._onChange, this);
}
},
_onClick: function (event) {
event.stopPropagation();
},
_onChange: function () {
this.model.set('layer_index', this._selectView.getValue());
},
clean: function () {
this._selectView.remove();
CoreView.prototype.clean.apply(this);
}
});
@@ -0,0 +1,5 @@
<% layerNames.forEach(function (name, i) { %>
<option value="<%- i %>" <% if (i === layerIndex) { %>selected="selected"<% } %>>
<%- name %>
</option>
<% }) %>
@@ -0,0 +1,20 @@
<p class="CDB-Text CDB-Size-small u-upperCase u-altTextColor Widget-selectTitle">Select Layer</p>
<div class="CDB-InputText CDB-Text is-cursor js-button u-ellipsis
<% if (isDisabled) { %> is-disabled <% } %>
<% if (!label) { %> is-empty <% } %>"
tabindex="0">
<% if (isLoading) { %>
<div class="CDB-LoaderIcon CDB-LoaderIcon--small is-dark">
<svg class="CDB-LoaderIcon-spinner" viewBox="0 0 50 50">
<circle class="CDB-LoaderIcon-path" cx="25" cy="25" r="20" fill="none"></circle>
</svg>
</div>
<span class="u-lSpace u-secondaryTextColor"><%- _t('components.backbone-forms.select.loading') %></span>
<% } else { %>
<% if (isEmpty) { %>
<%- _t('components.backbone-forms.select.empty') %>
<% } else { %>
<%- label || _t('components.backbone-forms.select.placeholder', { keyAttr: keyAttr }) %>
<% } %>
<% } %>
</div>
@@ -0,0 +1 @@
<%- label %>
@@ -0,0 +1,6 @@
<div class="Modal-navigation">
<ul class="Modal-navigationInner CDB-Text is-semibold CDB-Size-medium js-menu"></ul>
</div>
<div class="Modal-inner Modal-inner--with-navigation js-content">
</div>
@@ -0,0 +1,332 @@
var CDB = require('internal-carto.js');
var _ = require('underscore');
function TableStats (opts) {
if (opts.user && opts.api_key) {
this.user = opts.username;
this.api_key = opts.api_key;
} else if (opts.configModel) {
this.configModel = opts.configModel;
this.userModel = opts.userModel;
}
this.tables = {};
this.queue = {};
}
TableStats.prototype = {
graphFor: function (tableName, column, callback) {
this._getPgStats(tableName, function (stats) {
var graph = new ColumnGraph(stats[column]);
callback(graph);
});
},
_getPgStats: function (table, callback) {
var userModel = this.userModel;
var self = this;
if (this.tables[table]) {
if (!_.isEmpty(this.tables[table])) {
callback(this.tables[table]);
} else {
this.queue[table].push(callback);
}
} else {
this.tables[table] = {};
this.queue[table] = [callback];
var schema = userModel.getSchemaName();
var sql = new CDB.SQL({
user: this.configModel.get('user_name'),
sql_api_template: this.configModel.get('sql_api_template'),
api_key: this.configModel.get('api_key')
});
sql.execute(
'with a as (select reltuples from pg_class where relname = \'' + table + '\'), b as (select * from pg_stats where tablename = \'' + table + '\' and schemaname = \'' + schema + '\') select * from a,b',
null,
{
rows_per_page: 40,
page: 0
}
).done(function (data) {
data = data || {};
var rows = data.rows.map(function (r) {
var count = r.reltuples;
var bounds = self._reformatToJSON(r.histogram_bounds);
if (bounds) {
var avgs = bounds.reduce(function (p, c, i) {
if (i < bounds.length - 1) {
p.push((bounds[i + 1] + c) / 2);
}
return p;
}, []);
var binsize = count / (bounds.length - 1);
var sum = avgs.reduce(function (p, c) {
return p + c * binsize;
}, 0);
var average = sum / count;
var min = bounds[0];
var max = _.last(bounds);
}
var mostCommon = self._reformatToJSON(r.most_common_vals);
var trues;
if (mostCommon) {
var trueIndex = mostCommon.indexOf('t');
var falseIndex = mostCommon.indexOf('f');
if (trueIndex > -1 && falseIndex > -1) {
trues = r.most_common_freqs[trueIndex];
}
}
return {
column: r.attname,
histogram_bounds: bounds,
freqs: r.most_common_freqs,
mostcommon: mostCommon,
nulls: r.null_frac,
count: count,
sum: sum,
min: min,
max: max,
avg: average,
trues: trues
};
});
var stats = rows.reduce(function (p, c) {
var column = c.column;
delete c.column;
p[column] = c;
return p;
}, {});
self.tables[table] = stats;
self._processQueue(table);
});
}
},
_processQueue: function (table) {
var self = this;
this.queue[table].forEach(function (callback) {
callback(self.tables[table]);
});
},
_reformatToJSON: function (sqlData) {
if (!sqlData) return null;
sqlData = sqlData.replace('{', '').replace('}', '').split(',');
if (!sqlData.some(isNaN)) {
sqlData = sqlData.map(function (n) {
return parseInt(n, 10);
});
}
return sqlData;
}
};
function ColumnGraph (stats, options) {
options = options || {};
this.stats = stats;
this.normalize = typeof options.normalize === 'undefined' ? true : options.normalize;
}
ColumnGraph.prototype = {
getNullsPercentage: function () {
return this.stats.nulls;
},
getPercentageInTopCategories: function (topx) {
return this.stats.freqs.slice(0, topx || 10).reduce(function (p, c) { return p + c; });
},
getTrues: function () {
return this.stats.trues;
},
getHistogram: function (options) {
return this._generateHistogram(options.width, options.height, options.color, options.bins);
},
getCategory: function (options) {
return this._generateCategory(options.width, options.height, options.color);
},
getMin: function () {
return this.stats.min;
},
getMax: function () {
return this.stats.max;
},
getAverage: function () {
return this.stats.avg;
},
getCount: function () {
return this.stats.count;
},
getSum: function () {
return this.stats.sum;
},
_generateCategory: function (width, height, color) {
if (!this.stats.freqs) return;
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
var proportion;
if (_.isNumber(this.getTrues())) {
proportion = this.getTrues();
} else {
proportion = this.getPercentageInTopCategories();
}
canvas.height = height || 10;
canvas.width = width || 400;
var spacing = 2;
var begin = spacing;
var end = canvas.width - spacing * 2;
var greenPosition = (end - begin) * proportion + begin;
context.lineCap = 'round';
context.strokeStyle = '#EDEDED';
context.lineWidth = 4;
context.beginPath();
context.moveTo(begin, canvas.height / 2);
context.lineTo(end, canvas.height / 2);
context.stroke();
context.strokeStyle = color;
context.beginPath();
context.moveTo(begin, canvas.height / 2);
context.lineTo(greenPosition, canvas.height / 2);
context.stroke();
return canvas;
},
_generateHistogram: function (width, height, color, bins) {
var histogram;
if (_.isEmpty(this.stats)) {
return;
}
if (this.stats.freqs && this.stats.freqs.length < 60) {
histogram = this._getHistogramFromFreqs(bins);
} else if (this.stats.histogram_bounds) {
histogram = this._getHistogramFromBounds(bins);
}
if (!histogram || _.isEmpty(histogram)) {
return;
}
if (this.normalize) {
var max = _.max(histogram);
histogram = histogram.map(function (bin) {
return bin / max;
});
}
bins = histogram.length;
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
canvas.height = height || 90;
canvas.width = width || 400;
var barSeparation = 2;
var barWidth = (canvas.width - barSeparation * (bins - 1)) / bins;
context.strokeStyle = '#EDEDED';
context.lineWidth = 1;
// Guides
context.beginPath();
context.moveTo(0, 0);
context.lineTo(canvas.width, 0);
context.stroke();
context.beginPath();
context.moveTo(0, canvas.height / 2);
context.lineTo(canvas.width, canvas.height / 2);
context.stroke();
// Bars
histogram.forEach(function (bar, index) {
var x = index * (barWidth + barSeparation);
var barHeight = canvas.height * bar;
context.beginPath();
context.fillStyle = color;
context.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
});
// Base line
context.strokeStyle = '#A1BBA7';
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, canvas.height);
context.lineTo(canvas.width, canvas.height);
context.stroke();
return canvas;
},
_getHistogramFromBounds: function (bins) {
var bounds = this.stats.histogram_bounds;
bounds = bounds.sort(function (a, b) { return a - b; });
var min = bounds[0];
var max = bounds[bounds.length - 1];
var width = (max - min) / bins;
var histogram = new Array(bins + 1).join('0').split('').map(parseFloat);
function scaleBin (value) {
if (max === min) return min;
return Math.floor((bins - 1) * (value - min) / (max - min));
}
var boundProportion = 1 / (bounds.length - 1);
for (var i = 0; i < bounds.length - 1; ++i) {
var binMin = scaleBin(bounds[i]);
var binMax = scaleBin(bounds[i + 1]);
if (binMin === binMax) {
histogram[binMin] += boundProportion;
} else {
var proportionForFirstBin = ((min + width * (binMin + 1)) - bounds[i]) / (bounds[i + 1] - bounds[i]);
histogram[binMin] += proportionForFirstBin * boundProportion;
var proportionForLastBin = (bounds[i + 1] - (min + width * (binMax))) / (bounds[i + 1] - bounds[i]);
histogram[binMax] += proportionForLastBin * boundProportion;
var remaining = boundProportion - proportionForFirstBin - proportionForLastBin;
_.range(binMin + 1, binMax).forEach(function (binIndex, i, array) {
histogram[binIndex] = remaining / array.length;
});
}
}
return histogram;
},
_getHistogramFromFreqs: function (bins) {
var values = this.stats.mostcommon;
var freqs = this.stats.freqs;
bins = Math.min(bins, values.length);
var min = _.min(values);
var max = _.max(values);
// If there is only one value, histogram should not be rendered
if (min === max) {
return [];
}
var histogram = new Array(bins + 1).join('0').split('').map(parseFloat);
function scale (value) {
if (max === min) return min;
return (bins - 1) * (value - min) / (max - min);
}
values.forEach(function (v, i) {
histogram[Math.floor(scale(v))] += freqs[i];
});
return histogram;
}
};
module.exports = TableStats;
@@ -0,0 +1,22 @@
var _ = require('underscore');
var WidgetOptionModel = require('builder/components/modals/add-widgets/widget-option-model');
/**
* Special case for the time-series type, when if selected it deletes an existing time-series if there is one,
* otherwise it does nothing.
*/
module.exports = WidgetOptionModel.extend({
defaults: _.defaults({type: 'time-series'}, WidgetOptionModel.defaults),
save: function (widgetDefinitionsCollection) {
var m = widgetDefinitionsCollection.find(this._isTimesSeries);
if (m) {
m.destroy({ wait: true });
}
},
_isTimesSeries: function (m) {
return m.get('type') === 'time-series';
}
});
@@ -0,0 +1,37 @@
var CoreView = require('backbone/core-view');
var template = require('./time-series-none-option.tpl');
/**
* Represents the (default) no-option view, i.e. since time-series options only can have one selection,
* this serves as the "unselect" option.
*/
module.exports = CoreView.extend({
events: {
'click': '_onSelect'
},
initialize: function (opts) {
this.listenTo(this.model, 'change:selected', this.render);
},
render: function () {
this.$el.html(this._html());
return this;
},
_html: function () {
var isSelected = !!this.model.get('selected');
this.$el.toggleClass('is-selected', isSelected);
return template({
isSelected: isSelected
});
},
_onSelect: function () {
this.model.set('selected', !this.model.get('selected'));
}
});
@@ -0,0 +1,9 @@
<div class="WidgetList-option">
<input class="CDB-Radio u-iBlock js-radio" type="radio" <% if (isSelected) { %>checked="checked"<% } %> />
<span class="u-iBlock CDB-Radio-face"></span>
</div>
<div class="WidgetList-inner js-inner">
<h3 class="WidgetList-title CDB-Text CDB-Size-large u-bSpace--xl"><%- _t('components.modals.add-widgets.time-series-no-option-title') %></h3>
<p class="CDB-Text CDB-Size-small u-altTextColor"><%- _t('components.modals.add-widgets.time-series-no-option-desc') %></p>
</div>
@@ -0,0 +1,81 @@
var _ = require('underscore');
var WidgetOptionModel = require('builder/components/modals/add-widgets/widget-option-model');
var WidgetDefinitionModel = require('builder/data/widget-definition-model');
var TIME_SERIES_TYPE = 'time-series';
module.exports = WidgetOptionModel.extend({
defaults: _.defaults({type: TIME_SERIES_TYPE}, WidgetOptionModel.defaults),
save: function (widgetDefinitionsCollection) {
var model = this;
var columnName = this.columnName();
var layerId = this.layerDefinitionModel().id;
var attrs = {
type: TIME_SERIES_TYPE,
layer_id: layerId,
source: {
id: this.analysisDefinitionNodeModel().id
},
options: {
column: columnName,
title: this.get('title')
},
style: {
widget_style: {
definition: WidgetDefinitionModel.getDefaultWidgetStyle(TIME_SERIES_TYPE)
}
}
};
var attrsSave = {
type: TIME_SERIES_TYPE,
layer_id: layerId,
source: this.analysisDefinitionNodeModel().id,
column: columnName,
title: this.get('title'),
widget_style_definition: WidgetDefinitionModel.getDefaultWidgetStyle(TIME_SERIES_TYPE)
};
// Depending on column type, widget should have bins or aggregation
if (this.get('aggregation')) {
attrs.options.aggregation = this.get('aggregation');
attrsSave.aggregation = this.get('aggregation');
} else if (this.get('bins')) {
attrs.options.bins = this.get('bins');
attrsSave.bins = this.get('bins');
}
var successReplaceHandler = function () {
widgetDefinitionsCollection.trigger('successReplace', model);
};
var errorHandler = function (model, e) {
widgetDefinitionsCollection.trigger('error', model, e);
};
var createUpdatingNotification = function () {
widgetDefinitionsCollection.trigger('updating', model);
};
var existingModel = widgetDefinitionsCollection.find(this._isTimesSeries);
if (existingModel) {
// Update existing widget, but only if the column or layer differs
if (existingModel.get('column') !== columnName || existingModel.get('layer_id') !== layerId) {
createUpdatingNotification();
return existingModel.save(attrsSave, {
wait: true,
success: successReplaceHandler,
error: errorHandler
});
}
} else {
return widgetDefinitionsCollection.addWidget(model, attrs);
}
},
_isTimesSeries: function (model) {
return model.get('type') === TIME_SERIES_TYPE;
}
});
@@ -0,0 +1,49 @@
var CoreView = require('backbone/core-view');
var LayerSelectorView = require('builder/components/modals/add-widgets/layer-selector-view');
var template = require('./time-series-option.tpl');
/**
* View for an individual time-series option.
*/
module.exports = CoreView.extend({
events: {
'click': '_onSelect'
},
initialize: function (opts) {
this.listenTo(this.model, 'change:layer_index', this.render);
this.listenTo(this.model, 'change:selected', this.render);
},
render: function () {
this.clearSubViews();
this.$el.html(this._html());
this._renderLayerSelector();
return this;
},
_html: function () {
var isSelected = !!this.model.get('selected');
this.$el.toggleClass('is-selected', isSelected);
return template({
columnName: this.model.columnName(),
isSelected: isSelected
});
},
_renderLayerSelector: function () {
var view = new LayerSelectorView({
model: this.model
});
this.addView(view);
this.$('.js-inner').append(view.render().el);
},
_onSelect: function () {
this.model.set('selected', !this.model.get('selected'));
}
});
@@ -0,0 +1,8 @@
<div class="WidgetList-option">
<input class="CDB-Radio u-iBlock js-radio" type="radio" <% if (isSelected) { %>checked="checked"<% } %> />
<span class="u-iBlock CDB-Radio-face"></span>
</div>
<div class="WidgetList-inner js-inner">
<h3 class="u-ellipsis CDB-Text CDB-Size-large u-bSpace--m"><%- columnName %></h3>
</div>
@@ -0,0 +1,56 @@
var CoreView = require('backbone/core-view');
var TimeSeriesOptionView = require('./time-series-option-view.js');
var TimeSeriesNoneOptionView = require('./time-series-none-option-view.js');
/**
* View to select time-series widget options
*/
module.exports = CoreView.extend({
module: 'components/modals/add-widgets/time-series/time-series-options-view',
className: 'WidgetList',
initialize: function () {
this.listenTo(this.collection, 'change:selected', this._onSelectedChange);
},
render: function () {
this.clearSubViews();
this.$el.empty();
this.collection
.chain()
.filter(this._isTimeSeries)
.each(this._renderOption, this);
return this;
},
_renderOption: function (m) {
var ViewClass = m.has('tuples')
? TimeSeriesOptionView
: TimeSeriesNoneOptionView;
var view = new ViewClass({
className: 'WidgetList-item js-WidgetList-item',
model: m
});
this.addView(view);
this.$el.append(view.render().el);
},
_isTimeSeries: function (m) {
return m.get('type') === 'time-series';
},
_onSelectedChange: function (selectedModel, isSelected) {
// Make sure there can only be one selected time-series widget option
if (isSelected) {
this.collection.each(function (m) {
if (this._isTimeSeries(m) && m !== selectedModel) {
m.set('selected', false);
}
}, this);
}
}
});
@@ -0,0 +1,32 @@
var Backbone = require('backbone');
module.exports = Backbone.Model.extend({
defaults: {
layer_index: 0,
tuples: []
},
analysisDefinitionNodeModel: function () {
return this._selectedItem().analysisDefinitionNodeModel;
},
layerDefinitionModel: function () {
return this._selectedItem().layerDefinitionModel;
},
columnName: function () {
return this._selectedItem().columnModel.get('name');
},
save: function () {
throw new Error('save should be implemented by child');
},
_selectedItem: function () {
var i = this.get('layer_index') || 0;
var tuples = this.get('tuples') || [];
return tuples[i] || {};
}
});
@@ -0,0 +1,190 @@
var _ = require('underscore');
var CategoryOptionModel = require('./category/category-option-model');
var CategoryOptionsView = require('./category/category-options-view');
var HistogramOptionModel = require('./histogram/histogram-option-model');
var HistogramOptionsView = require('./histogram/histogram-options-view');
var FormulaOptionModel = require('./formula/formula-option-model');
var FormulaOptionsView = require('./formula/formula-options-view');
var TimeSeriesOptionModel = require('./time-series/time-series-option-model');
var TimeSeriesNoneOptionModel = require('./time-series/time-series-none-option-model');
var TimeSeriesOptionsView = require('./time-series/time-series-options-view');
var ScrollView = require('builder/components/scroll/scroll-view');
var CARTODB_ID = 'cartodb_id';
// Order is the same in how things will be presented in the UI
module.exports = [
{
type: 'category',
createOptionModels: function (tuplesItems) {
return _.reduce(tuplesItems, function (memo, tuples) {
var columnModel = tuples[0].columnModel;
var type = columnModel.get('type');
var m;
if (type === 'string' || type === 'boolean') {
m = new CategoryOptionModel({
tuples: tuples,
title: columnModel.get('name'),
name: columnModel.get('name'),
aggregation: 'count' // or sum
});
memo.push(m);
}
return memo;
}, []);
},
createTabPaneItem: function (optionsCollection) {
return {
label: _t('components.modals.add-widgets.tab-pane.category-label'),
name: 'category',
createContentView: function () {
return new ScrollView({
createContentView: function () {
return new CategoryOptionsView({
collection: optionsCollection
});
}
});
}
};
}
},
{
type: 'histogram',
createOptionModels: function (tuplesItems) {
return _.reduce(tuplesItems, function (memo, tuples) {
var columnModel = tuples[0].columnModel;
if (columnModel.get('type') === 'number') {
var m = new HistogramOptionModel({
tuples: tuples,
title: columnModel.get('name'),
name: columnModel.get('name'),
bins: 10
});
memo.push(m);
}
return memo;
}, []);
},
createTabPaneItem: function (optionsCollection) {
return {
label: _t('components.modals.add-widgets.tab-pane.histogram-label'),
name: 'histogram',
createContentView: function () {
return new ScrollView({
createContentView: function () {
return new HistogramOptionsView({
collection: optionsCollection
});
}
});
}
};
}
},
{
type: 'formula',
createOptionModels: function (tuplesItems) {
return _.reduce(tuplesItems, function (memo, tuples) {
var columnModel = tuples[0].columnModel;
var columnName = columnModel.get('name');
var operation = 'avg';
var title = columnName;
if (columnModel.get('type') === 'number') {
if (columnName === CARTODB_ID) {
operation = 'count';
title = _t('editor.data.stats.feature-count');
}
var m = new FormulaOptionModel({
tuples: tuples,
title: title,
operation: operation,
name: columnModel.get('name')
});
memo.push(m);
}
return memo;
}, []);
},
createTabPaneItem: function (optionsCollection) {
return {
label: _t('components.modals.add-widgets.tab-pane.formula-label'),
name: 'formula',
createContentView: function () {
return new ScrollView({
createContentView: function () {
return new FormulaOptionsView({
collection: optionsCollection
});
}
});
}
};
}
},
{
type: 'time-series',
createOptionModels: function (tuplesItems, widgetDefinitionsCollection) {
var defaults = {
bins: 256
};
var existingDefModel = widgetDefinitionsCollection.find(function (m) {
return m.get('type') === 'time-series';
});
var models = _.reduce(tuplesItems, function (memo, tuples) {
var columnModel = tuples[0].columnModel;
var columnName = columnModel.get('name');
var columnType = columnModel.get('type');
if (columnType === 'date' || columnType === 'number') {
var attrs = {
tuples: tuples,
title: columnName
};
if (columnType === 'date') {
attrs.aggregation = defaults.aggregation;
} else if (columnType === 'number') {
attrs.bins = defaults.bins;
}
// Preselect the correct tuple, if there already exists a widget definition
if (existingDefModel && existingDefModel.get('column') === columnName) {
_.find(tuples, function (d, i) {
if (d.layerDefinitionModel.id === existingDefModel.get('layer_id')) {
attrs.selected = true;
attrs.layer_index = i;
return true;
}
});
}
var m = new TimeSeriesOptionModel(attrs);
memo.push(m);
}
return memo;
}, []);
// Special-case; preprend the none-model since it should appear first
var noneOptionModel = new TimeSeriesNoneOptionModel();
return [noneOptionModel].concat(models);
},
createTabPaneItem: function (optionsCollection) {
return {
label: _t('components.modals.add-widgets.tab-pane.time-series-label'),
name: 'time-series',
createContentView: function () {
return new ScrollView({
createContentView: function () {
return new TimeSeriesOptionsView({
collection: optionsCollection
});
}
});
}
};
}
}
];