diff --git a/superset-frontend/spec/javascripts/components/AnchorLink_spec.jsx b/superset-frontend/spec/javascripts/components/AnchorLink_spec.jsx index fb20cff002428..9f0c05a8eb87a 100644 --- a/superset-frontend/spec/javascripts/components/AnchorLink_spec.jsx +++ b/superset-frontend/spec/javascripts/components/AnchorLink_spec.jsx @@ -27,13 +27,14 @@ describe('AnchorLink', () => { anchorLinkId: 'CHART-123', }; + const globalLocation = window.location; + afterEach(() => { + window.location = globalLocation; + }); + beforeEach(() => { - global.window = Object.create(window); - Object.defineProperty(window, 'location', { - value: { - hash: `#${props.anchorLinkId}`, - }, - }); + delete window.location; + window.location = new URL(`https://path?#${props.anchorLinkId}`); }); afterEach(() => { diff --git a/superset-frontend/spec/javascripts/dashboard/util/getDashboardUrl_spec.js b/superset-frontend/spec/javascripts/dashboard/util/getDashboardUrl_spec.js index bb2ae43ed6740..77a19c7717246 100644 --- a/superset-frontend/spec/javascripts/dashboard/util/getDashboardUrl_spec.js +++ b/superset-frontend/spec/javascripts/dashboard/util/getDashboardUrl_spec.js @@ -18,28 +18,51 @@ */ import getDashboardUrl from 'src/dashboard/util/getDashboardUrl'; import { DASHBOARD_FILTER_SCOPE_GLOBAL } from 'src/dashboard/reducers/dashboardFilters'; +import { DashboardStandaloneMode } from '../../../../src/dashboard/util/constants'; describe('getChartIdsFromLayout', () => { + const filters = { + '35_key': { + values: ['value'], + scope: DASHBOARD_FILTER_SCOPE_GLOBAL, + }, + }; + + const globalLocation = window.location; + afterEach(() => { + window.location = globalLocation; + }); + it('should encode filters', () => { - const filters = { - '35_key': { - values: ['value'], - scope: DASHBOARD_FILTER_SCOPE_GLOBAL, - }, - }; const url = getDashboardUrl('path', filters); expect(url).toBe( 'path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D', ); + }); + it('should encode filters with hash', () => { const urlWithHash = getDashboardUrl('path', filters, 'iamhashtag'); expect(urlWithHash).toBe( 'path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D#iamhashtag', ); + }); - const urlWithStandalone = getDashboardUrl('path', filters, '', true); + it('should encode filters with standalone', () => { + const urlWithStandalone = getDashboardUrl( + 'path', + filters, + '', + DashboardStandaloneMode.HIDE_NAV, + ); expect(urlWithStandalone).toBe( - 'path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D&standalone=true', + `path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D&standalone=${DashboardStandaloneMode.HIDE_NAV}`, + ); + }); + + it('should encode filters with missing standalone', () => { + const urlWithStandalone = getDashboardUrl('path', filters, '', null); + expect(urlWithStandalone).toBe( + 'path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D', ); }); }); diff --git a/superset-frontend/spec/javascripts/explore/components/EmbedCodeButton_spec.jsx b/superset-frontend/spec/javascripts/explore/components/EmbedCodeButton_spec.jsx index 59d2262626da1..74c0d21e38cda 100644 --- a/superset-frontend/spec/javascripts/explore/components/EmbedCodeButton_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/components/EmbedCodeButton_spec.jsx @@ -26,7 +26,8 @@ import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import EmbedCodeButton from 'src/explore/components/EmbedCodeButton'; import * as exploreUtils from 'src/explore/exploreUtils'; -import * as common from 'src/utils/common'; +import * as urlUtils from 'src/utils/urlUtils'; +import { DashboardStandaloneMode } from 'src/dashboard/util/constants'; const ENDPOINT = 'glob:*/r/shortner/'; @@ -53,7 +54,7 @@ describe('EmbedCodeButton', () => { it('should create a short, standalone, explore url', () => { const spy1 = sinon.spy(exploreUtils, 'getExploreLongUrl'); - const spy2 = sinon.spy(common, 'getShortUrl'); + const spy2 = sinon.spy(urlUtils, 'getShortUrl'); const wrapper = mount( @@ -92,15 +93,17 @@ describe('EmbedCodeButton', () => { shortUrlId: 100, }); const embedHTML = - '\n' + - ''; + `${ + '\n` + + ``; expect(wrapper.instance().generateEmbedHTML()).toBe(embedHTML); stub.restore(); }); diff --git a/superset-frontend/spec/javascripts/explore/utils_spec.jsx b/superset-frontend/spec/javascripts/explore/utils_spec.jsx index 9214400a10856..d416fc05ff0b3 100644 --- a/superset-frontend/spec/javascripts/explore/utils_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/utils_spec.jsx @@ -30,6 +30,7 @@ import { buildTimeRangeString, formatTimeRange, } from 'src/explore/dateFilterUtils'; +import { DashboardStandaloneMode } from 'src/dashboard/util/constants'; import * as hostNamesConfig from 'src/utils/hostNamesConfig'; import { getChartMetadataRegistry } from '@superset-ui/core'; @@ -99,7 +100,9 @@ describe('exploreUtils', () => { }); compareURI( URI(url), - URI('/superset/explore/').search({ standalone: 'true' }), + URI('/superset/explore/').search({ + standalone: DashboardStandaloneMode.HIDE_NAV, + }), ); }); it('preserves main URLs params', () => { @@ -205,7 +208,7 @@ describe('exploreUtils', () => { URI(getExploreLongUrl(formData, 'standalone')), URI('/superset/explore/').search({ form_data: sFormData, - standalone: 'true', + standalone: DashboardStandaloneMode.HIDE_NAV, }), ); }); diff --git a/superset-frontend/src/components/URLShortLinkButton.jsx b/superset-frontend/src/components/URLShortLinkButton.jsx index 625d2df4c733f..68728f6abeb83 100644 --- a/superset-frontend/src/components/URLShortLinkButton.jsx +++ b/superset-frontend/src/components/URLShortLinkButton.jsx @@ -21,7 +21,7 @@ import PropTypes from 'prop-types'; import { t } from '@superset-ui/core'; import Popover from 'src/common/components/Popover'; import CopyToClipboard from './CopyToClipboard'; -import { getShortUrl } from '../utils/common'; +import { getShortUrl } from '../utils/urlUtils'; import withToasts from '../messageToasts/enhancers/withToasts'; const propTypes = { diff --git a/superset-frontend/src/components/URLShortLinkModal.tsx b/superset-frontend/src/components/URLShortLinkModal.tsx index 148d86cdc565d..1dee42133778a 100644 --- a/superset-frontend/src/components/URLShortLinkModal.tsx +++ b/superset-frontend/src/components/URLShortLinkModal.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { t } from '@superset-ui/core'; import CopyToClipboard from './CopyToClipboard'; -import { getShortUrl } from '../utils/common'; +import { getShortUrl } from '../utils/urlUtils'; import withToasts from '../messageToasts/enhancers/withToasts'; import ModalTrigger from './ModalTrigger'; diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts index ab19f14334184..850baf958f8fe 100644 --- a/superset-frontend/src/constants.ts +++ b/superset-frontend/src/constants.ts @@ -22,3 +22,8 @@ export const TIME_WITH_MS = 'HH:mm:ss.SSS'; export const BOOL_TRUE_DISPLAY = 'True'; export const BOOL_FALSE_DISPLAY = 'False'; + +export const URL_PARAMS = { + standalone: 'standalone', + preselectFilters: 'preselect_filters', +}; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder.jsx b/superset-frontend/src/dashboard/components/DashboardBuilder.jsx index 2e3b3e238685b..945d95639cfea 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder.jsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder.jsx @@ -42,13 +42,16 @@ import findTabIndexByComponentId from 'src/dashboard/util/findTabIndexByComponen import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex'; import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; +import { URL_PARAMS } from 'src/constants'; import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_ID, DASHBOARD_ROOT_DEPTH, + DashboardStandaloneMode, } from '../util/constants'; import FilterBar from './nativeFilters/FilterBar/FilterBar'; import { StickyVerticalBar } from './StickyVerticalBar'; +import { getUrlParam } from '../../utils/urlUtils'; const TABS_HEIGHT = 47; const HEADER_HEIGHT = 67; @@ -225,7 +228,13 @@ class DashboardBuilder extends React.Component { const childIds = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID]; - const barTopOffset = HEADER_HEIGHT + (topLevelTabs ? TABS_HEIGHT : 0); + const hideDashboardHeader = + getUrlParam(URL_PARAMS.standalone, 'number') === + DashboardStandaloneMode.HIDE_NAV_AND_TITLE; + + const barTopOffset = + (hideDashboardHeader ? 0 : HEADER_HEIGHT) + + (topLevelTabs ? TABS_HEIGHT : 0); return ( {({ dropIndicatorProps }) => (
- + {!hideDashboardHeader && } {dropIndicatorProps &&
} {topLevelTabs && ( )} - theme.gridUnit * 8}px; diff --git a/superset-frontend/src/dashboard/util/constants.ts b/superset-frontend/src/dashboard/util/constants.ts index b743d091749b0..917ea36c97543 100644 --- a/superset-frontend/src/dashboard/util/constants.ts +++ b/superset-frontend/src/dashboard/util/constants.ts @@ -69,3 +69,8 @@ export const IN_COMPONENT_ELEMENT_TYPES = ['LABEL']; // filter scope selector filter fields pane root id export const ALL_FILTERS_ROOT = 'ALL_FILTERS_ROOT'; + +export enum DashboardStandaloneMode { + HIDE_NAV = 1, + HIDE_NAV_AND_TITLE = 2, +} diff --git a/superset-frontend/src/dashboard/util/getDashboardUrl.js b/superset-frontend/src/dashboard/util/getDashboardUrl.ts similarity index 71% rename from superset-frontend/src/dashboard/util/getDashboardUrl.js rename to superset-frontend/src/dashboard/util/getDashboardUrl.ts index f921c09a21b27..d3cf06c668b9a 100644 --- a/superset-frontend/src/dashboard/util/getDashboardUrl.js +++ b/superset-frontend/src/dashboard/util/getDashboardUrl.ts @@ -16,19 +16,29 @@ * specific language governing permissions and limitations * under the License. */ +import { URL_PARAMS } from 'src/constants'; import serializeActiveFilterValues from './serializeActiveFilterValues'; export default function getDashboardUrl( - pathname, + pathname: string, filters = {}, hash = '', - standalone = false, + standalone?: number | null, ) { + const newSearchParams = new URLSearchParams(); + // convert flattened { [id_column]: values } object // to nested filter object - const obj = serializeActiveFilterValues(filters); - const preselectFilters = encodeURIComponent(JSON.stringify(obj)); + newSearchParams.set( + URL_PARAMS.preselectFilters, + JSON.stringify(serializeActiveFilterValues(filters)), + ); + + if (standalone) { + newSearchParams.set(URL_PARAMS.standalone, standalone.toString()); + } + const hashSection = hash ? `#${hash}` : ''; - const standaloneParam = standalone ? '&standalone=true' : ''; - return `${pathname}?preselect_filters=${preselectFilters}${standaloneParam}${hashSection}`; + + return `${pathname}?${newSearchParams.toString()}${hashSection}`; } diff --git a/superset-frontend/src/explore/components/EmbedCodeButton.jsx b/superset-frontend/src/explore/components/EmbedCodeButton.jsx index 04efde16fa2c3..0ff7afce9593e 100644 --- a/superset-frontend/src/explore/components/EmbedCodeButton.jsx +++ b/superset-frontend/src/explore/components/EmbedCodeButton.jsx @@ -23,7 +23,8 @@ import { t } from '@superset-ui/core'; import Popover from 'src/common/components/Popover'; import FormLabel from 'src/components/FormLabel'; import CopyToClipboard from 'src/components/CopyToClipboard'; -import { getShortUrl } from 'src/utils/common'; +import { getShortUrl } from 'src/utils/urlUtils'; +import { URL_PARAMS } from 'src/constants'; import { getExploreLongUrl, getURIDirectory } from '../exploreUtils'; const propTypes = { @@ -66,7 +67,7 @@ export default class EmbedCodeButton extends React.Component { generateEmbedHTML() { const srcLink = `${window.location.origin + getURIDirectory()}?r=${ this.state.shortUrlId - }&standalone=true&height=${this.state.height}`; + }&${URL_PARAMS.standalone}=1&height=${this.state.height}`; return ( ' MAX_URL_LENGTH) { @@ -172,8 +174,8 @@ export function getExploreUrl({ if (endpointType === 'csv') { search.csv = 'true'; } - if (endpointType === 'standalone') { - search.standalone = 'true'; + if (endpointType === URL_PARAMS.standalone) { + search.standalone = '1'; } if (endpointType === 'query') { search.query = 'true'; diff --git a/superset-frontend/src/utils/common.js b/superset-frontend/src/utils/common.js index b14848b5b2e50..ea452adc53d2d 100644 --- a/superset-frontend/src/utils/common.js +++ b/superset-frontend/src/utils/common.js @@ -21,7 +21,6 @@ import { getTimeFormatter, TimeFormats, } from '@superset-ui/core'; -import { getClientErrorObject } from './getClientErrorObject'; // ATTENTION: If you change any constants, make sure to also change constants.py @@ -55,33 +54,6 @@ export function storeQuery(query) { }); } -export function getParamsFromUrl() { - const hash = window.location.search; - const params = hash.split('?')[1].split('&'); - const newParams = {}; - params.forEach(p => { - const value = p.split('=')[1].replace(/\+/g, ' '); - const key = p.split('=')[0]; - newParams[key] = value; - }); - return newParams; -} - -export function getShortUrl(longUrl) { - return SupersetClient.post({ - endpoint: '/r/shortner/', - postPayload: { data: `/${longUrl}` }, // note: url should contain 2x '/' to redirect properly - parseMethod: 'text', - stringify: false, // the url saves with an extra set of string quotes without this - }) - .then(({ text }) => text) - .catch(response => - getClientErrorObject(response).then(({ error, statusText }) => - Promise.reject(error || statusText), - ), - ); -} - export function optionLabel(opt) { if (opt === null) { return NULL_STRING; diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts new file mode 100644 index 0000000000000..266555ca6d350 --- /dev/null +++ b/superset-frontend/src/utils/urlUtils.ts @@ -0,0 +1,63 @@ +/** + * 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 { SupersetClient } from '@superset-ui/core'; +import { getClientErrorObject } from './getClientErrorObject'; + +export type UrlParamType = 'string' | 'number' | 'boolean'; +export function getUrlParam(paramName: string, type: 'string'): string; +export function getUrlParam(paramName: string, type: 'number'): number; +export function getUrlParam(paramName: string, type: 'boolean'): boolean; +export function getUrlParam(paramName: string, type: UrlParamType): unknown { + const urlParam = new URLSearchParams(window.location.search).get(paramName); + switch (type) { + case 'number': + if (!urlParam) { + return null; + } + if (urlParam === 'true') { + return 1; + } + if (urlParam === 'false') { + return 0; + } + if (!Number.isNaN(Number(urlParam))) { + return Number(urlParam); + } + return null; + // TODO: process other types when needed + default: + return urlParam; + } +} + +export function getShortUrl(longUrl: string) { + return SupersetClient.post({ + endpoint: '/r/shortner/', + postPayload: { data: `/${longUrl}` }, // note: url should contain 2x '/' to redirect properly + parseMethod: 'text', + stringify: false, // the url saves with an extra set of string quotes without this + }) + .then(({ text }) => text) + .catch(response => + // @ts-ignore + getClientErrorObject(response).then(({ error, statusText }) => + Promise.reject(error || statusText), + ), + ); +} diff --git a/superset/utils/core.py b/superset/utils/core.py index ceec948a393f9..60244494964b9 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -69,7 +69,7 @@ from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends.openssl.x509 import _Certificate -from flask import current_app, flash, g, Markup, render_template +from flask import current_app, flash, g, Markup, render_template, request from flask_appbuilder import SQLA from flask_appbuilder.security.sqla.models import Role, User from flask_babel import gettext as __ @@ -252,6 +252,14 @@ class ReservedUrlParameters(str, Enum): STANDALONE = "standalone" EDIT_MODE = "edit" + @staticmethod + def is_standalone_mode() -> Optional[bool]: + standalone_param = request.args.get(ReservedUrlParameters.STANDALONE.value) + standalone: Optional[bool] = ( + standalone_param and standalone_param != "false" and standalone_param != "0" + ) + return standalone + class RowLevelSecurityFilterType(str, Enum): REGULAR = "Regular" diff --git a/superset/views/core.py b/superset/views/core.py index cf0ca7d4dddd5..54ccd131b4fb4 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -103,6 +103,7 @@ from superset.utils import core as utils from superset.utils.async_query_manager import AsyncQueryTokenException from superset.utils.cache import etag_cache +from superset.utils.core import ReservedUrlParameters from superset.utils.dates import now_as_float from superset.utils.decorators import check_dashboard_access from superset.views.base import ( @@ -400,9 +401,10 @@ def slice(self, slice_id: int) -> FlaskResponse: # pylint: disable=no-self-use endpoint = "/superset/explore/?form_data={}".format( parse.quote(json.dumps({"slice_id": slice_id})) ) - param = utils.ReservedUrlParameters.STANDALONE.value - if request.args.get(param) == "true": - endpoint += f"&{param}=true" + + is_standalone_mode = ReservedUrlParameters.is_standalone_mode() + if is_standalone_mode: + endpoint += f"&{ReservedUrlParameters.STANDALONE}={is_standalone_mode}" return redirect(endpoint) def get_query_string_response(self, viz_obj: BaseViz) -> FlaskResponse: @@ -783,10 +785,7 @@ def explore( # pylint: disable=too-many-locals,too-many-return-statements datasource.type, datasource.name, ) - - standalone = ( - request.args.get(utils.ReservedUrlParameters.STANDALONE.value) == "true" - ) + standalone_mode = ReservedUrlParameters.is_standalone_mode() dummy_datasource_data: Dict[str, Any] = { "type": datasource_type, "name": datasource_name, @@ -802,7 +801,7 @@ def explore( # pylint: disable=too-many-locals,too-many-return-statements "datasource_id": datasource_id, "datasource_type": datasource_type, "slice": slc.data if slc else None, - "standalone": standalone, + "standalone": standalone_mode, "user_id": user_id, "forced_height": request.args.get("height"), "common": common_bootstrap_payload(), @@ -826,7 +825,7 @@ def explore( # pylint: disable=too-many-locals,too-many-return-statements ), entry="explore", title=title, - standalone_mode=standalone, + standalone_mode=standalone_mode, ) @api @@ -1835,10 +1834,7 @@ def dashboard( # pylint: disable=too-many-locals superset_can_explore = security_manager.can_access("can_explore", "Superset") 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(utils.ReservedUrlParameters.STANDALONE.value) == "true" - ) + standalone_mode = ReservedUrlParameters.is_standalone_mode() edit_mode = ( request.args.get(utils.ReservedUrlParameters.EDIT_MODE.value) == "true" )