mirror of
https://github.com/apache/superset.git
synced 2026-04-18 23:55:00 +00:00
feat: save active tabs in dashboard permalink (#19983)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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]]]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user