feat(style): hide dashboard header by url parameter (#12918)

* feat(native-filters): hide dashboard header bu url parameter

* lint: fix lint

* test: add tests

* test: fix test

* refactor: upgrade standalone param

* fix: pre-commit and extract to method is_standalone_mode

* test: fix tests

* test: fix tests

* fix: fix standalone statement

* refactor: fix CR notes

* chore: pre-commit

* fix: fix sticky tabs + update CR notes

* lint: fix lint

* lint: fix lint

* fix: fix CR notes

* fix: fix CR notes

* lint: fix lint

* refactor: fix cr notes

Co-authored-by: amitmiran137 <amit.miran@nielsen.com>
This commit is contained in:
simcha90 2021-02-11 15:05:35 +02:00 committed by GitHub
parent 42c4facb7e
commit c5781cde60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 197 additions and 97 deletions

View File

@ -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(() => {

View File

@ -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',
);
});
});

View File

@ -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(
<ThemeProvider theme={supersetTheme}>
@ -92,15 +93,17 @@ describe('EmbedCodeButton', () => {
shortUrlId: 100,
});
const embedHTML =
'<iframe\n' +
' width="2000"\n' +
' height="1000"\n' +
' seamless\n' +
' frameBorder="0"\n' +
' scrolling="no"\n' +
' src="http://localhostendpoint_url?r=100&standalone=true&height=1000"\n' +
'>\n' +
'</iframe>';
`${
'<iframe\n' +
' width="2000"\n' +
' height="1000"\n' +
' seamless\n' +
' frameBorder="0"\n' +
' scrolling="no"\n' +
' src="http://localhostendpoint_url?r=100&standalone='
}${DashboardStandaloneMode.HIDE_NAV}&height=1000"\n` +
`>\n` +
`</iframe>`;
expect(wrapper.instance().generateEmbedHTML()).toBe(embedHTML);
stub.restore();
});

View File

@ -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,
}),
);
});

View File

@ -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 = {

View File

@ -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';

View File

@ -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',
};

View File

@ -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 (
<StickyContainer
@ -243,11 +252,14 @@ class DashboardBuilder extends React.Component {
editMode={editMode}
// you cannot drop on/displace tabs if they already exist
disableDragdrop={!!topLevelTabs}
style={{ zIndex: 100, ...style }}
style={{
zIndex: 100,
...style,
}}
>
{({ dropIndicatorProps }) => (
<div>
<DashboardHeader />
{!hideDashboardHeader && <DashboardHeader />}
{dropIndicatorProps && <div {...dropIndicatorProps} />}
{topLevelTabs && (
<WithPopoverMenu
@ -277,7 +289,6 @@ class DashboardBuilder extends React.Component {
</DragDroppable>
)}
</Sticky>
<StyledDashboardContent
className="dashboard-content"
dashboardFiltersOpen={this.state.dashboardFiltersOpen}

View File

@ -23,7 +23,7 @@ import { styled, SupersetClient, t } from '@superset-ui/core';
import { Menu, NoAnimationDropdown } from 'src/common/components';
import Icon from 'src/components/Icon';
import { URL_PARAMS } from 'src/constants';
import CssEditor from './CssEditor';
import RefreshIntervalModal from './RefreshIntervalModal';
import SaveModal from './SaveModal';
@ -34,6 +34,7 @@ import FilterScopeModal from './filterscope/FilterScopeModal';
import downloadAsImage from '../../utils/downloadAsImage';
import getDashboardUrl from '../util/getDashboardUrl';
import { getActiveFilters } from '../util/activeDashboardFilters';
import { getUrlParam } from '../../utils/urlUtils';
const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
@ -162,14 +163,11 @@ class HeaderActionsDropdown extends React.PureComponent {
break;
}
case MENU_KEYS.TOGGLE_FULLSCREEN: {
const hasStandalone = window.location.search.includes(
'standalone=true',
);
const url = getDashboardUrl(
window.location.pathname,
getActiveFilters(),
window.location.hash,
!hasStandalone,
getUrlParam(URL_PARAMS.standalone, 'number'),
);
window.location.replace(url);
break;

View File

@ -22,8 +22,6 @@ import { StickyContainer, Sticky } from 'react-sticky';
import { styled } from '@superset-ui/core';
import cx from 'classnames';
export const SUPERSET_HEADER_HEIGHT = 59;
const Wrapper = styled.div`
position: relative;
width: ${({ theme }) => theme.gridUnit * 8}px;

View File

@ -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,
}

View File

@ -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}`;
}

View File

@ -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 (
'<iframe\n' +
` width="${this.state.width}"\n` +

View File

@ -47,7 +47,7 @@ const propTypes = {
table_name: PropTypes.string,
vizType: PropTypes.string.isRequired,
form_data: PropTypes.object,
standalone: PropTypes.bool,
standalone: PropTypes.number,
timeout: PropTypes.number,
refreshOverlayVisible: PropTypes.bool,
chart: chartPropShape,

View File

@ -34,6 +34,7 @@ import {
getFromLocalStorage,
setInLocalStorage,
} from 'src/utils/localStorageHelpers';
import { URL_PARAMS } from 'src/constants';
import ExploreChartPanel from './ExploreChartPanel';
import ConnectedControlPanelsContainer from './ControlPanelsContainer';
import SaveModal from './SaveModal';
@ -67,7 +68,7 @@ const propTypes = {
controls: PropTypes.object.isRequired,
forcedHeight: PropTypes.string,
form_data: PropTypes.object.isRequired,
standalone: PropTypes.bool.isRequired,
standalone: PropTypes.number.isRequired,
timeout: PropTypes.number,
impressionId: PropTypes.string,
vizType: PropTypes.string,
@ -187,7 +188,7 @@ function ExploreViewContainer(props) {
const payload = { ...props.form_data };
const longUrl = getExploreLongUrl(
props.form_data,
props.standalone ? 'standalone' : null,
props.standalone ? URL_PARAMS.standalone : null,
false,
);
try {

View File

@ -27,7 +27,9 @@ import {
} from '@superset-ui/core';
import { availableDomains } from 'src/utils/hostNamesConfig';
import { safeStringify } from 'src/utils/safeStringify';
import { URL_PARAMS } from 'src/constants';
import { MULTI_OPERATORS } from './constants';
import { DashboardStandaloneMode } from '../dashboard/util/constants';
const MAX_URL_LENGTH = 8000;
@ -99,8 +101,8 @@ export function getExploreLongUrl(
search[key] = extraSearch[key];
});
search.form_data = safeStringify(formData);
if (endpointType === 'standalone') {
search.standalone = 'true';
if (endpointType === URL_PARAMS.standalone) {
search.standalone = DashboardStandaloneMode.HIDE_NAV;
}
const url = uri.directory(directory).search(search).toString();
if (!allowOverflow && url.length > 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';

View File

@ -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;

View File

@ -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),
),
);
}

View File

@ -70,7 +70,7 @@ import sqlalchemy as sa
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 __
@ -254,6 +254,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"

View File

@ -103,6 +103,7 @@ from superset.typing import FlaskResponse
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 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
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 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
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 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
"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 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
),
entry="explore",
title=title,
standalone_mode=standalone,
standalone_mode=standalone_mode,
)
@api
@ -1835,10 +1834,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
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"
)