diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js index 5d535b3be9b3..6c98f701fc93 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -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, }, diff --git a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx index f2af7af1dcbc..2d13cedcd63e 100644 --- a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx +++ b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx @@ -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={ -
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{ + e.stopPropagation(); + }} + > { + 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, }; diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx index 498009224a5e..bf247bc24925 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx @@ -70,6 +70,7 @@ test('Should render menu items', () => { , + { useRedux: true }, ); expect( screen.getByRole('menuitem', { name: 'Copy dashboard URL' }), @@ -92,6 +93,7 @@ test('Click on "Copy dashboard URL" and succeed', async () => { , + { useRedux: true }, ); await waitFor(() => { @@ -119,6 +121,7 @@ test('Click on "Copy dashboard URL" and fail', async () => { , + { useRedux: true }, ); await waitFor(() => { @@ -147,6 +150,7 @@ test('Click on "Share dashboard by email" and succeed', async () => { , + { useRedux: true }, ); await waitFor(() => { @@ -177,6 +181,7 @@ test('Click on "Share dashboard by email" and fail', async () => { , + { useRedux: true }, ); await waitFor(() => { diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index f9016e5263c0..d0d8844cdd2b 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -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, }); } diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 39ba7dbceb82..ef3b4893e31a 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -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 = ({ 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 = ({ idOrSlug }: PageProps) => { } } dispatch( - hydrateDashboard( + hydrateDashboard({ dashboard, charts, + activeTabs, filterboxMigrationState, - dataMaskFromUrl, - ), + dataMask, + }), ); } return null; diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index e4b8227689ce..aabc2e5c2e77 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -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; diff --git a/superset-frontend/src/hooks/apiResources/dashboards.ts b/superset-frontend/src/hooks/apiResources/dashboards.ts index 9f512d5b15b2..b21cc668c06a 100644 --- a/superset-frontend/src/hooks/apiResources/dashboards.ts +++ b/superset-frontend/src/hooks/apiResources/dashboards.ts @@ -26,6 +26,7 @@ export const useDashboard = (idOrSlug: string | number) => useApiV1Resource(`/api/v1/dashboard/${idOrSlug}`), dashboard => ({ ...dashboard, + // TODO: load these at the API level metadata: (dashboard.json_metadata && JSON.parse(dashboard.json_metadata)) || {}, position_data: diff --git a/superset-frontend/src/utils/getClientErrorObject.ts b/superset-frontend/src/utils/getClientErrorObject.ts index 3451528f025c..b6b6c58995da 100644 --- a/superset-frontend/src/utils/getClientErrorObject.ts +++ b/superset-frontend/src/utils/getClientErrorObject.ts @@ -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)[0]?.[0] || - t('Invalid input'); + if (!error.error && error.message) { + if (typeof error.message === 'object') { + error.error = + Object.values(error.message as Record)[0]?.[0] || + t('Invalid input'); + } + if (typeof error.message === 'string') { + error.error = error.message; + } } if (error.stack) { error = { diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts index bd570291f2cb..0ca104ef024f 100644 --- a/superset-frontend/src/utils/urlUtils.ts +++ b/superset-frontend/src/utils/urlUtils.ts @@ -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, }); } diff --git a/superset/dashboards/permalink/schemas.py b/superset/dashboards/permalink/schemas.py index a0fc1cbc5598..ce222d7ed62c 100644 --- a/superset/dashboards/permalink/schemas.py +++ b/superset/dashboards/permalink/schemas.py @@ -18,10 +18,16 @@ 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", ) diff --git a/superset/dashboards/permalink/types.py b/superset/dashboards/permalink/types.py index e93076ba2378..91c5a9620cf7 100644 --- a/superset/dashboards/permalink/types.py +++ b/superset/dashboards/permalink/types.py @@ -18,8 +18,9 @@ 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]]] diff --git a/superset/key_value/models.py b/superset/key_value/models.py index f846d9039d4e..f92457d19017 100644 --- a/superset/key_value/models.py +++ b/superset/key_value/models.py @@ -21,6 +21,8 @@ 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) diff --git a/superset/migrations/shared/utils.py b/superset/migrations/shared/utils.py index 4b0c4e1440dd..614590409bd2 100644 --- a/superset/migrations/shared/utils.py +++ b/superset/migrations/shared/utils.py @@ -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") diff --git a/superset/migrations/versions/2022-06-27_14-59_7fb8bca906d2_permalink_rename_filterstate.py b/superset/migrations/versions/2022-06-27_14-59_7fb8bca906d2_permalink_rename_filterstate.py new file mode 100644 index 000000000000..ecd424d12a15 --- /dev/null +++ b/superset/migrations/versions/2022-06-27_14-59_7fb8bca906d2_permalink_rename_filterstate.py @@ -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() diff --git a/superset/views/core.py b/superset/views/core.py index 10c6aaa04840..1625a691aa97 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -2001,13 +2001,13 @@ def dashboard_permalink( # pylint: disable=no-self-use 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) diff --git a/tests/integration_tests/dashboards/permalink/api_tests.py b/tests/integration_tests/dashboards/permalink/api_tests.py index 33186131d559..12d758d5eb3f 100644 --- a/tests/integration_tests/dashboards/permalink/api_tests.py +++ b/tests/integration_tests/dashboards/permalink/api_tests.py @@ -38,8 +38,8 @@ from tests.integration_tests.test_app import app STATE = { - "filterState": {"FILTER_1": "foo"}, - "hash": "my-anchor", + "dataMask": {"FILTER_1": "foo"}, + "activeTabs": ["my-anchor"], }