Compare commits

...

21 Commits

Author SHA1 Message Date
Arik Fraimovich
a9e135c94f Exclude venv dir from the release package 2014-02-05 09:48:11 +02:00
Arik Fraimovich
212ade2da7 Add query_id back to widgets in tables.sql until we remove it from Model 2014-02-05 09:47:51 +02:00
Arik Fraimovich
f939bf6108 Merge pull request #76 from EverythingMe/fix_75
Fix #75: Large numbers shown as NaN/NaN/NaN NaN:NaN
2014-02-04 23:45:01 -08:00
Arik Fraimovich
3360cd934b Remove backward compatability workaround (fixes #75) 2014-02-05 09:41:03 +02:00
Arik Fraimovich
f35a0970ac Merge pull request #74 from EverythingMe/update_readme
Readme update (added reference to mailing list & IRC channel)
2014-02-04 07:37:04 -08:00
Arik Fraimovich
97ca722a11 Small fix to README. 2014-02-04 17:02:24 +02:00
Arik Fraimovich
e554c9bdd7 Merge pull request #73 from EverythingMe/viz
Visualization Support
2014-02-04 07:01:40 -08:00
Arik Fraimovich
567a732e1e Readme update (added reference to mailing list & IRC channel) 2014-02-04 16:58:36 +02:00
Amir Nissim
5b532d03a0 version bump 0.2 2014-02-04 16:56:35 +02:00
Amir Nissim
cd838e5a7e migrating Widgets to Visualizations 2014-02-04 16:11:48 +02:00
Amir Nissim
bb096be00c Visualization.name length to 255 (should match Query.name length) 2014-02-04 15:16:07 +02:00
Amir Nissim
7b78bfe191 add Visualization and SERIES types 2014-02-03 16:35:16 +02:00
Amir Nissim
a45ba0bf30 Dashboard visualizations 2014-02-03 16:12:29 +02:00
Amir Nissim
5ce3699a58 QueryFiddle: Live chart type editing 2014-02-03 15:01:41 +02:00
Amir Nissim
1cd836ac8d Live visualization config POC (title only) 2014-02-02 18:20:18 +02:00
Amir Nissim
c83705119d delete visualizations 2014-02-02 13:23:01 +02:00
Amir Nissim
fdd2cfe1d1 edit and create visualizations 2014-02-02 13:23:01 +02:00
Amir Nissim
8327baa2f6 create Visualization cont. 2014-02-02 13:23:01 +02:00
Amir Nissim
84df2fb85c create Visualization [WIP] 2014-02-02 13:23:01 +02:00
Amir Nissim
cab6f9e58d Visualization models 2014-02-02 13:23:01 +02:00
Amir Nissim
d2ace5c1cf Visualization UI:
* queryfiddle page
 * new widget form
2014-02-02 13:23:01 +02:00
16 changed files with 860 additions and 427 deletions

View File

@@ -1,5 +1,5 @@
NAME=redash
VERSION=0.1
VERSION=0.2
FULL_VERSION=$(VERSION).$(CIRCLE_BUILD_NUM)
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(FULL_VERSION).tar.gz
@@ -10,7 +10,7 @@ deps:
cd rd_ui && grunt build
pack:
tar -zcv -f $(FILENAME) --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
tar -zcv -f $(FILENAME) --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
upload:
python bin/upload_version.py $(FULL_VERSION) $(FILENAME)

View File

@@ -21,6 +21,11 @@ You can try out the demo instance: http://rd-demo.herokuapp.com/ (login with any
Due to Heroku dev plan limits, it has a small database of flights (see schema [here](http://rd-demo.herokuapp.com/dashboard/schema)). Also due to another Heroku limitation, it is running with the regular user, hence you can DELETE or INSERT data/tables. Please be nice and don't do this.
## Getting help
* [Google Group (mailing list)](https://groups.google.com/forum/#!forum/redash-users): the best place to get updates about new releases or ask general questions.
* #redash IRC channel on [Freenode](http://www.freenode.net/).
## Technology
* [AngularJS](http://angularjs.org/)

View File

@@ -51,7 +51,8 @@ class Query(models.Model):
app_label = 'redash'
db_table = 'queries'
def to_dict(self, with_result=True, with_stats=False):
def to_dict(self, with_result=True, with_stats=False,
with_visualizations=False):
d = {
'id': self.id,
'latest_query_data_id': self.latest_query_data_id,
@@ -75,6 +76,10 @@ class Query(models.Model):
if with_result and self.latest_query_data_id:
d['latest_query_data'] = self.latest_query_data.to_dict()
if with_visualizations:
d['visualizations'] = [vis.to_dict(with_query=False)
for vis in self.visualizations.all()]
return d
@classmethod
@@ -148,10 +153,41 @@ class Dashboard(models.Model):
return u"%s=%s" % (self.id, self.name)
class Visualization(models.Model):
id = models.AutoField(primary_key=True)
type = models.CharField(max_length=100)
query = models.ForeignKey(Query, related_name='visualizations')
name = models.CharField(max_length=255)
description = models.CharField(max_length=4096)
options = models.TextField()
class Meta:
app_label = 'redash'
db_table = 'visualizations'
def to_dict(self, with_query=True):
d = {
'id': self.id,
'type': self.type,
'name': self.name,
'description': self.description,
'options': json.loads(self.options),
}
if with_query:
d['query'] = self.query.to_dict()
return d
def __unicode__(self):
return u"%s=>%s" % (self.id, self.query_id)
class Widget(models.Model):
id = models.AutoField(primary_key=True)
query = models.ForeignKey(Query)
type = models.CharField(max_length=100)
query = models.ForeignKey(Query, related_name='widgets')
visualization = models.ForeignKey(Visualization, related_name='widgets')
width = models.IntegerField()
options = models.TextField()
dashboard = models.ForeignKey(Dashboard, related_name='widgets')
@@ -163,10 +199,10 @@ class Widget(models.Model):
def to_dict(self):
return {
'id': self.id,
'query': self.query.to_dict(),
'type': self.type,
'width': self.width,
'options': json.loads(self.options),
'visualization': self.visualization.to_dict(),
'dashboard_id': self.dashboard_id
}

View File

@@ -30,12 +30,22 @@ CREATE TABLE "dashboards" (
"is_archived" boolean NOT NULL
)
;
CREATE TABLE "visualizations" (
"id" serial NOT NULL PRIMARY KEY,
"type" varchar(100) NOT NULL,
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
"name" varchar(255) NOT NULL,
"description" varchar(4096),
"options" text NOT NULL
)
;
CREATE TABLE "widgets" (
"id" serial NOT NULL PRIMARY KEY,
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
"type" varchar(100) NOT NULL,
"width" integer NOT NULL,
"options" text NOT NULL,
"query_id" integer,
"visualization_id" integer NOT NULL REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED,
"dashboard_id" integer NOT NULL REFERENCES "dashboards" ("id") DEFERRABLE INITIALLY DEFERRED
)
;
@@ -43,4 +53,4 @@ CREATE INDEX "queries_latest_query_data_id" ON "queries" ("latest_query_data_id"
CREATE INDEX "widgets_query_id" ON "widgets" ("query_id");
CREATE INDEX "widgets_dashboard_id" ON "widgets" ("dashboard_id");
COMMIT;
COMMIT;

50
rd_service/migrate.py Normal file
View File

@@ -0,0 +1,50 @@
import json
import settings
from data.models import *
# first run:
# CREATE TABLE "visualizations" (
# "id" serial NOT NULL PRIMARY KEY,
# "type" varchar(100) NOT NULL,
# "query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
# "name" varchar(255) NOT NULL,
# "description" varchar(4096),
# "options" text NOT NULL
# )
# ;
# ALTER TABLE widgets ADD COLUMN "visualization_id" integer REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED;
if __name__ == '__main__':
print 'migrating Widgets -> Visualizations ...'
for query in Query.objects.filter(name__icontains="cohort"):
vis = Visualization(query=query, name=query.name,
description=query.description,
type="COHORT", options="{}")
vis.save()
for widget in Widget.objects.all():
print 'processing widget %d' % widget.id
query = widget.query
vis_type = widget.type.upper()
vis = query.visualizations.filter(type=vis_type)
if vis:
print 'found'
widget.visualization = vis[0]
widget.save()
else:
options = json.loads(widget.options)
vis_options = {"series": options} if options else {}
vis_options = json.dumps(vis_options)
vis = Visualization(query=query, name=query.name,
description=query.description,
type=vis_type, options=vis_options)
vis.save()
widget.visualization = vis
widget.save()

View File

@@ -167,7 +167,7 @@ class WidgetsHandler(BaseAuthenticatedHandler):
class DashboardHandler(BaseAuthenticatedHandler):
def get(self, dashboard_slug=None):
if dashboard_slug:
dashboard = data.models.Dashboard.objects.prefetch_related('widgets__query__latest_query_data').get(slug=dashboard_slug)
dashboard = data.models.Dashboard.objects.prefetch_related('widgets__visualization__query__latest_query_data').get(slug=dashboard_slug)
self.write_json(dashboard.to_dict(with_widgets=True))
else:
dashboards = [d.to_dict() for d in
@@ -204,6 +204,7 @@ class QueriesHandler(BaseAuthenticatedHandler):
query_def['created_at'] = dateutil.parser.parse(query_def['created_at'])
query_def.pop('latest_query_data', None)
query_def.pop('visualizations', None)
if id:
query = data.models.Query(**query_def)
@@ -221,7 +222,7 @@ class QueriesHandler(BaseAuthenticatedHandler):
if id:
q = data.models.Query.objects.get(pk=id)
if q:
self.write_json(q.to_dict())
self.write_json(q.to_dict(with_visualizations=True))
else:
self.send_error(404)
else:
@@ -251,6 +252,30 @@ class QueryResultsHandler(BaseAuthenticatedHandler):
self.write({'job': job.to_dict()})
class VisualizationHandler(BaseAuthenticatedHandler):
def get(self, id):
pass
def post(self, id=None):
kwargs = json.loads(self.request.body)
kwargs['options'] = json.dumps(kwargs['options'])
if id:
vis = data.models.Visualization(**kwargs)
fields = kwargs.keys()
fields.remove('id')
vis.save(update_fields=fields)
else:
vis = data.models.Visualization(**kwargs)
vis.save()
self.write_json(vis.to_dict(with_query=False))
def delete(self, id):
vis = data.models.Visualization.objects.get(pk=id)
vis.delete()
class CsvQueryResultsHandler(BaseAuthenticatedHandler):
def get_current_user(self):
user = super(CsvQueryResultsHandler, self).get_current_user()
@@ -312,6 +337,7 @@ def get_application(static_path, is_debug, redis_connection, data_manager):
(r"/api/queries(?:/([0-9]*))?", QueriesHandler),
(r"/api/query_results(?:/([0-9]*))?", QueryResultsHandler),
(r"/api/jobs/(.*)", JobsHandler),
(r"/api/visualizations(?:/([0-9]*))?", VisualizationHandler),
(r"/api/widgets(?:/([0-9]*))?", WidgetsHandler),
(r"/api/dashboards(?:/(.*))?", DashboardHandler),
(r"/admin/(.*)", MainHandler),

View File

@@ -7,7 +7,7 @@
var WidgetCtrl = function ($scope, $http, $location, Query) {
$scope.deleteWidget = function() {
if (!confirm('Are you sure you want to remove "' + $scope.widget.query.name + '" from the dashboard?')) {
if (!confirm('Are you sure you want to remove "' + $scope.widget.visualization.name + '" from the dashboard?')) {
return;
}
@@ -24,7 +24,7 @@
$location.path('/queries/' + query.id);
}
$scope.query = new Query($scope.widget.query);
$scope.query = new Query($scope.widget.visualization.query);
$scope.queryResult = $scope.query.getQueryResult();
$scope.updateTime = (new Date($scope.queryResult.getUpdatedAt())).toISOString();
@@ -33,12 +33,14 @@
$scope.updateTime = '';
}
var QueryFiddleCtrl = function ($scope, $window, $routeParams, $http, $location, growl, notifications, Query) {
var QueryFiddleCtrl = function ($scope, $window, $location, $routeParams, $http, $location, growl, notifications, Query, Visualization) {
var DEFAULT_TAB = 'table';
var pristineHash = null;
$scope.dirty = undefined;
var leavingPageText = "You will lose your changes if you leave";
$scope.dirty = undefined;
$scope.newVisualization = undefined;
$window.onbeforeunload = function(){
if (currentUser.canEdit($scope.query) && $scope.dirty) {
return leavingPageText;
@@ -72,8 +74,9 @@
$scope.$parent.pageTitle = "Query Fiddle";
$scope.tabs = [{'key': 'table', 'name': 'Table'}, {'key': 'chart', 'name': 'Chart'},
{'key': 'pivot', 'name': 'Pivot Table'}, {'key': 'cohort', 'name': 'Cohort'}];
$scope.$watch(function() {return $location.hash()}, function(hash) {
$scope.selectedTab = hash || DEFAULT_TAB;
});
$scope.lockButton = function (lock) {
$scope.queryExecuting = lock;
@@ -211,13 +214,26 @@
$scope.queryResult = $scope.query.getQueryResult(0);
$scope.lockButton(true);
$scope.cancelling = false;
}
};
$scope.cancelExecution = function() {
$scope.cancelling = true;
$scope.queryResult.cancelExecution();
}
};
$scope.deleteVisualization = function($e, vis) {
$e.preventDefault();
if (confirm('Are you sure you want to delete ' + vis.name + ' ?')) {
Visualization.delete(vis);
if ($scope.selectedTab == vis.id) {
$scope.selectedTab = DEFAULT_TAB;
}
$scope.query.visualizations =
$scope.query.visualizations.filter(function(v) {
return vis.id !== v.id;
});
}
};
}
var QueriesCtrl = function($scope, $http, $location, $filter, Query) {
@@ -373,7 +389,7 @@
.controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl])
.controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl])
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
.controller('QueryFiddleCtrl', ['$scope', '$window', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl])
.controller('QueryFiddleCtrl', ['$scope', '$window', '$location', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', 'Visualization', QueryFiddleCtrl])
.controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl])
.controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]);
})();

View File

@@ -1,256 +1,399 @@
var directives = angular.module('redash.directives', []);
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
return {
restrict: 'E',
scope: {
tabsCollection: '=',
selectedTab: '='
},
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="#{{tab.key}}">{{tab.name}}</a></li></ul>',
replace: true,
link: function($scope, element, attrs) {
$scope.selectTab = function(tabKey) {
$scope.selectedTab = _.find($scope.tabsCollection, function(tab) { return tab.key == tabKey; });
(function() {
'use strict';
var directives = angular.module('redash.directives', []);
directives.directive('rdTab', ['$location', function($location) {
return {
restrict: 'E',
scope: {
'id': '@',
'name': '@'
},
transclude: true,
template: '<li class="rd-tab" ng-class="{active: id==selectedTab}"><a href="#{{id}}">{{name}}<span ng-transclude></span></a></li>',
replace: true,
link: function(scope) {
scope.$watch(function(){return scope.$parent.selectedTab}, function(tab) {
scope.selectedTab = tab;
});
}
$scope.$watch(function() { return $location.hash()}, function(hash) {
if (hash) {
$scope.selectTab($location.hash());
} else {
$scope.selectTab($scope.tabsCollection[0].key);
}
});
}
}
}])
}]);
directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Dashboard', function($http, $location, $timeout, Dashboard) {
return {
restrict: 'E',
scope: {
dashboard: '='
},
templateUrl: '/views/edit_dashboard.html',
replace: true,
link: function($scope, element, attrs) {
var gridster = element.find(".gridster ul").gridster({
widget_margins: [5, 5],
widget_base_dimensions: [260, 100],
min_cols: 2,
max_cols: 2,
serialize_params: function($w, wgd) {
return {
col: wgd.col,
row: wgd.row,
id: $w.data('widget-id')
}
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
return {
restrict: 'E',
scope: {
tabsCollection: '=',
selectedTab: '='
},
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="#{{tab.key}}">{{tab.name}}</a></li></ul>',
replace: true,
link: function($scope, element, attrs) {
$scope.selectTab = function(tabKey) {
$scope.selectedTab = _.find($scope.tabsCollection, function(tab) { return tab.key == tabKey; });
}
}).data('gridster');
var gsItemTemplate = '<li data-widget-id="{id}" class="widget panel panel-default gs-w">' +
'<div class="panel-heading">{name}' +
'</div></li>';
$scope.$watch('dashboard.widgets', function(widgets) {
$timeout(function () {
gridster.remove_all_widgets();
if (widgets && widgets.length) {
var layout = [];
_.each(widgets, function(row, rowIndex) {
_.each(row, function(widget, colIndex) {
layout.push({
id: widget.id,
col: colIndex+1,
row: rowIndex+1,
ySize: 1,
xSize: widget.width,
name: widget.query.name
});
});
});
_.each(layout, function(item) {
var el = gsItemTemplate.replace('{id}', item.id).replace('{name}', item.name);
gridster.add_widget(el, item.xSize, item.ySize, item.col, item.row);
});
$scope.$watch(function() { return $location.hash()}, function(hash) {
if (hash) {
$scope.selectTab($location.hash());
} else {
$scope.selectTab($scope.tabsCollection[0].key);
}
});
}, true);
$scope.saveDashboard = function() {
$scope.saveInProgress = true;
// TODO: we should use the dashboard service here.
if ($scope.dashboard.id) {
var positions = $(element).find('.gridster ul').data('gridster').serialize();
var layout = [];
_.each(_.sortBy(positions, function (pos) {
return pos.row * 10 + pos.col;
}), function (pos) {
var row = pos.row - 1;
var col = pos.col - 1;
layout[row] = layout[row] || [];
if (col > 0 && layout[row][col - 1] == undefined) {
layout[row][col - 1] = pos.id;
} else {
layout[row][col] = pos.id;
}
});
$scope.dashboard.layout = layout;
layout = JSON.stringify(layout);
$http.post('/api/dashboards/' + $scope.dashboard.id, {'name': $scope.dashboard.name, 'layout': layout}).success(function(response) {
$scope.dashboard = new Dashboard(response);
$scope.saveInProgress = false;
$(element).modal('hide');
})
} else {
$http.post('/api/dashboards', {'name': $scope.dashboard.name}).success(function(response) {
$(element).modal('hide');
$location.path('/dashboard/' + response.slug).replace();
})
}
}
}
}
}])
}]);
directives.directive('editVisulatizationForm', ['Visualization', 'growl', function(Visualization, growl) {
return {
restrict: 'E',
templateUrl: '/views/edit_visualization.html',
replace: true,
scope: {
query: '=',
vis: '=?'
},
link: function(scope, element, attrs) {
scope.advancedMode = false;
scope.visTypes = {
'Chart': Visualization.prototype.TYPES.CHART,
'Cohort': Visualization.prototype.TYPES.COHORT,
'Table': Visualization.prototype.TYPES.GRID
};
scope.seriesTypes = {
'Line': Visualization.prototype.SERIES_TYPES.LINE,
'Bar': Visualization.prototype.SERIES_TYPES.BAR,
'Area': Visualization.prototype.SERIES_TYPES.AREA
};
directives.directive('newWidgetForm', ['$http', function($http) {
return {
restrict: 'E',
scope: {
dashboard: '='
},
templateUrl: '/views/new_widget_form.html',
replace: true,
link: function($scope, element, attrs) {
$scope.widgetTypes = [{name: 'Chart', value: 'chart'}, {name: 'Table', value: 'grid'}, {name: 'Cohort', value: 'cohort'}];
$scope.widgetSizes = [{name: 'Regular Size', value: 1}, {name: 'Double Size', value: 2}];
var reset = function() {
$scope.saveInProgress = false;
$scope.widgetType = 'chart';
$scope.widgetSize = 1;
$scope.queryId = null;
}
reset();
$scope.saveWidget = function() {
$scope.saveInProgress = true;
var widget = {
'query_id': $scope.queryId,
'dashboard_id': $scope.dashboard.id,
'type': $scope.widgetType,
'options': {},
'width': $scope.widgetSize
if (!scope.vis) {
// create new visualization
// wait for query to load to populate with defaults
var unwatch = scope.$watch('query', function(q) {
if (q && q.id) {
unwatch();
scope.vis = {
'query_id': q.id,
'type': Visualization.prototype.TYPES.CHART,
'name': q.name,
'description': q.description,
'options': newOptions()
};
}
}, true);
}
$http.post('/api/widgets', widget).success(function(response) {
// update dashboard layout
$scope.dashboard.layout = response['layout'];
if (response['new_row']) {
$scope.dashboard.widgets.push([response['widget']]);
} else {
$scope.dashboard.widgets[$scope.dashboard.widgets.length-1].push(response['widget']);
function newOptions(chartType) {
if (chartType === Visualization.prototype.TYPES.COHORT) {
// empty config at the moment
return {};
}
// close the dialog
$('#add_query_dialog').modal('hide');
reset();
// Chart
return {
'series': {
'type': Visualization.prototype.SERIES_TYPES.LINE
}
};
}
scope.toggleAdvancedMode = function() {
scope.advancedMode = !scope.advancedMode;
};
scope.typeChanged = function() {
scope.vis.options = newOptions();
};
scope.submit = function() {
Visualization.save(scope.vis, function success(result) {
growl.addSuccessMessage("Visualization saved");
scope.vis = result;
var visIds = _.pluck(scope.query.visualizations, 'id');
var index = visIds.indexOf(result.id);
if (index > -1) {
scope.query.visualizations[index] = result;
} else {
scope.query.visualizations.push(result);
}
}, function error() {
growl.addErrorMessage("Visualization could not be saved");
});
};
}
}
}]);
directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Dashboard', function($http, $location, $timeout, Dashboard) {
return {
restrict: 'E',
scope: {
dashboard: '='
},
templateUrl: '/views/edit_dashboard.html',
replace: true,
link: function($scope, element, attrs) {
var gridster = element.find(".gridster ul").gridster({
widget_margins: [5, 5],
widget_base_dimensions: [260, 100],
min_cols: 2,
max_cols: 2,
serialize_params: function($w, wgd) {
return {
col: wgd.col,
row: wgd.row,
id: $w.data('widget-id')
}
}
}).data('gridster');
var gsItemTemplate = '<li data-widget-id="{id}" class="widget panel panel-default gs-w">' +
'<div class="panel-heading">{name}' +
'</div></li>';
$scope.$watch('dashboard.widgets', function(widgets) {
$timeout(function () {
gridster.remove_all_widgets();
if (widgets && widgets.length) {
var layout = [];
_.each(widgets, function(row, rowIndex) {
_.each(row, function(widget, colIndex) {
layout.push({
id: widget.id,
col: colIndex+1,
row: rowIndex+1,
ySize: 1,
xSize: widget.width,
name: widget.visualization.name
});
});
});
_.each(layout, function(item) {
var el = gsItemTemplate.replace('{id}', item.id).replace('{name}', item.name);
gridster.add_widget(el, item.xSize, item.ySize, item.col, item.row);
});
}
});
}, true);
$scope.saveDashboard = function() {
$scope.saveInProgress = true;
// TODO: we should use the dashboard service here.
if ($scope.dashboard.id) {
var positions = $(element).find('.gridster ul').data('gridster').serialize();
var layout = [];
_.each(_.sortBy(positions, function (pos) {
return pos.row * 10 + pos.col;
}), function (pos) {
var row = pos.row - 1;
var col = pos.col - 1;
layout[row] = layout[row] || [];
if (col > 0 && layout[row][col - 1] == undefined) {
layout[row][col - 1] = pos.id;
} else {
layout[row][col] = pos.id;
}
});
$scope.dashboard.layout = layout;
layout = JSON.stringify(layout);
$http.post('/api/dashboards/' + $scope.dashboard.id, {'name': $scope.dashboard.name, 'layout': layout}).success(function(response) {
$scope.dashboard = new Dashboard(response);
$scope.saveInProgress = false;
$(element).modal('hide');
})
} else {
$http.post('/api/dashboards', {'name': $scope.dashboard.name}).success(function(response) {
$(element).modal('hide');
$location.path('/dashboard/' + response.slug).replace();
})
}
}
}
}
}]);
directives.directive('newWidgetForm', ['$http', 'Query', function($http, Query) {
return {
restrict: 'E',
scope: {
dashboard: '='
},
templateUrl: '/views/new_widget_form.html',
replace: true,
link: function($scope, element, attrs) {
$scope.widgetSizes = [{name: 'Regular', value: 1}, {name: 'Double', value: 2}];
var reset = function() {
$scope.saveInProgress = false;
$scope.widgetSize = 1;
$scope.queryId = null;
$scope.selectedVis = null;
}
reset();
$scope.toggleView = function(viewName) {
$scope.currentView = ($scope.currentView == viewName) ? '' : viewName;
};
$scope.loadVisualizations = function() {
if (!$scope.queryId) {
return;
}
Query.get({
id: $scope.queryId
}, function(query) {
if (query) {
$scope.query = query;
if(query.visualizations.length) {
$scope.selectedVis = query.visualizations[0];
}
}
});
};
$scope.saveWidget = function() {
$scope.saveInProgress = true;
var widget = {
'visualization_id': $scope.selectedVis.id,
'dashboard_id': $scope.dashboard.id,
'options': {},
'width': $scope.widgetSize
}
$http.post('/api/widgets', widget).success(function(response) {
// update dashboard layout
$scope.dashboard.layout = response['layout'];
if (response['new_row']) {
$scope.dashboard.widgets.push([response['widget']]);
} else {
$scope.dashboard.widgets[$scope.dashboard.widgets.length-1].push(response['widget']);
}
// close the dialog
$('#add_query_dialog').modal('hide');
reset();
})
}
}
}
}])
// From: http://jsfiddle.net/joshdmiller/NDFHg/
directives.directive('editInPlace', function () {
return {
restrict: 'E',
scope: {
value: '=',
ignoreBlanks: '=',
editable: '='
},
template: function(tElement, tAttrs) {
var elType = tAttrs.editor || 'input';
var placeholder = tAttrs.placeholder || 'Click to edit';
return '<span ng-click="editable && edit()" ng-bind="value" ng-class="{editable: editable}"></span>' +
'<span ng-click="editable && edit()" ng-show="editable && !value" ng-class="{editable: editable}">' + placeholder + '</span>' +
'<{elType} ng-model="value" class="form-control" rows="2"></{elType}>'.replace('{elType}', elType);
},
link: function ($scope, element, attrs) {
// Let's get a reference to the input element, as we'll want to reference it.
var inputElement = angular.element(element.children()[2]);
// This directive should have a set class so we can style it.
element.addClass('edit-in-place');
// Initially, we're not editing.
$scope.editing = false;
// ng-click handler to activate edit-in-place
$scope.edit = function () {
if ($scope.ignoreBlanks) {
$scope.oldValue = $scope.value;
}
$scope.editing = true;
// We control display through a class on the directive itself. See the CSS.
element.addClass('active');
// And we must focus the element.
// `angular.element()` provides a chainable array, like jQuery so to access a native DOM function,
// we have to reference the first element in the array.
inputElement[0].focus();
};
$(inputElement).blur(function() {
if ($scope.ignoreBlanks && _.isEmpty($scope.value)) {
$scope.value = $scope.oldValue;
}
$scope.editing = false;
element.removeClass('active');
})
}
};
});
}
}
}])
// http://stackoverflow.com/a/17904092/1559840
directives.directive('jsonText', function() {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attr, ngModel) {
function into(input) {
return JSON.parse(input);
}
function out(data) {
return JSON.stringify(data, undefined, 2);
}
ngModel.$parsers.push(into);
ngModel.$formatters.push(out);
// From: http://jsfiddle.net/joshdmiller/NDFHg/
directives.directive('editInPlace', function () {
return {
restrict: 'E',
scope: {
value: '=',
ignoreBlanks: '=',
editable: '='
},
template: function(tElement, tAttrs) {
var elType = tAttrs.editor || 'input';
var placeholder = tAttrs.placeholder || 'Click to edit';
return '<span ng-click="editable && edit()" ng-bind="value" ng-class="{editable: editable}"></span>' +
'<span ng-click="editable && edit()" ng-show="editable && !value" ng-class="{editable: editable}">' + placeholder + '</span>' +
'<{elType} ng-model="value" class="form-control" rows="2"></{elType}>'.replace('{elType}', elType);
},
link: function ($scope, element, attrs) {
// Let's get a reference to the input element, as we'll want to reference it.
var inputElement = angular.element(element.children()[2]);
}
};
});
// This directive should have a set class so we can style it.
element.addClass('edit-in-place');
directives.directive('rdTimer', ['$timeout', function ($timeout) {
return {
restrict: 'E',
scope: { timestamp: '=' },
template: '{{currentTime}}',
controller: ['$scope' ,function ($scope) {
$scope.currentTime = "00:00:00";
var currentTimeout = null;
// Initially, we're not editing.
$scope.editing = false;
// ng-click handler to activate edit-in-place
$scope.edit = function () {
if ($scope.ignoreBlanks) {
$scope.oldValue = $scope.value;
var updateTime = function() {
$scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format("HH:mm:ss")
currentTimeout = $timeout(updateTime, 1000);
}
$scope.editing = true;
// We control display through a class on the directive itself. See the CSS.
element.addClass('active');
// And we must focus the element.
// `angular.element()` provides a chainable array, like jQuery so to access a native DOM function,
// we have to reference the first element in the array.
inputElement[0].focus();
};
$(inputElement).blur(function() {
if ($scope.ignoreBlanks && _.isEmpty($scope.value)) {
$scope.value = $scope.oldValue;
var cancelTimer = function() {
if (currentTimeout) {
$timeout.cancel(currentTimeout);
currentTimeout = null;
}
}
$scope.editing = false;
element.removeClass('active');
})
}
};
});
directives.directive('rdTimer', ['$timeout', function ($timeout) {
return {
restrict: 'E',
scope: { timestamp: '=' },
template: '{{currentTime}}',
controller: ['$scope' ,function ($scope) {
$scope.currentTime = "00:00:00";
var currentTimeout = null;
updateTime();
var updateTime = function() {
$scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format("HH:mm:ss")
currentTimeout = $timeout(updateTime, 1000);
}
var cancelTimer = function() {
if (currentTimeout) {
$timeout.cancel(currentTimeout);
currentTimeout = null;
}
}
updateTime();
$scope.$on('$destroy', function () {
cancelTimer();
});
}]
};
}]);
$scope.$on('$destroy', function () {
cancelTimer();
});
}]
};
}]);
})();

View File

@@ -1,34 +1,114 @@
'use strict';
(function(){
'use strict';
angular.module('highchart', [])
.directive('chart', ['$timeout', function ($timeout) {
return {
restrict: 'E',
template: '<div></div>',
scope: {
options: "=options",
series: "=series"
var defaultOptions = {
title: {
"text": null
},
tooltip: {
valueDecimals: 2,
formatter: function () {
if (moment.isMoment(this.x)) {
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
pointsCount = this.points.length;
$.each(this.points, function (i, point) {
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
if (pointsCount > 1 && point.percentage) {
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
}
});
} else {
var s = "<b>" + this.points[0].key + "</b>";
$.each(this.points, function (i, point) {
s+= '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
});
}
return s;
},
transclude: true,
replace: true,
shared: true
},
xAxis: {
type: 'datetime'
},
yAxis: {
title: {
text: null
}
},
exporting: {
chartOptions: {
title: {
text: ''
}
},
buttons: {
contextButton: {
menuItems: [
{
text: 'Toggle % Stacking',
onclick: function () {
var newStacking = "normal";
if (this.series[0].options.stacking == "normal") {
newStacking = "percent";
}
link: function (scope, element, attrs) {
var chartsDefaults = {
chart: {
renderTo: element[0],
type: attrs.type || null,
height: attrs.height || null,
width: attrs.width || null
}
};
_.each(this.series, function (series) {
series.update({stacking: newStacking}, true);
});
}
}
]
}
}
},
credits: {
enabled: false
},
plotOptions: {
"column": {
"stacking": "normal",
"pointPadding": 0,
"borderWidth": 1,
"groupPadding": 0,
"shadow": false
}
},
series: []
};
var deepCopy = true;
var newSettings = {};
$.extend(deepCopy, newSettings, chartsDefaults, scope.options);
angular.module('highchart', [])
.directive('chart', ['$timeout', function ($timeout) {
return {
restrict: 'E',
template: '<div></div>',
scope: {
options: "=options",
series: "=series"
},
transclude: true,
replace: true,
// Making sure that the DOM is ready before creating the chart element, so it gets proper width.
$timeout(function(){
scope.chart = new Highcharts.Chart(newSettings);
link: function (scope, element, attrs) {
var chartsDefaults = {
chart: {
renderTo: element[0],
type: attrs.type || null,
height: attrs.height || null,
width: attrs.width || null
}
};
var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
// Update when options change
scope.$watch('options', function(newOptions) {
initChart(newOptions);
}, true);
//Update when charts data changes
scope.$watch(function () {
@@ -37,47 +117,65 @@ angular.module('highchart', [])
if (!length || length == 0) {
scope.chart.showLoading();
} else {
while(scope.chart.series.length > 0) {
scope.chart.series[0].remove(true);
}
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
scope.chart.xAxis[0].update({type: 'category'});
// We need to make sure that for each category, each series has a value.
var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')}));
_.each(scope.series, function(s) {
// TODO: move this logic to Query#getChartData
var yValues = _.groupBy(s.data, 'x');
var newData = _.sortBy(_.map(categories, function(category) {
return {
name: category,
y: yValues[category] && yValues[category][0].y
}
}), 'name');
s.data = newData;
});
} else {
scope.chart.xAxis[0].update({type: 'datetime'});
}
scope.chart.counters.color = 0;
_.each(scope.series, function(s) {
scope.chart.addSeries(s);
})
scope.chart.redraw();
scope.chart.hideLoading();
drawChart();
};
}, true);
});
function initChart(options) {
if (scope.chart) {
scope.chart.destroy();
}
}
};
$.extend(true, chartOptions, options);
}]);
scope.chart = new Highcharts.Chart(chartOptions);
drawChart();
}
function drawChart() {
while(scope.chart.series.length > 0) {
scope.chart.series[0].remove(true);
}
// todo series.type
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
scope.chart.xAxis[0].update({type: 'category'});
// We need to make sure that for each category, each series has a value.
var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')}));
_.each(scope.series, function(s) {
// TODO: move this logic to Query#getChartData
var yValues = _.groupBy(s.data, 'x');
var newData = _.sortBy(_.map(categories, function(category) {
return {
name: category,
y: yValues[category] && yValues[category][0].y
}
}), 'name');
s.data = newData;
});
} else {
scope.chart.xAxis[0].update({type: 'datetime'});
}
scope.chart.counters.color = 0;
_.each(scope.series, function(s) {
// here we override the series with the visualization config
var _s = $.extend(true, {}, s, chartOptions['series']);
scope.chart.addSeries(_s);
})
scope.chart.redraw();
scope.chart.hideLoading();
}
}
};
}]);
})();

View File

@@ -1,83 +1,20 @@
var renderers = angular.module('redash.renderers', []);
var defaultChartOptions = {
"title": {
"text": null
},
"tooltip": {
valueDecimals: 2,
formatter: function () {
if (moment.isMoment(this.x)) {
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
pointsCount = this.points.length;
$.each(this.points, function (i, point) {
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
if (pointsCount > 1 && point.percentage) {
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
}
});
} else {
var s = "<b>" + this.points[0].key + "</b>";
$.each(this.points, function (i, point) {
s+= '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
});
}
return s;
renderers.directive('visualizationRenderer', function() {
return {
restrict: 'E',
scope: {
visualization: '=',
queryResult: '='
},
shared: true
},
xAxis: {
type: 'datetime'
},
yAxis: {
title: {
text: null
}
},
exporting: {
chartOptions: {
title: {
text: this.description
}
},
buttons: {
contextButton: {
menuItems: [
{
text: 'Toggle % Stacking',
onclick: function () {
var newStacking = "normal";
if (this.series[0].options.stacking == "normal") {
newStacking = "percent";
}
_.each(this.series, function (series) {
series.update({stacking: newStacking}, true);
});
}
}
]
}
}
},
credits: {
enabled: false
},
plotOptions: {
"column": {
"stacking": "normal",
"pointPadding": 0,
"borderWidth": 1,
"groupPadding": 0,
"shadow": false
}
},
"series": []
};
template: '<div ng-switch on="visualization.type">' +
'<chart-renderer ng-switch-when="CHART" options="visualization.options" query-result="queryResult"></chart-renderer>' +
'<grid-renderer ng-switch-when="GRID" options="visualization.options" query-result="queryResult"></grid-renderer>' +
'<cohort-renderer ng-switch-when="COHORT" options="visualization.options" query-result="queryResult"></cohort-renderer>' +
'</div>',
replace: false
}
});
renderers.directive('chartRenderer', function () {
return {
@@ -90,8 +27,13 @@ renderers.directive('chartRenderer', function () {
replace: false,
controller: ['$scope', function ($scope) {
$scope.chartSeries = [];
$scope.chartOptions = defaultChartOptions;
$scope.chartOptions = {};
$scope.$watch('options', function(chartOptions) {
if (chartOptions) {
$scope.chartOptions = chartOptions;
}
});
$scope.$watch('queryResult && queryResult.getData()', function (data) {
if (!data || $scope.queryResult.getData() == null) {
$scope.chartSeries.splice(0, $scope.chartSeries.length);
@@ -99,7 +41,7 @@ renderers.directive('chartRenderer', function () {
$scope.chartSeries.splice(0, $scope.chartSeries.length);
_.each($scope.queryResult.getChartData(), function (s) {
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}, $scope.options));
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}));
});
}
});
@@ -142,13 +84,7 @@ renderers.directive('gridRenderer', function () {
var gridData = _.map($scope.queryResult.getData(), function (row) {
var newRow = {};
_.each(row, function (val, key) {
// TODO: hack to detect date fields, needed only for backward compatability
if (val > 1000 * 1000 * 1000 * 100) {
newRow[$scope.queryResult.getColumnCleanName(key)] = moment(val);
} else {
newRow[$scope.queryResult.getColumnCleanName(key)] = val;
}
newRow[$scope.queryResult.getColumnCleanName(key)] = val;
})
return newRow;
});
@@ -264,4 +200,4 @@ renderers.directive('cohortRenderer', function() {
});
}
}
})
})

View File

@@ -253,7 +253,7 @@
}
return QueryResult;
}
};
var Query = function ($resource, QueryResult) {
var Query = $resource('/api/queries/:id', {id: '@id'});
@@ -263,6 +263,7 @@
ttl = this.ttl;
}
var queryResult = null;
if (this.latest_query_data && ttl != 0) {
queryResult = new QueryResult({'query_result': this.latest_query_data});
@@ -273,17 +274,37 @@
}
return queryResult;
}
};
Query.prototype.getHash = function() {
return [this.name, this.description, this.query].join('!#');
}
};
return Query;
}
};
var Visualization = function($resource) {
var Visualization = $resource('/api/visualizations/:id', {id: '@id'});
Visualization.prototype = {
TYPES: {
'CHART': 'CHART',
'COHORT': 'COHORT',
'GRID': 'GRID'
},
SERIES_TYPES: {
'LINE': 'line',
'BAR': 'bar',
'AREA': 'area'
}
};
return Visualization;
};
angular.module('redash.services', [])
.factory('QueryResult', ['$resource', '$timeout', QueryResult])
.factory('Query', ['$resource', 'QueryResult', Query])
.factory('Visualization', ['$resource', Visualization])
})();

View File

@@ -2,6 +2,10 @@ body {
padding-top: 70px;
}
a.link {
cursor: pointer;
}
a.page-title {
overflow: hidden;
text-overflow: ellipsis;
@@ -56,6 +60,10 @@ a.navbar-brand {
margin-bottom: 0px;
}
.panel-heading > a {
color: inherit;
}
/* angular-growl */
.growl {
position: fixed;
@@ -193,4 +201,16 @@ to add those CSS styles here. */
-webkit-border-radius: 6px 0 6px 6px;
-moz-border-radius: 6px 0 6px 6px;
border-radius: 6px 0 6px 6px;
}
.rd-tab .remove {
cursor: pointer;
color: #A09797;
padding: 0 3px 1px 4px;
font-size: 11px;
}
.rd-tab .remove:hover {
color: white;
background-color: #FF8080;
border-radius: 50%;
}

View File

@@ -8,7 +8,7 @@
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" href="#edit_dashboard_dialog" tooltip="Edit Dashboard (Name/Layout)"><span
class="glyphicon glyphicon-cog"></span></button>
<button type="button" class="btn btn-default btn-xs" data-toggle="modal"
href="#add_query_dialog" tooltip="Add Widget (Chart/Table)"><span class="glyphicon glyphicon-import"></span>
href="#add_query_dialog" tooltip="Add Widget (Chart/Table)"><span class="glyphicon glyphicon-plus"></span>
</button>
</span>
</h2>
@@ -29,11 +29,8 @@
</h3>
</div>
<div ng-switch on="widget.type" class="panel-body">
<chart-renderer ng-switch-when="chart" query-result="queryResult" options="widget.options"></chart-renderer>
<grid-renderer ng-switch-when="grid" query-result="queryResult"></grid-renderer>
<cohort-renderer ng-switch-when="cohort" query-result="queryResult"></cohort-renderer>
</div>
<visualization-renderer visualization="widget.visualization" query-result="queryResult"></visualization-renderer class="panel-body">
<div class="panel-footer">
<span class="label label-default"
tooltip="next update {{nextUpdateTime}} (query runtime: {{queryResult.getRuntime() | durationHumanize}})"

View File

@@ -0,0 +1,31 @@
<form role="form" name="visForm" ng-submit="submit()">
<div class="form-group">
<label class="control-label">Name</label>
<input type="text" class="form-control" ng-model="vis.name" placeholder="{{query.name}}">
</div>
<div class="form-group">
<label class="control-label">Description</label>
<textarea class="form-control" ng-model="vis.description" placeholder="{{query.description}}"></textarea>
</div>
<div class="form-group">
<label class="control-label">Visualization Type</label>
<select required ng-model="vis.type" ng-options="value as key for (key, value) in visTypes" class="form-control" ng-change="typeChanged()"></select>
</div>
<div class="form-group" ng-show="vis.type == visTypes.Chart">
<label class="control-label">Chart Type</label>
<select required ng-model="vis.options.series.type" ng-options="value as key for (key, value) in seriesTypes" class="form-control"></select>
</div>
<div class="form-group">
<a class="link" ng-click="toggleAdvancedMode()">Advanced</a>
<textarea json-text class="form-control" rows="5" ng-model="vis.options" ng-show="advancedMode"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>

View File

@@ -7,15 +7,34 @@
</div>
<div class="modal-body">
<p>
<input type="text" class="form-control" placeholder="Query Id" ng-model="queryId">
<form class="form-inline" role="form" ng-submit="loadVisualizations()">
<div class="form-group">
<input class="form-control" placeholder="Query Id" ng-model="queryId">
</div>
<button type="submit" class="btn btn-primary" ng-disabled="!queryId">
<span class="glyphicon glyphicon-refresh"></span> Load
</button>
</form>
</p>
<p>
<select class="form-control" ng-model="widgetType" ng-options="c.value as c.name for c in widgetTypes"></select>
</p>
<p>
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select>
</p>
<div ng-show="query">
<div class="form-group">
<label for="">Choose Visualation</label>
<select ng-model="selectedVis" ng-options="vis as vis.name group by vis.type for vis in query.visualizations" class="form-control"></select>
</div>
<div class="form-group">
<a ng-click="toggleView('addNew')" class="link">&plus; Add New</a>
<div class="well" ng-show="currentView=='addNew'">
<edit-visulatization-form query="query"></edit-visulatization-form>
</div>
</div>
<div class="form-group">
<label for="">Widget Size</label>
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-disabled="saveInProgress" data-dismiss="modal">Close</button>

View File

@@ -51,22 +51,47 @@
</div>
<div class="row" ng-show="queryResult.getStatus() == 'done'">
<rd-tabs tabs-collection='tabs' selected-tab='selectedTab'></rd-tabs>
<ul class="nav nav-tabs">
<rd-tab id="table" name="Table"></rd-tab>
<rd-tab id="pivot" name="Pivot Table"></rd-tab>
<rd-tab id="{{vis.id}}" name="{{vis.name}}" ng-repeat="vis in query.visualizations">
<span class="remove" ng-click="deleteVisualization($event, vis)"> &times;</span>
</rd-tab>
<rd-tab id="add" name="&plus;New" removeable="true"></rd-tab>
</ul>
<div ng-show="selectedTab.key == 'chart'" class="col-lg-12">
<chart-renderer query-result="queryResult"></chart-renderer>
</div>
<div class="col-lg-12" ng-show="selectedTab.key == 'table'">
<div class="col-lg-12" ng-show="selectedTab == 'table'">
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
</div>
<div class="col-lg-12" ng-show="selectedTab.key == 'pivot'">
<div class="col-lg-12" ng-show="selectedTab == 'pivot'">
<pivot-table-renderer query-result="queryResult"></pivot-table-renderer>
</div>
<div class="col-lg-12" ng-show="selectedTab.key == 'cohort'">
<cohort-renderer query-result="queryResult"></cohort-renderer>
<div class="col-lg-12" ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
<div class="row">
<p>
<div class="col-lg-6">
<edit-visulatization-form vis="vis" query="query"></edit-visulatization-form>
</div>
<div class="col-lg-6">
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
</div>
</p>
</div>
</div>
<div class="col-lg-12" ng-show="selectedTab == 'add'">
<div class="row">
<p>
<div class="col-lg-6">
<edit-visulatization-form vis="newVisualization" query="query"></edit-visulatization-form>
</div>
<div class="col-lg-6">
<visualization-renderer visualization="newVisualization" query-result="queryResult"></visualization-renderer>
</div>
</p>
</div>
</div>
</div>
</div>