diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index 004da809..05145c8c 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -111,6 +111,10 @@ + + + + diff --git a/rd_ui/app/scripts/app.js b/rd_ui/app/scripts/app.js index 04718e98..52ac7029 100644 --- a/rd_ui/app/scripts/app.js +++ b/rd_ui/app/scripts/app.js @@ -5,6 +5,7 @@ angular.module('redash', [ 'redash.filters', 'redash.services', 'redash.renderers', + 'redash.visualization', 'ui.codemirror', 'highchart', 'angular-growl', diff --git a/rd_ui/app/scripts/directives.js b/rd_ui/app/scripts/directives.js index 6f171989..d9d3fa47 100644 --- a/rd_ui/app/scripts/directives.js +++ b/rd_ui/app/scripts/directives.js @@ -3,7 +3,7 @@ var directives = angular.module('redash.directives', []); - directives.directive('rdTab', ['$location', function($location) { + directives.directive('rdTab', function() { return { restrict: 'E', scope: { @@ -19,9 +19,9 @@ }); } } - }]); + }); - directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) { + directives.directive('rdTabs', ['$location', function($location) { return { restrict: 'E', scope: { @@ -46,130 +46,6 @@ } }]); - 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 - }; - scope.seriesTypes = { - 'Line': 'line', - 'Column': 'column', - 'Area': 'area', - 'Scatter': 'scatter', - 'Pie': 'pie' - }; - - scope.stackingOptions = { - "None": "none", - "Normal": "normal", - "Percent": "percent" - }; - - scope.stacking = "none"; - - 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(); - - if (!scope.vis) { - scope.vis = { - 'query_id': q.id, - 'type': Visualization.prototype.TYPES.CHART - }; - } - } - }, true); - } - - function newOptions(chartType) { - if (chartType === Visualization.prototype.TYPES.CHART) { - return { - 'series': { - 'type': 'column', - 'stacking': null - } - }; - }; - - return {}; - } - - var chartOptionsUnwatch = null; - - scope.$watch('vis.type', function(type) { - // if not edited by user, set name to match type - if (type && scope.vis && !scope.visForm.name.$dirty) { - // poor man's titlecase - scope.vis.name = scope.vis.type[0] + scope.vis.type.slice(1).toLowerCase(); - } - - if (type && type == Visualization.prototype.TYPES.CHART) { - if (scope.vis.options.series.stacking === null) { - scope.stacking = "none"; - } else if (scope.vis.options.series.stacking === undefined) { - scope.stacking = "normal"; - } else { - scope.stacking = scope.vis.options.series.stacking ; - } - - chartOptionsUnwatch = scope.$watch("stacking", function(stacking) { - if (stacking == "none") { - scope.vis.options.series.stacking = null; - } else { - scope.vis.options.series.stacking = stacking; - } - }); - } else { - if (chartOptionsUnwatch) { - chartOptionsUnwatch(); - chartOptionsUnwatch = null; - } - } - }); - - scope.toggleAdvancedMode = function() { - scope.advancedMode = !scope.advancedMode; - }; - - scope.typeChanged = function() { - scope.vis.options = newOptions(scope.vis.type); - }; - - 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', @@ -281,6 +157,7 @@ $scope.widgetSize = 1; $scope.queryId = null; $scope.selectedVis = null; + $scope.query = null; } diff --git a/rd_ui/app/scripts/query_fiddle/renderers.js b/rd_ui/app/scripts/query_fiddle/renderers.js index e747ac18..1eb965e1 100644 --- a/rd_ui/app/scripts/query_fiddle/renderers.js +++ b/rd_ui/app/scripts/query_fiddle/renderers.js @@ -1,134 +1,5 @@ var renderers = angular.module('redash.renderers', []); -renderers.directive('visualizationRenderer', function() { - return { - restrict: 'E', - scope: { - visualization: '=', - queryResult: '=' - }, - template: '
' + - '' + - '' + - '' + - '
', - replace: false - } -}); - -renderers.directive('chartRenderer', function () { - return { - restrict: 'E', - scope: { - queryResult: '=', - options: '=?' - }, - template: "", - replace: false, - controller: ['$scope', function ($scope) { - $scope.chartSeries = []; - $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); - } else { - $scope.chartSeries.splice(0, $scope.chartSeries.length); - - _.each($scope.queryResult.getChartData(), function (s) { - $scope.chartSeries.push(_.extend(s, {'stacking': 'normal'})); - }); - } - }); - }] - } -}) - -renderers.directive('gridRenderer', function () { - return { - restrict: 'E', - scope: { - queryResult: '=', - itemsPerPage: '=' - }, - templateUrl: "/views/grid_renderer.html", - replace: false, - controller: ['$scope', function ($scope) { - $scope.gridColumns = []; - $scope.gridData = []; - $scope.gridConfig = { - isPaginationEnabled: true, - itemsByPage: $scope.itemsPerPage || 15, - maxSize: 8 - }; - - $scope.$watch('queryResult && queryResult.getData()', function (data) { - if (!data) { - return; - } - - if ($scope.queryResult.getData() == null) { - $scope.gridColumns = []; - $scope.gridData = []; - $scope.filters = []; - } else { - - - $scope.filters = $scope.queryResult.getFilters(); - - var gridData = _.map($scope.queryResult.getData(), function (row) { - var newRow = {}; - _.each(row, function (val, key) { - newRow[$scope.queryResult.getColumnCleanName(key)] = val; - }) - return newRow; - }); - - $scope.gridColumns = _.map($scope.queryResult.getColumnCleanNames(), function (col, i) { - var columnDefinition = { - 'label': $scope.queryResult.getColumnFriendlyNames()[i], - 'map': col - }; - - if (gridData.length > 0) { - var exampleData = gridData[0][col]; - if (angular.isNumber(exampleData)) { - columnDefinition['formatFunction'] = 'number'; - columnDefinition['formatParameter'] = 2; - } else if (moment.isMoment(exampleData)) { - columnDefinition['formatFunction'] = function(value) { - return value.format("DD/MM/YY HH:mm"); - } - } - } - - return columnDefinition; - }); - - $scope.gridData = _.clone(gridData); - - $scope.$watch('filters', function (filters) { - $scope.gridData = _.filter(gridData, function (row) { - return _.reduce(filters, function (memo, filter) { - if (filter.current == 'All') { - return memo && true; - } - - return (memo && row[$scope.queryResult.getColumnCleanName(filter.name)] == filter.current); - }, true); - }); - }, true); - } - }); - }] - } -}) - renderers.directive('pivotTableRenderer', function () { return { restrict: 'E', @@ -152,52 +23,4 @@ renderers.directive('pivotTableRenderer', function () { }); } } -}) - -renderers.directive('cohortRenderer', function() { - return { - restrict: 'E', - scope: { - queryResult: '=' - }, - template: "", - replace: false, - link: function($scope, element, attrs) { - $scope.$watch('queryResult && queryResult.getData()', function (data) { - if (!data) { - return; - } - - if ($scope.queryResult.getData() == null) { - - } else { - var sortedData = _.sortBy($scope.queryResult.getData(), "date"); - var grouped = _.groupBy(sortedData, "date"); - var data = _.map(grouped, function(values, date) { - var row = [values[0].total]; - _.each(values, function(value) { row.push(value.value); }); - return row; - }); - - var initialDate = moment(sortedData[0].date).toDate(), - container = angular.element(element)[0]; - - Cornelius.draw({ - initialDate: initialDate, - container: container, - cohort: data, - title: null, - timeInterval: 'daily', - labels: { - time: 'Activation Day', - people: 'Users' - }, - formatHeaderLabel: function (i) { - return "Day " + (i - 1); - } - }); - } - }); - } - } -}) +}); \ No newline at end of file diff --git a/rd_ui/app/scripts/services.js b/rd_ui/app/scripts/services.js index 7216c2d2..cf7efc58 100644 --- a/rd_ui/app/scripts/services.js +++ b/rd_ui/app/scripts/services.js @@ -283,23 +283,7 @@ return Query; }; - var Visualization = function($resource) { - var Visualization = $resource('/api/visualizations/:id', {id: '@id'}); - - Visualization.prototype = { - TYPES: { - 'CHART': 'CHART', - 'COHORT': 'COHORT', - 'TABLE': 'TABLE' - } - }; - - return Visualization; - }; - angular.module('redash.services', []) .factory('QueryResult', ['$resource', '$timeout', QueryResult]) .factory('Query', ['$resource', 'QueryResult', Query]) - .factory('Visualization', ['$resource', Visualization]) - })(); diff --git a/rd_ui/app/scripts/visualizations/base.js b/rd_ui/app/scripts/visualizations/base.js new file mode 100644 index 00000000..7d9e5071 --- /dev/null +++ b/rd_ui/app/scripts/visualizations/base.js @@ -0,0 +1,149 @@ +(function () { + var VisualizationProvider = function() { + this.visualizations = {}; + this.visualizationTypes = {}; + var defaultConfig = { + defaultOptions: {}, + skipTypes: false, + editorTemplate: null + } + + this.registerVisualization = function(config) { + var visualization = _.extend({}, defaultConfig, config); + + // TODO: this is prone to errors; better refactor. + if (_.isEmpty(this.visualizations)) { + this.defaultVisualization = visualization; + } + + this.visualizations[config.type] = visualization; + + if (!config.skipTypes) { + this.visualizationTypes[config.name] = config.type; + }; + }; + + this.getSwitchTemplate = function(property) { + var pattern = /(<[a-zA-Z0-9-]*?)( |>)/ + + var mergedTemplates = _.reduce(this.visualizations, function(templates, visualization) { + if (visualization[property]) { + var ngSwitch = '$1 ng-switch-when="' + visualization.type + '" $2'; + var template = visualization[property].replace(pattern, ngSwitch); + + return templates + "\n" + template; + } + + return templates; + }, ""); + + mergedTemplates = '
'+ mergedTemplates + "
"; + + return mergedTemplates; + } + + this.$get = ['$resource', function($resource) { + var Visualization = $resource('/api/visualizations/:id', {id: '@id'}); + Visualization.visualizations = this.visualizations; + Visualization.visualizationTypes = this.visualizationTypes; + Visualization.renderVisualizationsTemplate = this.getSwitchTemplate('renderTemplate'); + Visualization.editorTemplate = this.getSwitchTemplate('editorTemplate'); + Visualization.defaultVisualization = this.defaultVisualization; + + return Visualization; + }]; + }; + + var VisualizationRenderer = function(Visualization) { + return { + restrict: 'E', + scope: { + visualization: '=', + queryResult: '=' + }, + // TODO: using switch here (and in the options editor) might introduce errors and bad + // performance wise. It's better to eventually show the correct template based on the + // visualization type and not make the browser render all of them. + template: Visualization.renderVisualizationsTemplate, + replace: false + } + }; + + var VisualizationOptionsEditor = function(Visualization) { + return { + restrict: 'E', + template: Visualization.editorTemplate, + replace: false + } + }; + + var EditVisualizationForm = function(Visualization, growl) { + return { + restrict: 'E', + templateUrl: '/views/visualizations/edit_visualization.html', + replace: true, + scope: { + query: '=', + queryResult: '=', + visualization: '=?' + }, + link: function (scope, element, attrs) { + scope.visTypes = Visualization.visualizationTypes; + + scope.newVisualization = function(q) { + return { + 'query_id': q.id, + 'type': Visualization.defaultVisualization.type, + 'name': Visualization.defaultVisualization.name, + 'description': q.description || '', + 'options': Visualization.defaultVisualization.defaultOptions + }; + } + + if (!scope.visualization) { + // 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.visualization = scope.newVisualization(q); + } + }, true); + } + + scope.$watch('visualization.type', function (type) { + // if not edited by user, set name to match type + if (type && scope.visualization && !scope.visForm.name.$dirty) { + // poor man's titlecase + scope.visualization.name = scope.visualization.type[0] + scope.visualization.type.slice(1).toLowerCase(); + } + }); + + scope.submit = function () { + Visualization.save(scope.visualization, function success(result) { + growl.addSuccessMessage("Visualization saved"); + + scope.visualization = scope.newVisualization(scope.query); + + 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"); + }); + }; + } + } + }; + + angular.module('redash.visualization', []) + .provider('Visualization', VisualizationProvider) + .directive('visualizationRenderer', ['Visualization', VisualizationRenderer]) + .directive('visualizationOptionsEditor', ['Visualization', VisualizationOptionsEditor]) + .directive('editVisulatizationForm', ['Visualization', 'growl', EditVisualizationForm]) +})(); diff --git a/rd_ui/app/scripts/visualizations/chart.js b/rd_ui/app/scripts/visualizations/chart.js new file mode 100644 index 00000000..60ef8940 --- /dev/null +++ b/rd_ui/app/scripts/visualizations/chart.js @@ -0,0 +1,106 @@ +(function () { + var chartVisualization = angular.module('redash.visualization'); + + chartVisualization.config(['VisualizationProvider', function(VisualizationProvider) { + var renderTemplate = ''; + var editTemplate = ''; + var defaultOptions = { + 'series': { + 'type': 'column', + 'stacking': null + } + }; + + VisualizationProvider.registerVisualization({ + type: 'CHART', + name: 'Chart', + renderTemplate: renderTemplate, + editorTemplate: editTemplate, + defaultOptions: defaultOptions + }); + }]); + + chartVisualization.directive('chartRenderer', function () { + return { + restrict: 'E', + scope: { + queryResult: '=', + options: '=?' + }, + template: "", + replace: false, + controller: ['$scope', function ($scope) { + $scope.chartSeries = []; + $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); + } else { + $scope.chartSeries.splice(0, $scope.chartSeries.length); + + _.each($scope.queryResult.getChartData(), function (s) { + $scope.chartSeries.push(_.extend(s, {'stacking': 'normal'})); + }); + } + }); + }] + } + }); + + chartVisualization.directive('chartEditor', function () { + return { + restrict: 'E', + templateUrl: '/views/visualizations/chart_editor.html', + link: function (scope, element, attrs) { + scope.seriesTypes = { + 'Line': 'line', + 'Column': 'column', + 'Area': 'area', + 'Scatter': 'scatter', + 'Pie': 'pie' + }; + + scope.stackingOptions = { + "None": "none", + "Normal": "normal", + "Percent": "percent" + }; + + scope.stacking = "none"; + + var chartOptionsUnwatch = null; + + scope.$watch('visualization', function (visualization) { + if (visualization && visualization.type == 'CHART') { + if (scope.visualization.options.series.stacking === null) { + scope.stacking = "none"; + } else if (scope.visualization.options.series.stacking === undefined) { + scope.stacking = "normal"; + } else { + scope.stacking = scope.visualization.options.series.stacking; + } + + chartOptionsUnwatch = scope.$watch("stacking", function (stacking) { + if (stacking == "none") { + scope.visualization.options.series.stacking = null; + } else { + scope.visualization.options.series.stacking = stacking; + } + }); + } else { + if (chartOptionsUnwatch) { + chartOptionsUnwatch(); + chartOptionsUnwatch = null; + } + } + }); + } + } + }); +}()); \ No newline at end of file diff --git a/rd_ui/app/scripts/visualizations/cohort.js b/rd_ui/app/scripts/visualizations/cohort.js new file mode 100644 index 00000000..4226c4a3 --- /dev/null +++ b/rd_ui/app/scripts/visualizations/cohort.js @@ -0,0 +1,60 @@ +(function () { + var cohortVisualization = angular.module('redash.visualization'); + + cohortVisualization.config(['VisualizationProvider', function(VisualizationProvider) { + VisualizationProvider.registerVisualization({ + type: 'COHORT', + name: 'Cohort', + renderTemplate: '' + }); + }]); + + cohortVisualization.directive('cohortRenderer', function() { + return { + restrict: 'E', + scope: { + queryResult: '=' + }, + template: "", + replace: false, + link: function($scope, element, attrs) { + $scope.$watch('queryResult && queryResult.getData()', function (data) { + if (!data) { + return; + } + + if ($scope.queryResult.getData() == null) { + + } else { + var sortedData = _.sortBy($scope.queryResult.getData(), "date"); + var grouped = _.groupBy(sortedData, "date"); + var data = _.map(grouped, function(values, date) { + var row = [values[0].total]; + _.each(values, function(value) { row.push(value.value); }); + return row; + }); + + var initialDate = moment(sortedData[0].date).toDate(), + container = angular.element(element)[0]; + + Cornelius.draw({ + initialDate: initialDate, + container: container, + cohort: data, + title: null, + timeInterval: 'daily', + labels: { + time: 'Activation Day', + people: 'Users' + }, + formatHeaderLabel: function (i) { + return "Day " + (i - 1); + } + }); + } + }); + } + } + }); + +}()); \ No newline at end of file diff --git a/rd_ui/app/scripts/visualizations/table.js b/rd_ui/app/scripts/visualizations/table.js new file mode 100644 index 00000000..98ee06d9 --- /dev/null +++ b/rd_ui/app/scripts/visualizations/table.js @@ -0,0 +1,92 @@ +(function () { + var tableVisualization = angular.module('redash.visualization'); + + tableVisualization.config(['VisualizationProvider', function(VisualizationProvider) { + VisualizationProvider.registerVisualization({ + type: 'TABLE', + name: 'Table', + renderTemplate: '', + skipTypes: true + }); + }]); + + tableVisualization.directive('gridRenderer', function () { + return { + restrict: 'E', + scope: { + queryResult: '=', + itemsPerPage: '=' + }, + templateUrl: "/views/grid_renderer.html", + replace: false, + controller: ['$scope', function ($scope) { + $scope.gridColumns = []; + $scope.gridData = []; + $scope.gridConfig = { + isPaginationEnabled: true, + itemsByPage: $scope.itemsPerPage || 15, + maxSize: 8 + }; + + $scope.$watch('queryResult && queryResult.getData()', function (data) { + if (!data) { + return; + } + + if ($scope.queryResult.getData() == null) { + $scope.gridColumns = []; + $scope.gridData = []; + $scope.filters = []; + } else { + + + $scope.filters = $scope.queryResult.getFilters(); + + var gridData = _.map($scope.queryResult.getData(), function (row) { + var newRow = {}; + _.each(row, function (val, key) { + newRow[$scope.queryResult.getColumnCleanName(key)] = val; + }) + return newRow; + }); + + $scope.gridColumns = _.map($scope.queryResult.getColumnCleanNames(), function (col, i) { + var columnDefinition = { + 'label': $scope.queryResult.getColumnFriendlyNames()[i], + 'map': col + }; + + if (gridData.length > 0) { + var exampleData = gridData[0][col]; + if (angular.isNumber(exampleData)) { + columnDefinition['formatFunction'] = 'number'; + columnDefinition['formatParameter'] = 2; + } else if (moment.isMoment(exampleData)) { + columnDefinition['formatFunction'] = function(value) { + return value.format("DD/MM/YY HH:mm"); + } + } + } + + return columnDefinition; + }); + + $scope.gridData = _.clone(gridData); + + $scope.$watch('filters', function (filters) { + $scope.gridData = _.filter(gridData, function (row) { + return _.reduce(filters, function (memo, filter) { + if (filter.current == 'All') { + return memo && true; + } + + return (memo && row[$scope.queryResult.getColumnCleanName(filter.name)] == filter.current); + }, true); + }); + }, true); + } + }); + }] + } + }) +}()); \ No newline at end of file diff --git a/rd_ui/app/views/edit_visualization.html b/rd_ui/app/views/edit_visualization.html deleted file mode 100644 index 2521902f..00000000 --- a/rd_ui/app/views/edit_visualization.html +++ /dev/null @@ -1,24 +0,0 @@ -
-
- - -
- -
- - -
- -
- - - - - -
- -
- -
- -
diff --git a/rd_ui/app/views/queryfiddle.html b/rd_ui/app/views/queryfiddle.html index 3a51ea07..99b3cf3e 100644 --- a/rd_ui/app/views/queryfiddle.html +++ b/rd_ui/app/views/queryfiddle.html @@ -73,7 +73,7 @@

- +

@@ -90,7 +90,7 @@

- +
diff --git a/rd_ui/app/views/visualizations/chart_editor.html b/rd_ui/app/views/visualizations/chart_editor.html new file mode 100644 index 00000000..2c36235a --- /dev/null +++ b/rd_ui/app/views/visualizations/chart_editor.html @@ -0,0 +1,7 @@ +
+ + + + + +
\ No newline at end of file diff --git a/rd_ui/app/views/visualizations/edit_visualization.html b/rd_ui/app/views/visualizations/edit_visualization.html new file mode 100644 index 00000000..0250bc4b --- /dev/null +++ b/rd_ui/app/views/visualizations/edit_visualization.html @@ -0,0 +1,18 @@ +
+
+ + +
+ +
+ + +
+ + + +
+ +
+ +