diff --git a/superset/assets/cypress/integration/dashboard/index.test.js b/superset/assets/cypress/integration/dashboard/index.test.js index cc608e7d5b..5512307bbc 100644 --- a/superset/assets/cypress/integration/dashboard/index.test.js +++ b/superset/assets/cypress/integration/dashboard/index.test.js @@ -23,6 +23,7 @@ import DashboardFilterTest from './filter'; import DashboardLoadTest from './load'; import DashboardSaveTest from './save'; import DashboardTabsTest from './tabs'; +import DashboardUrlParamsTest from './url_params'; describe('Dashboard', () => { DashboardControlsTest(); @@ -32,4 +33,5 @@ describe('Dashboard', () => { DashboardLoadTest(); DashboardSaveTest(); DashboardTabsTest(); + DashboardUrlParamsTest(); }); diff --git a/superset/assets/cypress/integration/dashboard/url_params.js b/superset/assets/cypress/integration/dashboard/url_params.js new file mode 100644 index 0000000000..b82aea10ed --- /dev/null +++ b/superset/assets/cypress/integration/dashboard/url_params.js @@ -0,0 +1,56 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper'; + +export default () => describe('dashboard url params', () => { + const urlParams = { param1: '123', param2: 'abc' }; + let sliceIds = []; + + beforeEach(() => { + cy.server(); + cy.login(); + + cy.visit(WORLD_HEALTH_DASHBOARD, { qs: urlParams }); + + cy.get('#app').then((data) => { + const bootstrapData = JSON.parse(data[0].dataset.bootstrap); + const dashboard = bootstrapData.dashboard_data; + sliceIds = dashboard.slices.map(slice => (slice.slice_id)); + }); + }); + + it('should apply url params to slice requests', () => { + const aliases = []; + sliceIds + .forEach((id) => { + const alias = `getJson_${id}`; + aliases.push(`@${alias}`); + cy.route('POST', `/superset/explore_json/?form_data={"slice_id":${id}}`).as(alias); + }); + + cy.wait(aliases).then((requests) => { + requests.forEach((xhr) => { + const requestFormData = xhr.request.body; + const requestParams = JSON.parse(requestFormData.get('form_data')); + expect(requestParams.url_params) + .deep.eq(urlParams); + }); + }); + }); +}); diff --git a/superset/assets/package.json b/superset/assets/package.json index c7b11fdbb4..bdd888067f 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -20,7 +20,7 @@ "clean-css": "prettier --write src/**/*.{css,less,sass,scss}", "cypress": "cypress", "cypress-debug": "cypress open --config watchForFileChanges=true", - "install-cypress": "npm install cypress@3.4.1" + "install-cypress": "npm install cypress@3.6.1" }, "repository": { "type": "git", diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js index 91f74832ce..bc32adcc4c 100644 --- a/superset/assets/src/dashboard/reducers/getInitialState.js +++ b/superset/assets/src/dashboard/reducers/getInitialState.js @@ -50,7 +50,7 @@ import newComponentFactory from '../util/newComponentFactory'; import { TIME_RANGE } from '../../visualizations/FilterBox/FilterBox'; export default function(bootstrapData) { - const { user_id, datasources, common, editMode } = bootstrapData; + const { user_id, datasources, common, editMode, urlParams } = bootstrapData; const dashboard = { ...bootstrapData.dashboard_data }; let preselectFilters = {}; @@ -113,11 +113,18 @@ export default function(bootstrapData) { dashboard.slices.forEach(slice => { const key = slice.slice_id; if (['separator', 'markup'].indexOf(slice.form_data.viz_type) === -1) { + const form_data = { + ...slice.form_data, + url_params: { + ...slice.form_data.url_params, + ...urlParams, + }, + }; chartQueries[key] = { ...chart, id: key, - form_data: slice.form_data, - formData: applyDefaultFormData(slice.form_data), + form_data, + formData: applyDefaultFormData(form_data), }; slices[key] = { diff --git a/superset/jinja_context.py b/superset/jinja_context.py index f522de3440..4f3dd95733 100644 --- a/superset/jinja_context.py +++ b/superset/jinja_context.py @@ -56,6 +56,9 @@ def url_param(param: str, default: Optional[str] = None) -> Optional[Any]: parameters in the explore view as well as from the dashboard, and it should carry through to your queries. + Default values for URL parameters can be defined in chart metdata by + adding the key-value pair `url_params: {'foo': 'bar'}` + :param param: the parameter to lookup :param default: the value to return in the absence of the parameter """ diff --git a/superset/utils/core.py b/superset/utils/core.py index 8c9cba2886..ce1bd6123e 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -35,7 +35,7 @@ from email.mime.text import MIMEText from email.utils import formatdate from enum import Enum from time import struct_time -from typing import Iterator, List, NamedTuple, Optional, Tuple, Union +from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Tuple, Union from urllib.parse import unquote_plus import bleach @@ -906,8 +906,16 @@ def merge_extra_filters(form_data: dict): del form_data["extra_filters"] -def merge_request_params(form_data: dict, params: dict): - url_params = {} +def merge_request_params(form_data: Dict[str, Any], params: Dict[str, Any]) -> None: + """ + Merge request parameters to the key `url_params` in form_data. Only updates + or appends parameters to `form_data` that are defined in `params; pre-existing + parameters not defined in params are left unchanged. + + :param form_data: object to be updated + :param params: request parameters received via query string + """ + url_params = form_data.get("url_params", {}) for key, value in params.items(): if key in ("form_data", "r"): continue @@ -1234,3 +1242,13 @@ class TimeRangeEndpoint(str, Enum): EXCLUSIVE = "exclusive" INCLUSIVE = "inclusive" UNKNOWN = "unknown" + + +class ReservedUrlParameters(Enum): + """ + Reserved URL parameters that are used internally by Superset. These will not be + passed to chart queries, as they control the behavior of the UI. + """ + + STANDALONE = "standalone" + EDIT_MODE = "edit" diff --git a/superset/views/core.py b/superset/views/core.py index e98e321996..5d1c861674 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -19,7 +19,8 @@ import logging import re from contextlib import closing from datetime import datetime, timedelta -from typing import Dict, List, Optional, Union +from enum import Enum +from typing import List, Optional, Union from urllib import parse import backoff @@ -978,8 +979,9 @@ class Superset(BaseSupersetView): endpoint = "/superset/explore/?form_data={}".format( parse.quote(json.dumps({"slice_id": slice_id})) ) - if request.args.get("standalone") == "true": - endpoint += "&standalone=true" + param = utils.ReservedUrlParameters.STANDALONE.value + if request.args.get(param) == "true": + endpoint += f"&{param}=true" return redirect(endpoint) def get_query_string_response(self, viz_obj): @@ -1268,7 +1270,9 @@ class Superset(BaseSupersetView): datasource.name, ) - standalone = request.args.get("standalone") == "true" + standalone = ( + request.args.get(utils.ReservedUrlParameters.STANDALONE.value) == "true" + ) bootstrap_data = { "can_add": slice_add_perm, "can_download": slice_download_perm, @@ -2178,8 +2182,12 @@ class Superset(BaseSupersetView): superset_can_csv = security_manager.can_access("can_csv", "Superset") slice_can_edit = security_manager.can_access("can_edit", "SliceModelView") - standalone_mode = request.args.get("standalone") == "true" - edit_mode = request.args.get("edit") == "true" + standalone_mode = ( + request.args.get(utils.ReservedUrlParameters.STANDALONE.value) == "true" + ) + edit_mode = ( + request.args.get(utils.ReservedUrlParameters.EDIT_MODE.value) == "true" + ) # Hack to log the dashboard_id properly, even when getting a slug @event_logger.log_this @@ -2204,6 +2212,11 @@ class Superset(BaseSupersetView): "slice_can_edit": slice_can_edit, } ) + url_params = { + key: value + for key, value in request.args.items() + if key not in [param.value for param in utils.ReservedUrlParameters] + } bootstrap_data = { "user_id": g.user.get_id(), @@ -2211,6 +2224,7 @@ class Superset(BaseSupersetView): "datasources": {ds.uid: ds.data for ds in datasources}, "common": self.common_bootstrap_payload(), "editMode": edit_mode, + "urlParams": url_params, } if request.args.get("json") == "true": diff --git a/tests/utils_tests.py b/tests/utils_tests.py index b44282cade..94603dbb8b 100644 --- a/tests/utils_tests.py +++ b/tests/utils_tests.py @@ -522,7 +522,7 @@ class UtilsTestCase(SupersetTestCase): merge_extra_filters(form_data) self.assertEqual(form_data, expected) - def test_merge_request_params(self): + def test_merge_request_params_when_url_params_undefined(self): form_data = {"since": "2000", "until": "now"} url_params = {"form_data": form_data, "dashboard_ids": "(1,2,3,4,5)"} merge_request_params(form_data, url_params) @@ -530,6 +530,20 @@ class UtilsTestCase(SupersetTestCase): self.assertIn("dashboard_ids", form_data["url_params"]) self.assertNotIn("form_data", form_data.keys()) + def test_merge_request_params_when_url_params_predefined(self): + form_data = { + "since": "2000", + "until": "now", + "url_params": {"abc": "123", "dashboard_ids": "(1,2,3)"}, + } + url_params = {"form_data": form_data, "dashboard_ids": "(1,2,3,4,5)"} + merge_request_params(form_data, url_params) + self.assertIn("url_params", form_data.keys()) + self.assertIn("abc", form_data["url_params"]) + self.assertEquals( + url_params["dashboard_ids"], form_data["url_params"]["dashboard_ids"] + ) + def test_datetime_f(self): self.assertEqual( datetime_f(datetime(1990, 9, 21, 19, 11, 19, 626096)),