diff --git a/migrations/0024_add_options_to_query.py b/migrations/0024_add_options_to_query.py new file mode 100644 index 0000000000..05e37f5c50 --- /dev/null +++ b/migrations/0024_add_options_to_query.py @@ -0,0 +1,10 @@ +from redash.models import db, Query +from playhouse.migrate import PostgresqlMigrator, migrate + +if __name__ == '__main__': + migrator = PostgresqlMigrator(db.database) + + with db.database.transaction(): + migrate( + migrator.add_column('queries', 'options', Query.options), + ) diff --git a/rd_ui/app/scripts/controllers/dashboard.js b/rd_ui/app/scripts/controllers/dashboard.js index 50a37fc9a9..7ebdf805e9 100644 --- a/rd_ui/app/scripts/controllers/dashboard.js +++ b/rd_ui/app/scripts/controllers/dashboard.js @@ -211,14 +211,20 @@ Events.record(currentUser, "view", "widget", $scope.widget.id); + $scope.reload = function(force) { + var maxAge = $location.search()['maxAge']; + if (force) { + maxAge = 0; + } + $scope.queryResult = $scope.query.getQueryResult(maxAge); + }; + if ($scope.widget.visualization) { Events.record(currentUser, "view", "query", $scope.widget.visualization.query.id); Events.record(currentUser, "view", "visualization", $scope.widget.visualization.id); $scope.query = $scope.widget.getQuery(); - var parameters = Query.collectParamsFromQueryString($location, $scope.query); - var maxAge = $location.search()['maxAge']; - $scope.queryResult = $scope.query.getQueryResult(maxAge, parameters); + $scope.reload(false); $scope.type = 'visualization'; } else if ($scope.widget.restricted) { diff --git a/rd_ui/app/scripts/controllers/query_view.js b/rd_ui/app/scripts/controllers/query_view.js index f6e36cc713..4b7cbbccdb 100644 --- a/rd_ui/app/scripts/controllers/query_view.js +++ b/rd_ui/app/scripts/controllers/query_view.js @@ -5,8 +5,6 @@ var DEFAULT_TAB = 'table'; var getQueryResult = function(maxAge) { - // Collect params, and getQueryResult with params; getQueryResult merges it into the query - var parameters = Query.collectParamsFromQueryString($location, $scope.query); if (maxAge === undefined) { maxAge = $location.search()['maxAge']; } @@ -16,7 +14,7 @@ } $scope.showLog = false; - $scope.queryResult = $scope.query.getQueryResult(maxAge, parameters); + $scope.queryResult = $scope.query.getQueryResult(maxAge); }; var getDataSourceId = function() { @@ -127,7 +125,10 @@ if (data) { data.id = $scope.query.id; } else { - data = _.clone($scope.query); + data = _.pick($scope.query, ["schedule", "query", "id", "description", "name", "data_source_id", "options"]); + if ($scope.query.isNew()) { + data['latest_query_data_id'] = $scope.query.latest_query_data_id; + } } options = _.extend({}, { @@ -135,9 +136,6 @@ errorMessage: 'Query could not be saved' }, options); - delete data.latest_query_data; - delete data.queryResult; - return Query.save(data, function() { growl.addSuccessMessage(options.successMessage); }, function(httpResponse) { diff --git a/rd_ui/app/scripts/directives/directives.js b/rd_ui/app/scripts/directives/directives.js index fef6a423f5..63326724f3 100644 --- a/rd_ui/app/scripts/directives/directives.js +++ b/rd_ui/app/scripts/directives/directives.js @@ -92,13 +92,14 @@ restrict: 'E', scope: { 'tabId': '@', - 'name': '@' + 'name': '@', + 'basePath': '=?' }, transclude: true, template: '
  • {{name}}
  • ', replace: true, link: function (scope) { - scope.basePath = $location.path().substring(1); + scope.basePath = scope.basePath || $location.path().substring(1); scope.$watch(function () { return scope.$parent.selectedTab }, function (tab) { @@ -496,4 +497,41 @@ } }]); + directives.directive('parameters', ['$location', '$modal', function($location, $modal) { + return { + restrict: 'E', + transclude: true, + scope: { + 'parameters': '=', + 'syncValues': '=?', + 'editable': '=?' + }, + templateUrl: '/views/directives/parameters.html', + link: function(scope, elem, attrs) { + // is this the correct location for this logic? + if (scope.syncValues !== false) { + scope.$watch('parameters', function() { + _.each(scope.parameters, function(param) { + if (param.value !== null || param.value !== '') { + $location.search('p_' + param.name, param.value); + } + }) + }, true); + } + + scope.showParameterSettings = function(param) { + $modal.open({ + templateUrl: '/views/dialogs/parameter_settings.html', + controller: ['$scope', '$modalInstance', function($scope, $modalInstance) { + $scope.close = function() { + $modalInstance.close(); + }; + $scope.parameter = param; + }] + }) + } + } + } + }]); + })(); diff --git a/rd_ui/app/scripts/directives/query_directives.js b/rd_ui/app/scripts/directives/query_directives.js index 7a73b9da17..2dfa39a929 100644 --- a/rd_ui/app/scripts/directives/query_directives.js +++ b/rd_ui/app/scripts/directives/query_directives.js @@ -10,29 +10,29 @@ }, template: '{{query.name}}', link: function(scope, element) { - scope.link = 'queries/' + scope.query.id; + var hash = null; if (scope.visualization) { if (scope.visualization.type === 'TABLE') { // link to hard-coded table tab instead of the (hidden) visualization tab - scope.link += '#table'; + hash = 'table'; } else { - scope.link += '#' + scope.visualization.id; + hash = scope.visualization.id; } } - // element.find('a').attr('href', link); + scope.link = scope.query.getUrl(false, hash); } } } - function querySourceLink() { + function querySourceLink($location) { return { restrict: 'E', template: '\ Show Source\ + ng-href="{{query.getUrl(true, selectedTab)}}" class="btn btn-default">Show Source\ \ Hide Source\ + ng-href="{{query.getUrl(false, selectedTab)}}" class="btn btn-default">Hide Source\ \ ' } @@ -285,7 +285,7 @@ angular.module('redash.directives') .directive('queryLink', queryLink) - .directive('querySourceLink', querySourceLink) + .directive('querySourceLink', ['$location', querySourceLink]) .directive('queryResultLink', queryResultLink) .directive('queryEditor', queryEditor) .directive('queryRefreshSelect', queryRefreshSelect) diff --git a/rd_ui/app/scripts/filters.js b/rd_ui/app/scripts/filters.js index 461f1aaa0a..6a42fa594f 100644 --- a/rd_ui/app/scripts/filters.js +++ b/rd_ui/app/scripts/filters.js @@ -120,4 +120,10 @@ angular.module('redash.filters', []). filtered.push(items[i]) return filtered; }; + }) + + .filter('notEmpty', function() { + return function(collection) { + return !_.isEmpty(collection); + } }); diff --git a/rd_ui/app/scripts/services/resources.js b/rd_ui/app/scripts/services/resources.js index 8a4ca34362..3d160c999d 100644 --- a/rd_ui/app/scripts/services/resources.js +++ b/rd_ui/app/scripts/services/resources.js @@ -417,7 +417,7 @@ return QueryResult; }; - var Query = function ($resource, QueryResult, DataSource) { + var Query = function ($resource, $location, QueryResult) { var Query = $resource('api/queries/:id', {id: '@id'}, { search: { @@ -429,32 +429,19 @@ method: 'get', isArray: true, url: "api/queries/recent" - }}); + } + }); Query.newQuery = function () { return new Query({ query: "", name: "New Query", schedule: null, - user: currentUser + user: currentUser, + options: {} }); }; - Query.collectParamsFromQueryString = function($location, query) { - var parameterNames = query.getParameters(); - var parameters = {}; - - var queryString = $location.search(); - _.each(parameterNames, function(param, i) { - var qsName = "p_" + param; - if (qsName in queryString) { - parameters[param] = queryString[qsName]; - } - }); - - return parameters; - }; - Query.prototype.getSourceLink = function () { return '/queries/' + this.id + '/source'; }; @@ -477,32 +464,31 @@ }; Query.prototype.paramsRequired = function() { - var queryParameters = this.getParameters(); - return !_.isEmpty(queryParameters); + return this.getParameters().isRequired(); }; - Query.prototype.getQueryResult = function (maxAge, parameters) { + Query.prototype.getQueryResult = function (maxAge) { if (!this.query) { return; } var queryText = this.query; - var queryParameters = this.getParameters(); - var paramsRequired = !_.isEmpty(queryParameters); - - var missingParams = parameters === undefined ? queryParameters : _.difference(queryParameters, _.keys(parameters)); + var parameters = this.getParameters(); + var missingParams = parameters.getMissing(); - if (paramsRequired && missingParams.length > 0) { + if (missingParams.length > 0) { var paramsWord = "parameter"; + var valuesWord = "value"; if (missingParams.length > 1) { paramsWord = "parameters"; + valuesWord = "values"; } - return new QueryResult({job: {error: "Missing values for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}}); + return new QueryResult({job: {error: "missing " + valuesWord + " for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}}); } - if (paramsRequired) { - queryText = Mustache.render(queryText, parameters); + if (parameters.isRequired()) { + queryText = Mustache.render(queryText, parameters.getValues()); // Need to clear latest results, to make sure we don't use results for different params. this.latest_query_data = null; @@ -526,34 +512,142 @@ return this.queryResult; }; + Query.prototype.getUrl = function(source, hash) { + var url = "queries/" + this.id; + + if (source) { + url += '/source'; + } + + var params = ""; + if (this.getParameters().isRequired()) { + _.each(this.getParameters().getValues(), function(value, name) { + if (value === null) { + return; + } + + if (params !== "") { + params += "&"; + } + + params += 'p_' + encodeURIComponent(name) + "=" + encodeURIComponent(value); + }); + } + + if (params !== "") { + url += "?" + params; + } + + if (hash) { + url += "#" + hash; + } + + return url; + } + Query.prototype.getQueryResultPromise = function() { return this.getQueryResult().toPromise(); }; - Query.prototype.getParameters = function() { - var parts = Mustache.parse(this.query); - var parameters = []; - var collectParams = function(parts) { - parameters = []; - _.each(parts, function(part) { - if (part[0] == 'name' || part[0] == '&') { - parameters.push(part[1]); - } else if (part[0] == '#') { - parameters = _.union(parameters, collectParams(part[4])); + + var Parameters = function(query) { + this.query = query; + + this.parseQuery = function() { + var parts = Mustache.parse(this.query.query); + var parameters = []; + var collectParams = function(parts) { + parameters = []; + _.each(parts, function(part) { + if (part[0] == 'name' || part[0] == '&') { + parameters.push(part[1]); + } else if (part[0] == '#') { + parameters = _.union(parameters, collectParams(part[4])); + } + }); + return parameters; + }; + + parameters = collectParams(parts); + + return parameters; + } + + this.updateParameters = function() { + if (this.query.query === this.cachedQueryText) { + return; + } + + this.cachedQueryText = this.query.query; + var parameterNames = this.parseQuery(); + + this.query.options.parameters = this.query.options.parameters || {}; + + var parametersMap = {}; + _.each(this.query.options.parameters, function(param) { + parametersMap[param.name] = param; + }); + + _.each(parameterNames, function(param) { + if (!_.has(parametersMap, param)) { + this.query.options.parameters.push({ + 'title': param, + 'name': param, + 'type': 'text', + 'value': null + }); + } + }.bind(this)); + + this.query.options.parameters = _.filter(this.query.options.parameters, function(p) { return _.indexOf(parameterNames, p.name) !== -1}); + } + + this.initFromQueryString = function() { + var queryString = $location.search(); + _.each(this.get(), function(param) { + var queryStringName = 'p_' + param.name; + if (_.has(queryString, queryStringName)) { + param.value = queryString[queryStringName]; } }); - return parameters; - }; + } - parameters = collectParams(parts); + this.updateParameters(); + this.initFromQueryString(); + } + + Parameters.prototype.get = function() { + this.updateParameters(); + return this.query.options.parameters; + }; - return parameters; + Parameters.prototype.getMissing = function() { + return _.pluck(_.filter(this.get(), function(p) { return p.value === null || p.value === ''; }), 'title'); } - return Query; - }; + Parameters.prototype.isRequired = function() { + return !_.isEmpty(this.get()); + } + + Parameters.prototype.getValues = function() { + var params = this.get(); + return _.object(_.pluck(params, 'name'), _.pluck(params, 'value')); + } + Query.prototype.getParameters = function() { + if (!this.$parameters) { + this.$parameters = new Parameters(this); + } + + return this.$parameters; + } + Query.prototype.getParametersDefs = function() { + return this.getParameters().get(); + } + + return Query; + }; var DataSource = function ($resource) { var actions = { @@ -667,7 +761,7 @@ angular.module('redash.services') .factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult]) - .factory('Query', ['$resource', 'QueryResult', 'DataSource', Query]) + .factory('Query', ['$resource', '$location', 'QueryResult', Query]) .factory('DataSource', ['$resource', DataSource]) .factory('Destination', ['$resource', Destination]) .factory('Alert', ['$resource', '$http', Alert]) diff --git a/rd_ui/app/styles/redash.css b/rd_ui/app/styles/redash.css index c93fc193f5..d0780c24e6 100644 --- a/rd_ui/app/styles/redash.css +++ b/rd_ui/app/styles/redash.css @@ -158,23 +158,6 @@ a.navbar-brand img { } -/* Visualization Filters */ - -.filters-container { - display: flex; - flex-wrap: wrap; -} - -.filter { - width: 33%; - padding-left: 5px; - padding-bottom: 5px; -} - -.filter > div { - width: 100%; -} - /* Gridster */ .gridster ul { @@ -658,7 +641,25 @@ div.table-name:hover { background-color: rgba(0, 0, 0, 0.1); } -hr.subscription { - height: 2px; - background: #333; +/* ui-select adjustments for SuperFlat */ + +/* Same definition as .form-control */ +.ui-select-toggle.btn-default { + height: 35px; + padding: 6px 12px; + font-size: 13px; + line-height: 1.42857143; + color: #9E9E9E; + background: #fff none; + border: 1px solid #e8e8e8; + border-radius: 5px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; +} + +.t-header.widget { + padding: 5px; } diff --git a/rd_ui/app/views/dashboard.html b/rd_ui/app/views/dashboard.html index 4274b2d795..49ef6ceaa4 100644 --- a/rd_ui/app/views/dashboard.html +++ b/rd_ui/app/views/dashboard.html @@ -5,7 +5,7 @@ diff --git a/rd_ui/app/views/destinations/edit.html b/rd_ui/app/views/destinations/edit.html index 6f26d16ccf..1096465fad 100644 --- a/rd_ui/app/views/destinations/edit.html +++ b/rd_ui/app/views/destinations/edit.html @@ -4,8 +4,6 @@ - - diff --git a/rd_ui/app/views/dialogs/parameter_settings.html b/rd_ui/app/views/dialogs/parameter_settings.html new file mode 100644 index 0000000000..cadbbe205c --- /dev/null +++ b/rd_ui/app/views/dialogs/parameter_settings.html @@ -0,0 +1,21 @@ + + diff --git a/rd_ui/app/views/directives/parameters.html b/rd_ui/app/views/directives/parameters.html new file mode 100644 index 0000000000..8e43535259 --- /dev/null +++ b/rd_ui/app/views/directives/parameters.html @@ -0,0 +1,7 @@ +
    +
    + + + +
    +
    diff --git a/rd_ui/app/views/query.html b/rd_ui/app/views/query.html index 20a0ddb490..9be65037af 100644 --- a/rd_ui/app/views/query.html +++ b/rd_ui/app/views/query.html @@ -221,6 +221,7 @@

    +
    Executing query… @@ -255,9 +256,8 @@

      - - - + × @@ -276,12 +276,6 @@

    -
    -

    - Pivot tables are now regular visualization, which you can create from the - "New Visualization" screen and save. -

    -
    diff --git a/rd_ui/app/views/visualizations/filters.html b/rd_ui/app/views/visualizations/filters.html index 9ae9e0c393..509017c42b 100644 --- a/rd_ui/app/views/visualizations/filters.html +++ b/rd_ui/app/views/visualizations/filters.html @@ -1,17 +1,19 @@ -
    -
    - - {{filter.friendlyName}}: {{$select.selected | filterValue:filter}} - - {{value | filterValue:filter }} - - +
    +
    +
    + + {{filter.friendlyName}}: {{$select.selected | filterValue:filter}} + + {{value | filterValue:filter }} + + - - {{filter.friendlyName}}: {{$item | filterValue:filter}} - - {{value | filterValue:filter }} - - + + {{filter.friendlyName}}: {{$item | filterValue:filter}} + + {{value | filterValue:filter }} + +
    +
    diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py index 3057a3f1ce..91230a85b2 100644 --- a/redash/handlers/queries.py +++ b/redash/handlers/queries.py @@ -86,8 +86,6 @@ def post(self, query_id): for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user', 'last_modified_by', 'org']: query_def.pop(field, None) - # TODO(@arikfr): after running a query it updates all relevant queries with the new result. So is this really - # needed? if 'latest_query_data_id' in query_def: query_def['latest_query_data'] = query_def.pop('latest_query_data_id') diff --git a/redash/models.py b/redash/models.py index bef4073fa2..5cd51abf21 100644 --- a/redash/models.py +++ b/redash/models.py @@ -72,6 +72,8 @@ def db_value(self, value): return json.dumps(value) def python_value(self, value): + if not value: + return value return json.loads(value) @@ -585,11 +587,11 @@ class Query(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin): query = peewee.TextField() query_hash = peewee.CharField(max_length=32) api_key = peewee.CharField(max_length=40) - user_email = peewee.CharField(max_length=360, null=True) user = peewee.ForeignKeyField(User) last_modified_by = peewee.ForeignKeyField(User, null=True, related_name="modified_queries") is_archived = peewee.BooleanField(default=False, index=True) schedule = peewee.CharField(max_length=10, null=True) + options = JSONField(default={}) class Meta: db_table = 'queries' @@ -607,7 +609,8 @@ def to_dict(self, with_stats=False, with_visualizations=False, with_user=True, w 'is_archived': self.is_archived, 'updated_at': self.updated_at, 'created_at': self.created_at, - 'data_source_id': self.data_source_id + 'data_source_id': self.data_source_id, + 'options': self.options } if with_user: @@ -833,7 +836,6 @@ class Dashboard(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin): org = peewee.ForeignKeyField(Organization, related_name="dashboards") slug = peewee.CharField(max_length=140, index=True) name = peewee.CharField(max_length=100) - user_email = peewee.CharField(max_length=360, null=True) user = peewee.ForeignKeyField(User) layout = peewee.TextField() dashboard_filters_enabled = peewee.BooleanField(default=False)