feat: save active tabs in dashboard permalink (#19983)

This commit is contained in:
Jesse Yang
2022-06-29 09:43:52 -07:00
committed by GitHub
parent c5d3678a31
commit cadd259788
17 changed files with 274 additions and 103 deletions

View File

@@ -59,23 +59,23 @@ import { updateColorSchema } from './dashboardInfo';
export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD';
export const hydrateDashboard =
(
dashboardData,
chartData,
({
dashboard,
charts,
filterboxMigrationState = FILTER_BOX_MIGRATION_STATES.NOOP,
dataMaskApplied,
) =>
dataMask,
activeTabs,
}) =>
(dispatch, getState) => {
const { user, common, dashboardState } = getState();
const { metadata } = dashboardData;
const { metadata, position_data: positionData } = dashboard;
const regularUrlParams = extractUrlParams('regular');
const reservedUrlParams = extractUrlParams('reserved');
const editMode = reservedUrlParams.edit === 'true';
let preselectFilters = {};
chartData.forEach(chart => {
charts.forEach(chart => {
// eslint-disable-next-line no-param-reassign
chart.slice_id = chart.form_data.slice_id;
});
@@ -98,12 +98,10 @@ export const hydrateDashboard =
updateColorSchema(metadata, metadata?.label_colors);
}
// dashboard layout
const { position_data } = dashboardData;
// new dash: position_json could be {} or null
const layout =
position_data && Object.keys(position_data).length > 0
? position_data
positionData && Object.keys(positionData).length > 0
? positionData
: getEmptyLayout();
// create a lookup to sync layout names with slice names
@@ -128,7 +126,7 @@ export const hydrateDashboard =
const sliceIds = new Set();
const slicesFromExploreCount = new Map();
chartData.forEach(slice => {
charts.forEach(slice => {
const key = slice.slice_id;
const form_data = {
...slice.form_data,
@@ -269,7 +267,7 @@ export const hydrateDashboard =
id: DASHBOARD_HEADER_ID,
type: DASHBOARD_HEADER_TYPE,
meta: {
text: dashboardData.dashboard_title,
text: dashboard.dashboard_title,
},
};
@@ -291,7 +289,7 @@ export const hydrateDashboard =
let filterConfig = metadata?.native_filter_configuration || [];
if (filterboxMigrationState === FILTER_BOX_MIGRATION_STATES.REVIEWING) {
filterConfig = getNativeFilterConfig(
chartData,
charts,
filterScopes,
preselectFilters,
);
@@ -302,7 +300,7 @@ export const hydrateDashboard =
filterConfig,
});
metadata.show_native_filters =
dashboardData?.metadata?.show_native_filters ??
dashboard?.metadata?.show_native_filters ??
(isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) &&
[
FILTER_BOX_MIGRATION_STATES.CONVERTED,
@@ -343,7 +341,7 @@ export const hydrateDashboard =
}
const { roles } = user;
const canEdit = canUserEditDashboard(dashboardData, user);
const canEdit = canUserEditDashboard(dashboard, user);
return dispatch({
type: HYDRATE_DASHBOARD,
@@ -352,7 +350,7 @@ export const hydrateDashboard =
charts: chartQueries,
// read-only data
dashboardInfo: {
...dashboardData,
...dashboard,
metadata,
userId: user.userId ? String(user.userId) : null, // legacy, please use state.user instead
dash_edit_perm: canEdit,
@@ -380,7 +378,7 @@ export const hydrateDashboard =
conf: common?.conf,
},
},
dataMask: dataMaskApplied,
dataMask,
dashboardFilters,
nativeFilters,
dashboardState: {
@@ -394,17 +392,17 @@ export const hydrateDashboard =
// dashboard viewers can set refresh frequency for the current visit,
// only persistent refreshFrequency will be saved to backend
shouldPersistRefreshFrequency: false,
css: dashboardData.css || '',
css: dashboard.css || '',
colorNamespace: metadata?.color_namespace || null,
colorScheme: metadata?.color_scheme || null,
editMode: canEdit && editMode,
isPublished: dashboardData.published,
isPublished: dashboard.published,
hasUnsavedChanges: false,
maxUndoHistoryExceeded: false,
lastModifiedTime: dashboardData.changed_on,
lastModifiedTime: dashboard.changed_on,
isRefreshing: false,
isFiltersRefreshing: false,
activeTabs: dashboardState?.activeTabs || [],
activeTabs: activeTabs || dashboardState?.activeTabs || [],
filterboxMigrationState,
datasetsStatus: ResourceStatus.LOADING,
},

View File

@@ -20,10 +20,11 @@ import React, { useState } from 'react';
import { t } from '@superset-ui/core';
import Popover, { PopoverProps } from 'src/components/Popover';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { getDashboardPermalink, getUrlParam } from 'src/utils/urlUtils';
import { getDashboardPermalink } from 'src/utils/urlUtils';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { URL_PARAMS } from 'src/constants';
import { getFilterValue } from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
import { useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
export type URLShortLinkButtonProps = {
dashboardId: number;
@@ -42,19 +43,27 @@ export default function URLShortLinkButton({
}: URLShortLinkButtonProps) {
const [shortUrl, setShortUrl] = useState('');
const { addDangerToast } = useToasts();
const { dataMask, activeTabs } = useSelector((state: RootState) => ({
dataMask: state.dataMask,
activeTabs: state.dashboardState.activeTabs,
}));
const getCopyUrl = async () => {
const nativeFiltersKey = getUrlParam(URL_PARAMS.nativeFiltersKey);
try {
const filterState = await getFilterValue(dashboardId, nativeFiltersKey);
const url = await getDashboardPermalink({
dashboardId,
filterState,
hash: anchorLinkId,
dataMask,
activeTabs,
anchor: anchorLinkId,
});
setShortUrl(url);
} catch (error) {
addDangerToast(error);
if (error) {
addDangerToast(
(await getClientErrorObject(error)).error ||
t('Something went wrong.'),
);
}
}
};
@@ -66,7 +75,14 @@ export default function URLShortLinkButton({
trigger="click"
placement={placement}
content={
<div id="shorturl-popover" data-test="shorturl-popover">
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
id="shorturl-popover"
data-test="shorturl-popover"
onClick={e => {
e.stopPropagation();
}}
>
<CopyToClipboard
text={shortUrl}
copyNode={

View File

@@ -48,6 +48,7 @@ const propTypes = {
editMode: PropTypes.bool.isRequired,
renderHoverMenu: PropTypes.bool,
directPathToChild: PropTypes.arrayOf(PropTypes.string),
activeTabs: PropTypes.arrayOf(PropTypes.string),
filterboxMigrationState: FILTER_BOX_MIGRATION_STATES,
// actions (from DashboardComponent.jsx)
@@ -74,6 +75,7 @@ const defaultProps = {
renderHoverMenu: true,
availableColumnCount: 0,
columnWidth: 0,
activeTabs: [],
directPathToChild: [],
filterboxMigrationState: FILTER_BOX_MIGRATION_STATES.NOOP,
setActiveTabs() {},
@@ -112,13 +114,20 @@ const StyledTabsContainer = styled.div`
export class Tabs extends React.PureComponent {
constructor(props) {
super(props);
const tabIndex = Math.max(
let tabIndex = Math.max(
0,
findTabIndexByComponentId({
currentComponent: props.component,
directPathToChild: props.directPathToChild,
}),
);
if (tabIndex === 0 && props.activeTabs?.length) {
props.component.children.forEach((tabId, index) => {
if (tabIndex === 0 && props.activeTabs.includes(tabId)) {
tabIndex = index;
}
});
}
const { children: tabIds } = props.component;
const activeKey = tabIds[tabIndex];
@@ -408,6 +417,7 @@ Tabs.defaultProps = defaultProps;
function mapStateToProps(state) {
return {
nativeFilters: state.nativeFilters,
activeTabs: state.dashboardState.activeTabs,
directPathToChild: state.dashboardState.directPathToChild,
filterboxMigrationState: state.dashboardState.filterboxMigrationState,
};

View File

@@ -70,6 +70,7 @@ test('Should render menu items', () => {
<Menu onClick={jest.fn()} selectable={false} data-test="main-menu">
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
expect(
screen.getByRole('menuitem', { name: 'Copy dashboard URL' }),
@@ -92,6 +93,7 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
<Menu onClick={jest.fn()} selectable={false} data-test="main-menu">
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
await waitFor(() => {
@@ -119,6 +121,7 @@ test('Click on "Copy dashboard URL" and fail', async () => {
<Menu onClick={jest.fn()} selectable={false} data-test="main-menu">
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
await waitFor(() => {
@@ -147,6 +150,7 @@ test('Click on "Share dashboard by email" and succeed', async () => {
<Menu onClick={jest.fn()} selectable={false} data-test="main-menu">
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
await waitFor(() => {
@@ -177,6 +181,7 @@ test('Click on "Share dashboard by email" and fail', async () => {
<Menu onClick={jest.fn()} selectable={false} data-test="main-menu">
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
await waitFor(() => {

View File

@@ -20,9 +20,9 @@ import React from 'react';
import copyTextToClipboard from 'src/utils/copy';
import { t, logging } from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
import { getDashboardPermalink, getUrlParam } from 'src/utils/urlUtils';
import { URL_PARAMS } from 'src/constants';
import { getFilterValue } from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
import { getDashboardPermalink } from 'src/utils/urlUtils';
import { RootState } from 'src/dashboard/types';
import { useSelector } from 'react-redux';
interface ShareMenuItemProps {
url?: string;
@@ -48,17 +48,17 @@ const ShareMenuItems = (props: ShareMenuItemProps) => {
dashboardComponentId,
...rest
} = props;
const { dataMask, activeTabs } = useSelector((state: RootState) => ({
dataMask: state.dataMask,
activeTabs: state.dashboardState.activeTabs,
}));
async function generateUrl() {
const nativeFiltersKey = getUrlParam(URL_PARAMS.nativeFiltersKey);
let filterState = {};
if (nativeFiltersKey && dashboardId) {
filterState = await getFilterValue(dashboardId, nativeFiltersKey);
}
return getDashboardPermalink({
dashboardId,
filterState,
hash: dashboardComponentId,
dataMask,
activeTabs,
anchor: dashboardComponentId,
});
}

View File

@@ -54,13 +54,13 @@ import {
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { canUserEditDashboard } from 'src/dashboard/util/permissionUtils';
import { getFilterSets } from '../actions/nativeFilters';
import { setDatasetsStatus } from '../actions/dashboardState';
import { getFilterSets } from 'src/dashboard/actions/nativeFilters';
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
import {
getFilterValue,
getPermalinkValue,
} from '../components/nativeFilters/FilterBar/keyValue';
import { filterCardPopoverStyle } from '../styles';
} from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
import { filterCardPopoverStyle } from 'src/dashboard/styles';
export const MigrationContext = React.createContext(
FILTER_BOX_MIGRATION_STATES.NOOP,
@@ -183,19 +183,20 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
async function getDataMaskApplied() {
const permalinkKey = getUrlParam(URL_PARAMS.permalinkKey);
const nativeFilterKeyValue = getUrlParam(URL_PARAMS.nativeFiltersKey);
let dataMaskFromUrl = nativeFilterKeyValue || {};
const isOldRison = getUrlParam(URL_PARAMS.nativeFilters);
let dataMask = nativeFilterKeyValue || {};
let activeTabs: string[] | undefined = [];
if (permalinkKey) {
const permalinkValue = await getPermalinkValue(permalinkKey);
if (permalinkValue) {
dataMaskFromUrl = permalinkValue.state.filterState;
({ dataMask, activeTabs } = permalinkValue.state);
}
} else if (nativeFilterKeyValue) {
dataMaskFromUrl = await getFilterValue(id, nativeFilterKeyValue);
dataMask = await getFilterValue(id, nativeFilterKeyValue);
}
if (isOldRison) {
dataMaskFromUrl = isOldRison;
dataMask = isOldRison;
}
if (readyToRender) {
@@ -207,12 +208,13 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
}
}
dispatch(
hydrateDashboard(
hydrateDashboard({
dashboard,
charts,
activeTabs,
filterboxMigrationState,
dataMaskFromUrl,
),
dataMask,
}),
);
}
return null;

View File

@@ -27,6 +27,7 @@ import {
import { Dataset } from '@superset-ui/chart-controls';
import { chart } from 'src/components/Chart/chartReducer';
import componentTypes from 'src/dashboard/util/componentTypes';
import { UrlParamEntries } from 'src/utils/urlUtils';
import { User } from 'src/types/bootstrapTypes';
import { ChartState } from '../explore/types';
@@ -145,13 +146,17 @@ export type ActiveFilters = {
[key: string]: ActiveFilter;
};
export type DashboardPermalinkValue = {
export interface DashboardPermalinkState {
dataMask: DataMaskStateWithId;
activeTabs: string[];
anchor: string;
urlParams?: UrlParamEntries;
}
export interface DashboardPermalinkValue {
dashboardId: string;
state: {
filterState: DataMaskStateWithId;
hash: string;
};
};
state: DashboardPermalinkState;
}
export type EmbeddedDashboard = {
uuid: string;

View File

@@ -26,6 +26,7 @@ export const useDashboard = (idOrSlug: string | number) =>
useApiV1Resource<Dashboard>(`/api/v1/dashboard/${idOrSlug}`),
dashboard => ({
...dashboard,
// TODO: load these at the API level
metadata:
(dashboard.json_metadata && JSON.parse(dashboard.json_metadata)) || {},
position_data:

View File

@@ -50,10 +50,15 @@ export function parseErrorJson(responseObject: JsonObject): ClientErrorObject {
}
// Marshmallow field validation returns the error mssage in the format
// of { message: { field1: [msg1, msg2], field2: [msg], } }
if (error.message && typeof error.message === 'object' && !error.error) {
error.error =
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
t('Invalid input');
if (!error.error && error.message) {
if (typeof error.message === 'object') {
error.error =
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
t('Invalid input');
}
if (typeof error.message === 'string') {
error.error = error.message;
}
}
if (error.stack) {
error = {

View File

@@ -19,7 +19,6 @@
import { JsonObject, QueryFormData, SupersetClient } from '@superset-ui/core';
import rison from 'rison';
import { isEmpty } from 'lodash';
import { getClientErrorObject } from './getClientErrorObject';
import {
RESERVED_CHART_URL_PARAMS,
RESERVED_DASHBOARD_URL_PARAMS,
@@ -96,7 +95,7 @@ function getUrlParams(excludedParams: string[]): URLSearchParams {
return urlParams;
}
type UrlParamEntries = [string, string][];
export type UrlParamEntries = [string, string][];
function getUrlParamEntries(urlParams: URLSearchParams): UrlParamEntries {
const urlEntries: [string, string][] = [];
@@ -134,14 +133,7 @@ function getPermalink(endpoint: string, jsonPayload: JsonObject) {
return SupersetClient.post({
endpoint,
jsonPayload,
})
.then(result => result.json.url as string)
.catch(response =>
// @ts-ignore
getClientErrorObject(response).then(({ error, statusText }) =>
Promise.reject(error || statusText),
),
);
}).then(result => result.json.url as string);
}
export function getChartPermalink(
@@ -156,17 +148,30 @@ export function getChartPermalink(
export function getDashboardPermalink({
dashboardId,
filterState,
hash, // the anchor part of the link which corresponds to the tab/chart id
dataMask,
activeTabs,
anchor, // the anchor part of the link which corresponds to the tab/chart id
}: {
dashboardId: string | number;
filterState: JsonObject;
hash?: string;
/**
* Current applied data masks (for native filters).
*/
dataMask: JsonObject;
/**
* Current active tabs in the dashboard.
*/
activeTabs: string[];
/**
* The "anchor" component for the permalink. It will be scrolled into view
* and highlighted upon page load.
*/
anchor?: string;
}) {
// only encode filter box state if non-empty
return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, {
filterState,
urlParams: getDashboardUrlParams(),
hash,
dataMask,
activeTabs,
anchor,
});
}

View File

@@ -18,10 +18,16 @@ from marshmallow import fields, Schema
class DashboardPermalinkPostSchema(Schema):
filterState = fields.Dict(
dataMask = fields.Dict(
required=False,
allow_none=True,
description="Native filter state",
description="Data mask used for native filter state",
)
activeTabs = fields.List(
fields.String(),
required=False,
allow_none=True,
description="Current active dashboard tabs",
)
urlParams = fields.List(
fields.Tuple(
@@ -37,6 +43,8 @@ class DashboardPermalinkPostSchema(Schema):
allow_none=True,
description="URL Parameters",
)
hash = fields.String(
required=False, allow_none=True, description="Optional anchor link"
anchor = fields.String(
required=False,
allow_none=True,
description="Optional anchor link added to url hash",
)

View File

@@ -18,8 +18,9 @@ from typing import Any, Dict, List, Optional, Tuple, TypedDict
class DashboardPermalinkState(TypedDict):
filterState: Optional[Dict[str, Any]]
hash: Optional[str]
dataMask: Optional[Dict[str, Any]]
activeTabs: Optional[List[str]]
anchor: Optional[str]
urlParams: Optional[List[Tuple[str, str]]]

View File

@@ -21,6 +21,8 @@ from sqlalchemy.orm import relationship
from superset import security_manager
from superset.models.helpers import AuditMixinNullable, ImportExportMixin
VALUE_MAX_SIZE = 2**24 - 1
class KeyValueEntry(Model, AuditMixinNullable, ImportExportMixin):
"""Key value store entity"""
@@ -28,7 +30,7 @@ class KeyValueEntry(Model, AuditMixinNullable, ImportExportMixin):
__tablename__ = "key_value"
id = Column(Integer, primary_key=True)
resource = Column(String(32), nullable=False)
value = Column(LargeBinary(length=2**24 - 1), nullable=False)
value = Column(LargeBinary(length=VALUE_MAX_SIZE), nullable=False)
created_on = Column(DateTime, nullable=True)
created_by_fk = Column(Integer, ForeignKey("ab_user.id"), nullable=True)
changed_on = Column(DateTime, nullable=True)

View File

@@ -17,16 +17,16 @@
import logging
import os
import time
from typing import Any
from typing import Any, Callable, Iterator, Optional, Union
from uuid import uuid4
from alembic import op
from sqlalchemy import engine_from_config
from sqlalchemy import engine_from_config, inspect
from sqlalchemy.dialects.mysql.base import MySQLDialect
from sqlalchemy.dialects.postgresql.base import PGDialect
from sqlalchemy.engine import reflection
from sqlalchemy.exc import NoSuchTableError
from sqlalchemy.orm import Session
from sqlalchemy.orm import Query, Session
logger = logging.getLogger(__name__)
@@ -80,16 +80,38 @@ def assign_uuids(
print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.\n")
return
# Othwewise Use Python uuid function
for obj in paginated_update(
session.query(model),
lambda current, total: print(
f" uuid assigned to {current} out of {total}", end="\r"
),
batch_size=batch_size,
):
obj.uuid = uuid4
print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.\n")
def paginated_update(
query: Query,
print_page_progress: Optional[Union[Callable[[int, int], None], bool]] = None,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> Iterator[Any]:
"""
Update models in small batches so we don't have to load everything in memory.
"""
start = 0
count = query.count()
session: Session = inspect(query).session
if print_page_progress is None or print_page_progress is True:
print_page_progress = lambda current, total: print(
f" {current}/{total}", end="\r"
)
while start < count:
end = min(start + batch_size, count)
for obj in session.query(model)[start:end]:
obj.uuid = uuid4()
for obj in query[start:end]:
yield obj
session.merge(obj)
session.commit()
if start + batch_size < count:
print(f" uuid assigned to {end} out of {count}\r", end="")
if print_page_progress:
print_page_progress(end, count)
start += batch_size
print(f"Done. Assigned {count} uuids in {time.time() - start_time:.3f}s.\n")

View File

@@ -0,0 +1,91 @@
# 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.
"""permalink_rename_filterState
Revision ID: 7fb8bca906d2
Revises: f3afaf1f11f0
Create Date: 2022-06-27 14:59:20.740380
"""
# revision identifiers, used by Alembic.
revision = "7fb8bca906d2"
down_revision = "f3afaf1f11f0"
import pickle
from alembic import op
from sqlalchemy import Column, Integer, LargeBinary, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session
from superset import db
from superset.migrations.shared.utils import paginated_update
Base = declarative_base()
VALUE_MAX_SIZE = 2**24 - 1
DASHBOARD_PERMALINK_RESOURCE_TYPE = "dashboard_permalink"
class KeyValueEntry(Base):
__tablename__ = "key_value"
id = Column(Integer, primary_key=True)
resource = Column(String(32), nullable=False)
value = Column(LargeBinary(length=VALUE_MAX_SIZE), nullable=False)
def upgrade():
bind = op.get_bind()
session: Session = db.Session(bind=bind)
for entry in paginated_update(
session.query(KeyValueEntry).filter(
KeyValueEntry.resource == DASHBOARD_PERMALINK_RESOURCE_TYPE
)
):
value = pickle.loads(entry.value) or {}
state = value.get("state")
if state:
if "filterState" in state:
state["dataMask"] = state["filterState"]
del state["filterState"]
if "hash" in state:
state["anchor"] = state["hash"]
del state["hash"]
entry.value = pickle.dumps(value)
session.commit()
def downgrade():
bind = op.get_bind()
session: Session = db.Session(bind=bind)
for entry in paginated_update(
session.query(KeyValueEntry).filter(
KeyValueEntry.resource == DASHBOARD_PERMALINK_RESOURCE_TYPE
),
):
value = pickle.loads(entry.value) or {}
state = value.get("state")
if state:
if "dataMask" in state:
state["filterState"] = state["dataMask"]
del state["dataMask"]
if "anchor" in state:
state["hash"] = state["anchor"]
del state["anchor"]
entry.value = pickle.dumps(value)
session.merge(entry)
session.commit()

View File

@@ -2001,13 +2001,13 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
return redirect("/dashboard/list/")
if not value:
return json_error_response(_("permalink state not found"), status=404)
dashboard_id = value["dashboardId"]
dashboard_id, state = value["dashboardId"], value.get("state", {})
url = f"/superset/dashboard/{dashboard_id}?permalink_key={key}"
url_params = value["state"].get("urlParams")
url_params = state.get("urlParams")
if url_params:
params = parse.urlencode(url_params)
url = f"{url}&{params}"
hash_ = value["state"].get("hash")
hash_ = state.get("anchor", state.get("hash"))
if hash_:
url = f"{url}#{hash_}"
return redirect(url)

View File

@@ -38,8 +38,8 @@ from tests.integration_tests.fixtures.world_bank_dashboard import (
from tests.integration_tests.test_app import app
STATE = {
"filterState": {"FILTER_1": "foo"},
"hash": "my-anchor",
"dataMask": {"FILTER_1": "foo"},
"activeTabs": ["my-anchor"],
}