Compare commits

..
Author SHA1 Message Date
Vitor Avila 2c2e9f524c Reusing old logic before #23228 2025-04-02 17:21:14 -03:00
Evan Rusackas abac881f1a Merge branch 'master' into bugfix/hide-filters 2025-04-02 08:52:24 -06:00
hexcafe cfbda8162f fix show_filters not working 2024-06-30 17:20:08 -07:00
49 changed files with 307 additions and 1194 deletions
-1
View File
@@ -137,7 +137,6 @@ Join our growing community!
- [Virtuoso QA](https://www.virtuosoqa.com)
- [Whale](https://whale.im)
- [Windsor.ai](https://www.windsor.ai/) [@octaviancorlade]
- [WinWin Network马上赢](https://brandct.cn/) [@wenbinye]
- [Zeta](https://www.zeta.tech/) [@shaikidris]
### Media & Entertainment
+1
View File
@@ -62,6 +62,7 @@ MAPBOX_API_KEY=''
# Make sure you set this to a unique secure random value on production
SUPERSET_SECRET_KEY=TEST_NON_DEV_SECRET
ENABLE_PLAYWRIGHT=false
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
BUILD_SUPERSET_FRONTEND_IN_DOCKER=true
@@ -12,12 +12,8 @@ import useBaseUrl from "@docusaurus/useBaseUrl";
This section is focused on documentation for end-users who will be using Superset
for the data analysis and exploration workflow
(data analysts, business analysts, data
scientists, etc).
:::tip
In addition to this site, [Preset.io](http://preset.io/) maintains an updated set of end-user
scientists, etc). In addition to this site, [Preset.io](http://preset.io/) maintains an updated set of end-user
documentation at [docs.preset.io](https://docs.preset.io/).
:::
This tutorial targets someone who wants to create charts and dashboards in Superset. Well show you
how to connect Superset to a new database and configure a table in that database for analysis.
@@ -179,36 +175,23 @@ into a position you like onto the underlying grid.
Congrats! Youve successfully linked, analyzed, and visualized data in Superset. There are a wealth
of other table configuration and visualization options, so please start exploring and creating
slices and dashboards of your own.
slices and dashboards of your own
ֿ
### Manage access to Dashboards
Access to dashboards is managed via owners (users that have edit permissions to the dashboard).
Access to dashboards is managed via owners (users that have edit permissions to the dashboard)
Non-owner users access can be managed in two different ways. The dashboard needs to be published to be visible to other users.
Non-owner users access can be managed two different ways:
1. Dataset permissions - if you add to the relevant role permissions to datasets it automatically grants implicit access to all dashboards that uses those permitted datasets.
2. Dashboard roles - if you enable [**DASHBOARD_RBAC** feature flag](/docs/configuration/configuring-superset#feature-flags) then you will be able to manage which roles can access the dashboard
1. Dataset permissions - if you add to the relevant role permissions to datasets it automatically grants implicit access to all dashboards that uses those permitted datasets
2. Dashboard roles - if you enable **DASHBOARD_RBAC** [feature flag](/docs/configuration/configuring-superset#feature-flags) then you be able to manage which roles can access the dashboard
- Granting a role access to a dashboard will bypass dataset level checks. Having dashboard access implicitly grants read access to all the featured charts in the dashboard, and thereby also all the associated datasets.
- If no roles are specified for a dashboard, regular **Dataset permissions** will apply.
<img src={useBaseUrl("/img/tutorial/tutorial_dashboard_access.png" )} />
### Publishing a Dashboard
If you would like to make your dashboard available to other users, click on the `Draft` button next to the
title of your dashboard.
<img src={useBaseUrl("/img/tutorial/publish_button_dashboard.png" )} />
:::warning
Draft dashboards are only visible to the dashboard owners and admins. Published dashboards are visible to all users with access to the underlying datasets or if RBAC is enabled, to the roles that have been granted access to the dashboard.
:::
### Mark a Dashboard as Favorite
You can mark a dashboard as a favorite by clicking on the star icon next to the title of your dashboard. This makes it easier to find it in the list of dashboards or on the home page.
### Customizing dashboard
The following URL parameters can be used to modify how the dashboard is rendered:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

+4 -3
View File
@@ -5705,11 +5705,12 @@ estree-util-to-js@^2.0.0:
source-map "^0.7.0"
estree-util-value-to-estree@^3.0.1:
version "3.3.3"
resolved "https://registry.yarnpkg.com/estree-util-value-to-estree/-/estree-util-value-to-estree-3.3.3.tgz#800b03a551b466dd77ed2c574b042a9992546cf2"
integrity sha512-Db+m1WSD4+mUO7UgMeKkAwdbfNWwIxLt48XF2oFU9emPfXkIu+k5/nlOj313v7wqtAPo0f9REhUvznFrPkG8CQ==
version "3.1.1"
resolved "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.1.1.tgz"
integrity sha512-5mvUrF2suuv5f5cGDnDphIy4/gW86z82kl5qG6mM9z04SEQI4FB5Apmaw/TGEf3l55nLtMs5s51dmhUzvAHQCA==
dependencies:
"@types/estree" "^1.0.0"
is-plain-obj "^4.0.0"
estree-util-visit@^2.0.0:
version "2.0.0"
+4 -3
View File
@@ -28,8 +28,9 @@ async_timeout>=4.0.0,<5.0.0
# a bit of attention to bump.
apispec>=6.0.0,<6.7.0
# 1.4.1 appears to use much more memory, where the python test suite runs out of memory
# causing CI to fail. 1.4.0 is the last version that works.
# 1.4.0 appears to use much more memory, where the python test suite runs out of memory
# causing CI to fail. 1.3.0 is the last version that works.
# This is probably related to the changes around PickleType
# https://marshmallow-sqlalchemy.readthedocs.io/en/latest/changelog.html#id3
# Opened this issue https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/665
marshmallow-sqlalchemy>=1.3.0,<1.4.1
marshmallow-sqlalchemy>=1.3.0,<1.4.0
+2 -1
View File
@@ -158,6 +158,7 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.14.0
@@ -215,7 +216,7 @@ marshmallow==3.26.1
# via
# flask-appbuilder
# marshmallow-sqlalchemy
marshmallow-sqlalchemy==1.4.0
marshmallow-sqlalchemy==1.3.0
# via
# -r requirements/base.in
# flask-appbuilder
+2 -1
View File
@@ -318,6 +318,7 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -434,7 +435,7 @@ marshmallow==3.26.1
# -c requirements/base.txt
# flask-appbuilder
# marshmallow-sqlalchemy
marshmallow-sqlalchemy==1.4.0
marshmallow-sqlalchemy==1.3.0
# via
# -c requirements/base.txt
# flask-appbuilder
+58 -100
View File
@@ -19,7 +19,7 @@
import {
DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY,
IFRAME_COMMS_MESSAGE_TYPE,
IFRAME_COMMS_MESSAGE_TYPE
} from './const';
// We can swap this out for the actual switchboard package once it gets published
@@ -34,62 +34,50 @@ import { getGuestTokenRefreshTiming } from './guestTokenRefresh';
export type GuestTokenFetchFn = () => Promise<string>;
export type UiConfigType = {
hideTitle?: boolean;
hideTab?: boolean;
hideChartControls?: boolean;
emitDataMasks?: boolean;
hideTitle?: boolean
hideTab?: boolean
hideChartControls?: boolean
filters?: {
[key: string]: boolean | undefined;
visible?: boolean;
expanded?: boolean;
};
[key: string]: boolean | undefined
visible?: boolean
expanded?: boolean
}
urlParams?: {
[key: string]: any;
};
};
[key: string]: any
}
}
export type EmbedDashboardParams = {
/** The id provided by the embed configuration UI in Superset */
id: string;
id: string
/** The domain where Superset can be located, with protocol, such as: https://superset.example.com */
supersetDomain: string;
supersetDomain: string
/** The html element within which to mount the iframe */
mountPoint: HTMLElement;
mountPoint: HTMLElement
/** A function to fetch a guest token from the Host App's backend server */
fetchGuestToken: GuestTokenFetchFn;
fetchGuestToken: GuestTokenFetchFn
/** The dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded **/
dashboardUiConfig?: UiConfigType;
dashboardUiConfig?: UiConfigType
/** Are we in debug mode? */
debug?: boolean;
debug?: boolean
/** The iframe title attribute */
iframeTitle?: string;
iframeTitle?: string
/** additional iframe sandbox attributes ex (allow-top-navigation, allow-popups-to-escape-sandbox) **/
iframeSandboxExtras?: string[];
iframeSandboxExtras?: string[]
/** force a specific refererPolicy to be used in the iframe request **/
referrerPolicy?: ReferrerPolicy;
};
referrerPolicy?: ReferrerPolicy
}
export type Size = {
width: number;
height: number;
};
width: number, height: number
}
export type ObserveDataMaskCallbackFn = (
dataMask: Record<string, any> & {
crossFiltersChanged: boolean;
nativeFiltersChanged: boolean;
},
) => void;
export type EmbeddedDashboard = {
getScrollSize: () => Promise<Size>;
unmount: () => void;
getDashboardPermalink: (anchor: string) => Promise<string>;
getActiveTabs: () => Promise<string[]>;
observeDataMask: (
callbackFn: ObserveDataMaskCallbackFn,
) => void;
getDataMask: () => Record<string, any>;
};
getScrollSize: () => Promise<Size>
unmount: () => void
getDashboardPermalink: (anchor: string) => Promise<string>
getActiveTabs: () => Promise<string[]>
}
/**
* Embeds a Superset dashboard into the page using an iframe.
@@ -101,7 +89,7 @@ export async function embedDashboard({
fetchGuestToken,
dashboardUiConfig,
debug = false,
iframeTitle = 'Embedded Dashboard',
iframeTitle = "Embedded Dashboard",
iframeSandboxExtras = [],
referrerPolicy,
}: EmbedDashboardParams): Promise<EmbeddedDashboard> {
@@ -113,67 +101,52 @@ export async function embedDashboard({
log('embedding');
if (supersetDomain.endsWith('/')) {
if (supersetDomain.endsWith("/")) {
supersetDomain = supersetDomain.slice(0, -1);
}
function calculateConfig() {
let configNumber = 0;
if (dashboardUiConfig) {
if (dashboardUiConfig.hideTitle) {
configNumber += 1;
let configNumber = 0
if(dashboardUiConfig) {
if(dashboardUiConfig.hideTitle) {
configNumber += 1
}
if (dashboardUiConfig.hideTab) {
configNumber += 2;
if(dashboardUiConfig.hideTab) {
configNumber += 2
}
if (dashboardUiConfig.hideChartControls) {
configNumber += 8;
}
if (dashboardUiConfig.emitDataMasks) {
configNumber += 16;
if(dashboardUiConfig.hideChartControls) {
configNumber += 8
}
}
return configNumber;
return configNumber
}
async function mountIframe(): Promise<Switchboard> {
return new Promise(resolve => {
const iframe = document.createElement('iframe');
const dashboardConfigUrlParams = dashboardUiConfig
? { uiConfig: `${calculateConfig()}` }
: undefined;
const filterConfig = dashboardUiConfig?.filters || {};
const filterConfigKeys = Object.keys(filterConfig);
const filterConfigUrlParams = Object.fromEntries(
filterConfigKeys.map(key => [
DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY[key],
filterConfig[key],
]),
);
const dashboardConfigUrlParams = dashboardUiConfig ? {uiConfig: `${calculateConfig()}`} : undefined;
const filterConfig = dashboardUiConfig?.filters || {}
const filterConfigKeys = Object.keys(filterConfig)
const filterConfigUrlParams = Object.fromEntries(filterConfigKeys.map(
key => [DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY[key], filterConfig[key]]))
// Allow url query parameters from dashboardUiConfig.urlParams to override the ones from filterConfig
const urlParams = {
...dashboardConfigUrlParams,
...filterConfigUrlParams,
...dashboardUiConfig?.urlParams,
};
const urlParamsString = Object.keys(urlParams).length
? '?' + new URLSearchParams(urlParams).toString()
: '';
const urlParams = {...dashboardConfigUrlParams, ...filterConfigUrlParams, ...dashboardUiConfig?.urlParams}
const urlParamsString = Object.keys(urlParams).length ? '?' + new URLSearchParams(urlParams).toString() : ''
// set up the iframe's sandbox configuration
iframe.sandbox.add('allow-same-origin'); // needed for postMessage to work
iframe.sandbox.add('allow-scripts'); // obviously the iframe needs scripts
iframe.sandbox.add('allow-presentation'); // for fullscreen charts
iframe.sandbox.add('allow-downloads'); // for downloading charts as image
iframe.sandbox.add('allow-forms'); // for forms to submit
iframe.sandbox.add('allow-popups'); // for exporting charts as csv
iframe.sandbox.add("allow-same-origin"); // needed for postMessage to work
iframe.sandbox.add("allow-scripts"); // obviously the iframe needs scripts
iframe.sandbox.add("allow-presentation"); // for fullscreen charts
iframe.sandbox.add("allow-downloads"); // for downloading charts as image
iframe.sandbox.add("allow-forms"); // for forms to submit
iframe.sandbox.add("allow-popups"); // for exporting charts as csv
// additional sandbox props
iframeSandboxExtras.forEach((key: string) => {
iframe.sandbox.add(key);
});
// force a specific refererPolicy to be used in the iframe request
if (referrerPolicy) {
if(referrerPolicy) {
iframe.referrerPolicy = referrerPolicy;
}
@@ -189,26 +162,20 @@ export async function embedDashboard({
// See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
// we know the content window isn't null because we are in the load event handler.
iframe.contentWindow!.postMessage(
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: 'port transfer' },
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: "port transfer" },
supersetDomain,
[theirPort],
);
)
log('sent message channel to the iframe');
// return our port from the promise
resolve(
new Switchboard({
port: ourPort,
name: 'superset-embedded-sdk',
debug,
}),
);
resolve(new Switchboard({ port: ourPort, name: 'superset-embedded-sdk', debug }));
});
iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`;
iframe.title = iframeTitle;
//@ts-ignore
mountPoint.replaceChildren(iframe);
log('placed the iframe');
log('placed the iframe')
});
}
@@ -237,21 +204,12 @@ export async function embedDashboard({
const getScrollSize = () => ourPort.get<Size>('getScrollSize');
const getDashboardPermalink = (anchor: string) =>
ourPort.get<string>('getDashboardPermalink', { anchor });
const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs');
const getDataMask = () => ourPort.get<Record<string, any>>('getDataMask');
const observeDataMask = (
callbackFn: ObserveDataMaskCallbackFn,
) => {
ourPort.start();
ourPort.defineMethod('observeDataMask', callbackFn);
};
const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs')
return {
getScrollSize,
unmount,
getDashboardPermalink,
getActiveTabs,
observeDataMask,
getDataMask,
};
}
@@ -89,9 +89,9 @@ const controlPanel: ControlPanelConfig = {
{
name: 'metric',
config: {
...sharedControls.metric,
clearable: true,
validators: [],
...optionalEntity,
type: 'DndMetricSelect',
label: t('Metric'),
description: t('Metric for node values'),
},
},
+14 -16
View File
@@ -252,30 +252,28 @@ export function querySuccess(query, results) {
return { type: QUERY_SUCCESS, query, results };
}
export function logFailedQuery(query, errors) {
export function queryFailed(query, msg, link, errors) {
return function (dispatch) {
const eventData = {
has_err: true,
start_offset: query.startDttm,
ts: new Date().getTime(),
};
errors?.forEach(({ error_type: errorType, message, extra }) => {
const issueCodes = extra?.issue_codes?.map(({ code }) => code) || [-1];
dispatch(
logEvent(LOG_ACTIONS_SQLLAB_FETCH_FAILED_QUERY, {
...eventData,
error_type: errorType,
issue_codes: issueCodes,
error_details: message,
}),
);
errors?.forEach(({ error_type: errorType, extra }) => {
const messages = extra?.issue_codes?.map(({ message }) => message) || [
errorType,
];
messages.forEach(message => {
dispatch(
logEvent(LOG_ACTIONS_SQLLAB_FETCH_FAILED_QUERY, {
...eventData,
error_type: errorType,
error_details: message,
}),
);
});
});
};
}
export function queryFailed(query, msg, link, errors) {
return function (dispatch) {
dispatch(logFailedQuery(query, errors));
dispatch({ type: QUERY_FAILED, query, msg, link, errors });
};
}
@@ -294,7 +294,7 @@ describe('async actions', () => {
});
it('calls queryFailed on fetch error and logs the error details', () => {
expect.assertions(2);
expect.assertions(3);
fetchMock.post(
runQueryEndpoint,
@@ -312,6 +312,7 @@ describe('async actions', () => {
const expectedActionTypes = [
actions.START_QUERY,
LOG_EVENT,
LOG_EVENT,
actions.QUERY_FAILED,
];
const { dispatch } = store;
@@ -319,7 +320,12 @@ describe('async actions', () => {
return request(dispatch, () => initialState).then(() => {
const actions = store.getActions();
expect(actions.map(a => a.type)).toEqual(expectedActionTypes);
expect(actions[1].payload.eventData.issue_codes).toEqual([1000, 1001]);
expect(actions[1].payload.eventData.error_details).toContain(
'Issue 1000',
);
expect(actions[2].payload.eventData.error_details).toContain(
'Issue 1001',
);
});
});
});
@@ -16,12 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { QueryState } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render, waitFor } from 'spec/helpers/testing-library';
import { LOG_ACTIONS_SQLLAB_FETCH_FAILED_QUERY } from 'src/logger/LogUtils';
import {
CLEAR_INACTIVE_QUERIES,
REFRESH_QUERIES,
@@ -33,13 +31,9 @@ import QueryAutoRefresh, {
} from 'src/SqlLab/components/QueryAutoRefresh';
import { successfulQuery, runningQuery } from 'src/SqlLab/fixtures';
import { QueryDictionary } from 'src/SqlLab/types';
import mockDatabases from 'spec/fixtures/mockDatabases';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const mockState = {
databases: mockDatabases,
};
// NOTE: The uses of @ts-ignore in this file is to enable testing of bad inputs to verify the
// function / component handles bad data elegantly
@@ -112,9 +106,7 @@ describe('QueryAutoRefresh', () => {
});
it('Attempts to refresh when given pending query', async () => {
const store = mockStore({
sqlLab: { ...mockState },
});
const store = mockStore();
fetchMock.get(refreshApi, {
result: [
{
@@ -143,7 +135,7 @@ describe('QueryAutoRefresh', () => {
});
it('Attempts to clear inactive queries when updated queries are empty', async () => {
const store = mockStore({ sqlLab: { ...mockState } });
const store = mockStore();
fetchMock.get(refreshApi, {
result: [],
});
@@ -171,7 +163,7 @@ describe('QueryAutoRefresh', () => {
});
it('Does not fail and attempts to refresh when given pending query and invalid query', async () => {
const store = mockStore({ sqlLab: { ...mockState } });
const store = mockStore();
fetchMock.get(refreshApi, {
result: [
{
@@ -201,7 +193,7 @@ describe('QueryAutoRefresh', () => {
});
it('Does NOT Attempt to refresh when given only completed queries', async () => {
const store = mockStore({ sqlLab: { ...mockState } });
const store = mockStore();
fetchMock.get(refreshApi, {
result: [
{
@@ -228,57 +220,4 @@ describe('QueryAutoRefresh', () => {
);
expect(fetchMock.calls(refreshApi)).toHaveLength(0);
});
it('logs the failed error for async queries', async () => {
const store = mockStore({ sqlLab: { ...mockState } });
fetchMock.get(refreshApi, {
result: [
{
id: runningQuery.id,
dbId: 1,
state: QueryState.Failed,
extra: {
errors: [
{
error_type: 'TEST_ERROR',
level: 'error',
message: 'Syntax invalid',
extra: {
issue_codes: [
{
code: 102,
message: 'DB failed',
},
],
},
},
],
},
},
],
});
render(
<QueryAutoRefresh
queries={runningQueries}
queriesLastUpdate={queriesLastUpdate}
/>,
{ useRedux: true, store },
);
await waitFor(
() =>
expect(store.getActions()).toContainEqual(
expect.objectContaining({
payload: expect.objectContaining({
eventName: LOG_ACTIONS_SQLLAB_FETCH_FAILED_QUERY,
eventData: expect.objectContaining({
error_type: 'TEST_ERROR',
error_details: 'Syntax invalid',
issue_codes: [102],
}),
}),
}),
),
{ timeout: QUERY_UPDATE_FREQ + 100 },
);
});
});
@@ -17,7 +17,7 @@
* under the License.
*/
import { useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useDispatch } from 'react-redux';
import { isObject } from 'lodash';
import rison from 'rison';
import {
@@ -25,17 +25,13 @@ import {
Query,
runningQueryStateList,
QueryResponse,
QueryState,
lruCache,
} from '@superset-ui/core';
import { QueryDictionary, SqlLabRootState } from 'src/SqlLab/types';
import { QueryDictionary } from 'src/SqlLab/types';
import useInterval from 'src/SqlLab/utils/useInterval';
import {
refreshQueries,
clearInactiveQueries,
logFailedQuery,
} from 'src/SqlLab/actions/sqlLab';
import type { DatabaseObject } from 'src/features/databases/types';
export const QUERY_UPDATE_FREQ = 2000;
const QUERY_UPDATE_BUFFER_MS = 5000;
@@ -71,17 +67,6 @@ function QueryAutoRefresh({
// pendingRequest check ensures we only have one active http call to check for query statuses
const pendingRequestRef = useRef(false);
const cleanInactiveRequestRef = useRef(false);
const failedQueries = useRef(lruCache(1000));
const databases = useSelector<SqlLabRootState>(
({ sqlLab }) => sqlLab.databases,
) as Record<string, DatabaseObject>;
const asyncFetchDbs = useRef(
new Set(
Object.values(databases)
.filter(({ allow_run_async }) => Boolean(allow_run_async))
.map(({ id }) => id),
),
);
const dispatch = useDispatch();
const checkForRefresh = () => {
@@ -112,17 +97,6 @@ function QueryAutoRefresh({
{},
) ?? {};
dispatch(refreshQueries(queries));
jsonPayload.result.forEach(query => {
const { id, dbId, state } = query;
if (
asyncFetchDbs.current.has(dbId) &&
!failedQueries.current.has(id) &&
state === QueryState.Failed
) {
dispatch(logFailedQuery(query, query.extra?.errors));
failedQueries.current.set(id, true);
}
});
} else {
dispatch(clearInactiveQueries(QUERY_UPDATE_FREQ));
}
@@ -16,37 +16,42 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react';
import { IconButton } from 'src/components/IconButton';
import IconButton, { IconButtonProps } from '.';
const meta: Meta<typeof IconButton> = {
title: 'Components/IconButton',
export default {
title: 'IconButton',
component: IconButton,
argTypes: {
onClick: { action: 'clicked' },
},
parameters: {
a11y: {
enabled: true,
};
export const InteractiveIconButton = (args: IconButtonProps) => (
<IconButton
buttonText={args.buttonText}
altText={args.altText}
icon={args.icon}
href={args.href}
target={args.target}
htmlType={args.htmlType}
/>
);
InteractiveIconButton.args = {
buttonText: 'This is the IconButton text',
altText: 'This is an example of non-default alt text',
href: 'https://preset.io/',
target: '_blank',
};
InteractiveIconButton.argTypes = {
icon: {
defaultValue: '/images/icons/sql.svg',
control: {
type: 'select',
},
},
};
export default meta;
type Story = StoryObj<typeof IconButton>;
export const Default: Story = {
args: {
buttonText: 'Default IconButton',
altText: 'Default icon button alt text',
},
};
export const CustomIcon: Story = {
args: {
buttonText: 'Custom icon IconButton',
altText: 'Custom icon button alt text',
icon: '/images/sqlite.png',
options: [
'/images/icons/sql.svg',
'/images/icons/server.svg',
'/images/icons/image.svg',
'Click to see example alt text',
],
},
};
@@ -16,33 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import IconButton from 'src/components/IconButton';
export default {
1: {
allow_ctas: false,
allow_cvas: false,
allow_dml: false,
allow_file_upload: false,
allow_run_async: true,
backend: 'postgresql',
database_name: 'examples',
expose_in_sqllab: true,
force_ctas_schema: null,
id: 1,
},
const defaultProps = {
buttonText: 'This is the IconButton text',
icon: '/images/icons/sql.svg',
};
export const disabledAsyncDb = {
21: {
allow_ctas: false,
allow_cvas: false,
allow_dml: false,
allow_file_upload: false,
allow_run_async: false,
backend: 'postgresql',
database_name: 'examples',
expose_in_sqllab: true,
force_ctas_schema: null,
id: 21,
},
};
describe('IconButton', () => {
it('renders an IconButton', () => {
render(<IconButton {...defaultProps} />);
const icon = screen.getByRole('img');
const buttonText = screen.getByText(/this is the iconbutton text/i);
expect(icon).toBeVisible();
expect(buttonText).toBeVisible();
});
});
@@ -1,90 +0,0 @@
/**
* 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 { render, screen, fireEvent } from 'spec/helpers/testing-library';
import { IconButton } from 'src/components/IconButton';
const defaultProps = {
buttonText: 'This is the IconButton text',
icon: '/images/icons/sql.svg',
};
describe('IconButton', () => {
it('renders an IconButton with icon and text', () => {
render(<IconButton {...defaultProps} />);
const icon = screen.getByRole('img');
const buttonText = screen.getByText(/this is the iconbutton text/i);
expect(icon).toBeVisible();
expect(buttonText).toBeVisible();
});
it('is keyboard accessible and has correct aria attributes', () => {
render(<IconButton {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('tabIndex', '0');
expect(button).toHaveAttribute('aria-label', defaultProps.buttonText);
});
it('handles Enter and Space key presses', () => {
const mockOnClick = jest.fn();
render(<IconButton {...defaultProps} onClick={mockOnClick} />);
const button = screen.getByRole('button');
fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(button, { key: ' ', code: 'Space' });
expect(mockOnClick).toHaveBeenCalledTimes(2);
});
it('uses custom alt text when provided', () => {
const customAltText = 'Custom Alt Text';
render(
<IconButton
buttonText="Custom Alt Text Button"
icon="/images/icons/sql.svg"
altText={customAltText}
/>,
);
const icon = screen.getByAltText(customAltText);
expect(icon).toBeVisible();
});
it('displays tooltip with button text', () => {
render(<IconButton {...defaultProps} />);
const tooltipTrigger = screen.getByText(/this is the iconbutton text/i);
expect(tooltipTrigger).toBeVisible();
});
it('calls onClick handler when clicked', () => {
const mockOnClick = jest.fn();
render(<IconButton {...defaultProps} onClick={mockOnClick} />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
});
@@ -16,90 +16,129 @@
* specific language governing permissions and limitations
* under the License.
*/
// eslint-disable-next-line
import { Typography } from 'src/components';
import { Tooltip } from 'src/components/Tooltip';
import Card, { CardProps } from 'src/components/Card';
import { styled } from '@superset-ui/core';
import Button, { ButtonProps as AntdButtonProps } from 'src/components/Button';
import { Icons } from 'src/components/Icons';
import { SupersetTheme, css } from '@superset-ui/core';
import LinesEllipsis from 'react-lines-ellipsis';
export interface IconButtonProps extends CardProps {
export interface IconButtonProps extends AntdButtonProps {
buttonText: string;
icon: string;
altText?: string;
}
const IconButton: React.FC<IconButtonProps> = ({
buttonText,
icon,
altText,
...cardProps
}) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
if (cardProps.onClick) {
(cardProps.onClick as React.EventHandler<React.SyntheticEvent>)(e);
}
if (e.key === ' ') {
e.preventDefault();
}
const StyledButton = styled(Button)`
height: auto;
display: flex;
flex-direction: column;
padding: 0;
`;
const StyledImage = styled.div`
padding: ${({ theme }) => theme.gridUnit * 4}px;
height: ${({ theme }) => theme.gridUnit * 18}px;
margin: ${({ theme }) => theme.gridUnit * 3}px 0;
.default-db-icon {
font-size: 36px;
color: ${({ theme }) => theme.colors.grayscale.base};
margin-right: 0;
span:first-of-type {
margin-right: 0;
}
cardProps.onKeyDown?.(e);
};
}
const renderIcon = () => {
const iconContent = icon ? (
<img
src={icon}
alt={altText || buttonText}
css={css`
width: 100%;
height: 120px;
object-fit: contain;
`}
/>
) : (
<div
css={css`
display: flex;
align-content: center;
align-items: center;
height: 120px;
`}
>
<Icons.DatabaseOutlined
css={css`
font-size: 48px;
`}
aria-label="default-icon"
/>
</div>
);
&:first-of-type {
margin-right: 0;
}
return iconContent;
};
img {
width: ${({ theme }) => theme.gridUnit * 10}px;
height: ${({ theme }) => theme.gridUnit * 10}px;
margin: 0;
&:first-of-type {
margin-right: 0;
}
}
svg {
&:first-of-type {
margin-right: 0;
}
}
`;
return (
<Card
hoverable
role="button"
tabIndex={0}
aria-label={buttonText}
onKeyDown={handleKeyDown}
cover={renderIcon()}
css={(theme: SupersetTheme) => ({
padding: theme.gridUnit * 3,
textAlign: 'center',
...cardProps.style,
})}
{...cardProps}
>
<Tooltip title={buttonText}>
<Typography.Text ellipsis>{buttonText}</Typography.Text>
</Tooltip>
</Card>
);
};
const StyledInner = styled.div`
max-height: calc(1.5em * 2);
white-space: break-spaces;
export { IconButton };
&:first-of-type {
margin-right: 0;
}
.LinesEllipsis {
&:first-of-type {
margin-right: 0;
}
}
`;
const StyledBottom = styled.div`
padding: ${({ theme }) => theme.gridUnit * 4}px 0;
border-radius: 0 0 ${({ theme }) => theme.borderRadius}px
${({ theme }) => theme.borderRadius}px;
background-color: ${({ theme }) => theme.colors.grayscale.light4};
width: 100%;
line-height: 1.5em;
overflow: hidden;
white-space: no-wrap;
text-overflow: ellipsis;
&:first-of-type {
margin-right: 0;
}
`;
const IconButton = styled(
({ icon, altText, buttonText, ...props }: IconButtonProps) => (
<StyledButton {...props}>
<StyledImage>
{icon && <img src={icon} alt={altText} />}
{!icon && (
<Icons.DatabaseOutlined
className="default-db-icon"
aria-label="default-icon"
/>
)}
</StyledImage>
<StyledBottom>
<StyledInner>
<LinesEllipsis
text={buttonText}
maxLine="2"
basedOn="words"
trimRight
/>
</StyledInner>
</StyledBottom>
</StyledButton>
),
)`
text-transform: none;
background-color: ${({ theme }) => theme.colors.grayscale.light5};
font-weight: ${({ theme }) => theme.typography.weights.normal};
color: ${({ theme }) => theme.colors.grayscale.dark2};
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
margin: 0;
width: 100%;
&:hover,
&:focus {
background-color: ${({ theme }) => theme.colors.grayscale.light5};
color: ${({ theme }) => theme.colors.grayscale.dark2};
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
box-shadow: 4px 4px 20px ${({ theme }) => theme.colors.grayscale.light2};
}
`;
export default IconButton;
@@ -110,7 +110,6 @@ import {
FilterOutlined,
UnorderedListOutlined,
WarningOutlined,
KeyOutlined,
} from '@ant-design/icons';
import { FC } from 'react';
import { IconType } from './types';
@@ -210,7 +209,6 @@ const AntdIcons = {
FilterOutlined,
UnorderedListOutlined,
WarningOutlined,
KeyOutlined,
} as const;
type AntdIconNames = keyof typeof AntdIcons;
@@ -26,8 +26,6 @@ interface UiConfigType {
hideTab: boolean;
hideNav: boolean;
hideChartControls: boolean;
// Only used in superset-embedded-sdk to emit data masks to the parent window
emitDataMasks: boolean;
}
interface EmbeddedUiConfigProviderProps {
children: JSX.Element;
@@ -38,7 +36,6 @@ export const UiConfigContext = createContext<UiConfigType>({
hideTab: false,
hideNav: false,
hideChartControls: false,
emitDataMasks: false,
});
export const useUiConfig = () => useContext(UiConfigContext);
@@ -52,7 +49,6 @@ export const EmbeddedUiConfigProvider: FC<EmbeddedUiConfigProviderProps> = ({
hideTab: (config & 2) !== 0,
hideNav: (config & 4) !== 0,
hideChartControls: (config & 8) !== 0,
emitDataMasks: (config & 16) !== 0,
});
return (
@@ -197,10 +197,6 @@ beforeEach(() => {
jest.clearAllMocks();
});
beforeEach(() => {
window.history.pushState({}, 'Test page', '/dashboard?standalone=1');
});
test('should render', () => {
const { container } = setup();
expect(container).toBeInTheDocument();
@@ -442,36 +438,6 @@ test('should NOT render MetadataBar when embedded', () => {
).not.toBeInTheDocument();
});
test('should hide edit button and navbar, and show Exit fullscreen when in fullscreen mode', () => {
const fullscreenState = {
...initialState,
dashboardState: {
...initialState.dashboardState,
isFullscreenMode: true,
},
};
setup(fullscreenState);
expect(screen.queryByTestId('edit-dashboard-button')).not.toBeInTheDocument();
expect(screen.getByTestId('actions-trigger')).toBeInTheDocument();
expect(screen.queryByTestId('main-navigation')).not.toBeInTheDocument();
});
test('should show Exit fullscreen when in fullscreen mode', async () => {
setup();
fireEvent.click(screen.getByTestId('actions-trigger'));
expect(await screen.findByText('Exit fullscreen')).toBeInTheDocument();
});
test('should have fullscreen option in dropdown', async () => {
setup();
await openActionsDropdown();
expect(screen.getByText('Exit fullscreen')).toBeInTheDocument();
expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
});
test('should render MetadataBar when not in edit mode and not embedded', () => {
const state = {
dashboardInfo: {
@@ -97,13 +97,11 @@ export const useHeaderActionsMenu = ({
showPropertiesModal();
break;
case MenuKeys.ToggleFullscreen: {
const isCurrentlyStandalone =
Number(getUrlParam(URL_PARAMS.standalone)) === 1;
const url = getDashboardUrl({
pathname: window.location.pathname,
filters: getActiveFilters(),
hash: window.location.hash,
standalone: isCurrentlyStandalone ? null : 1,
standalone: getUrlParam(URL_PARAMS.standalone),
});
window.location.replace(url);
break;
@@ -379,13 +379,16 @@ const Chart = props => {
],
);
const exportCSV = useCallback(() => {
exportTable('csv', false);
}, [exportTable]);
const exportCSV = useCallback(
(isFullCSV = false) => {
exportTable('csv', isFullCSV);
},
[exportTable],
);
const exportFullCSV = useCallback(() => {
exportTable('csv', true);
}, [exportTable]);
exportCSV(true);
}, [exportCSV]);
const exportPivotCSV = useCallback(() => {
exportTable('csv', false, true);
@@ -96,7 +96,7 @@ const ChartHolder = ({
const theme = useTheme();
const fullSizeStyle = css`
&& {
position: fixed !important;
position: fixed;
z-index: 3000;
left: 0;
top: 0;
-5
View File
@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DataMaskStateWithId } from '@superset-ui/core';
import getBootstrapData from 'src/utils/getBootstrapData';
import { store } from '../views/store';
import { getDashboardPermalink as getDashboardPermalinkUtil } from '../utils/urlUtils';
@@ -32,7 +31,6 @@ type EmbeddedSupersetApi = {
getScrollSize: () => Size;
getDashboardPermalink: ({ anchor }: { anchor: string }) => Promise<string>;
getActiveTabs: () => string[];
getDataMask: () => DataMaskStateWithId;
};
const getScrollSize = (): Size => ({
@@ -63,11 +61,8 @@ const getDashboardPermalink = async ({
const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || [];
const getDataMask = () => store?.getState()?.dataMask || {};
export const embeddedApi: EmbeddedSupersetApi = {
getScrollSize,
getDashboardPermalink,
getActiveTabs,
getDataMask,
};
+1 -31
View File
@@ -24,7 +24,6 @@ import Switchboard from '@superset-ui/switchboard';
import getBootstrapData from 'src/utils/getBootstrapData';
import setupClient from 'src/setup/setupClient';
import setupPlugins from 'src/setup/setupPlugins';
import { useUiConfig } from 'src/components/UiConfigContext';
import { RootContextProviders } from 'src/views/RootContextProviders';
import { store, USER_LOADED } from 'src/views/store';
import ErrorBoundary from 'src/components/ErrorBoundary';
@@ -33,7 +32,6 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
setupPlugins();
@@ -53,38 +51,11 @@ const LazyDashboardPage = lazy(
),
);
const EmbededLazyDashboardPage = () => {
const uiConfig = useUiConfig();
// Emit data mask changes to the parent window
if (uiConfig?.emitDataMasks) {
log('setting up Switchboard event emitter');
let previousDataMask = store.getState().dataMask;
store.subscribe(() => {
const currentState = store.getState();
const currentDataMask = currentState.dataMask;
// Only emit if the dataMask has changed
if (previousDataMask !== currentDataMask) {
Switchboard.emit('observeDataMask', {
...currentDataMask,
...getDataMaskChangeTrigger(currentDataMask, previousDataMask),
});
previousDataMask = currentDataMask;
}
});
}
return <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />;
};
const EmbeddedRoute = () => (
<Suspense fallback={<Loading />}>
<RootContextProviders>
<ErrorBoundary>
<EmbededLazyDashboardPage />
<LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />
</ErrorBoundary>
<ToastContainer position="top" />
</RootContextProviders>
@@ -238,7 +209,6 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
embeddedApi.getDashboardPermalink,
);
Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs);
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
Switchboard.start();
}
});
@@ -1,76 +0,0 @@
/**
* 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 { DataMaskStateWithId } from '@superset-ui/core';
import { cloneDeep } from 'lodash';
import { getDataMaskChangeTrigger } from './utils';
const dataMask: DataMaskStateWithId = {
'1': {
id: '1',
extraFormData: {},
filterState: {},
ownState: {},
},
'2': {
id: '2',
extraFormData: {},
filterState: {},
ownState: {},
},
'NATIVE_FILTER-1': {
id: 'NATIVE_FILTER-1',
extraFormData: {},
filterState: {
value: null,
},
ownState: {},
},
'NATIVE_FILTER-2': {
id: 'NATIVE_FILTER-2',
extraFormData: {},
filterState: {},
ownState: {},
},
};
it('datamask didnt change - both triggers set to false', () => {
const previousDataMask = cloneDeep(dataMask);
expect(getDataMaskChangeTrigger(dataMask, previousDataMask)).toEqual({
crossFiltersChanged: false,
nativeFiltersChanged: false,
});
});
it('a native filter changed - nativeFiltersChanged set to true', () => {
const previousDataMask = cloneDeep(dataMask);
previousDataMask['NATIVE_FILTER-1'].filterState!.value = 'test';
expect(getDataMaskChangeTrigger(dataMask, previousDataMask)).toEqual({
crossFiltersChanged: false,
nativeFiltersChanged: true,
});
});
it('a cross filter changed - crossFiltersChanged set to true', () => {
const previousDataMask = cloneDeep(dataMask);
previousDataMask['1'].filterState!.value = 'test';
expect(getDataMaskChangeTrigger(dataMask, previousDataMask)).toEqual({
crossFiltersChanged: true,
nativeFiltersChanged: false,
});
});
-46
View File
@@ -1,46 +0,0 @@
/**
* 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 { DataMaskStateWithId } from '@superset-ui/core';
import { isEmpty, isEqual } from 'lodash';
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
export const getDataMaskChangeTrigger = (
dataMask: DataMaskStateWithId,
previousDataMask: DataMaskStateWithId,
) => {
let crossFiltersChanged = false;
let nativeFiltersChanged = false;
if (!isEmpty(dataMask) && !isEmpty(previousDataMask)) {
for (const key in dataMask) {
if (
key.startsWith(NATIVE_FILTER_PREFIX) &&
!isEqual(dataMask[key], previousDataMask[key])
) {
nativeFiltersChanged = true;
break;
} else if (!isEqual(dataMask[key], previousDataMask[key])) {
crossFiltersChanged = true;
break;
}
}
}
return { crossFiltersChanged, nativeFiltersChanged };
};
@@ -45,7 +45,7 @@ import { AntdSelect, Upload } from 'src/components';
import Alert from 'src/components/Alert';
import Modal from 'src/components/Modal';
import Button from 'src/components/Button';
import { IconButton } from 'src/components/IconButton';
import IconButton from 'src/components/IconButton';
import InfoTooltip from 'src/components/InfoTooltip';
import withToasts from 'src/components/MessageToasts/withToasts';
import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';
-1
View File
@@ -1564,7 +1564,6 @@ class ImportV1ChartSchema(Schema):
dataset_uuid = fields.UUID(required=True)
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
external_url = fields.String(allow_none=True)
tags = fields.List(fields.String(), allow_none=True)
class ChartCacheWarmUpRequestSchema(Schema):
-26
View File
@@ -26,13 +26,10 @@ from superset.commands.chart.exceptions import ChartNotFoundError
from superset.daos.chart import ChartDAO
from superset.commands.dataset.export import ExportDatasetsCommand
from superset.commands.export.models import ExportModelsCommand
from superset.commands.tag.export import ExportTagsCommand
from superset.models.slice import Slice
from superset.tags.models import TagType
from superset.utils.dict_import_export import EXPORT_VERSION
from superset.utils.file import get_filename
from superset.utils import json
from superset.extensions import feature_flag_manager
logger = logging.getLogger(__name__)
@@ -74,23 +71,9 @@ class ExportChartsCommand(ExportModelsCommand):
if model.table:
payload["dataset_uuid"] = str(model.table.uuid)
# Fetch tags from the database if TAGGING_SYSTEM is enabled
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
tags = getattr(model, "tags", [])
payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
file_content = yaml.safe_dump(payload, sort_keys=False)
return file_content
_include_tags: bool = True # Default to True
@classmethod
def disable_tag_export(cls) -> None:
cls._include_tags = False
@classmethod
def enable_tag_export(cls) -> None:
cls._include_tags = True
@staticmethod
def _export(
model: Slice, export_related: bool = True
@@ -102,12 +85,3 @@ class ExportChartsCommand(ExportModelsCommand):
if model.table and export_related:
yield from ExportDatasetsCommand([model.table.id]).run()
# Check if the calling class is ExportDashboardCommands
if (
export_related
and ExportChartsCommand._include_tags
and feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM")
):
chart_id = model.id
yield from ExportTagsCommand().export(chart_ids=[chart_id])
@@ -14,27 +14,23 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from typing import Any
from marshmallow import Schema
from sqlalchemy.orm import Session # noqa: F401
from superset import db
from superset.charts.schemas import ImportV1ChartSchema
from superset.commands.chart.exceptions import ChartImportError
from superset.commands.chart.importers.v1.utils import import_chart
from superset.commands.database.importers.v1.utils import import_database
from superset.commands.dataset.importers.v1.utils import import_dataset
from superset.commands.importers.v1 import ImportModelsCommand
from superset.commands.importers.v1.utils import import_tag
from superset.commands.utils import update_chart_config_dataset
from superset.connectors.sqla.models import SqlaTable
from superset.daos.chart import ChartDAO
from superset.databases.schemas import ImportV1DatabaseSchema
from superset.datasets.schemas import ImportV1DatasetSchema
from superset.extensions import feature_flag_manager
class ImportChartsCommand(ImportModelsCommand):
@@ -51,13 +47,7 @@ class ImportChartsCommand(ImportModelsCommand):
import_error = ChartImportError
@staticmethod
# ruff: noqa: C901
def _import(
configs: dict[str, Any],
overwrite: bool = False,
contents: dict[str, Any] | None = None,
) -> None:
contents = {} if contents is None else contents
def _import(configs: dict[str, Any], overwrite: bool = False) -> None: # noqa: C901
# discover datasets associated with charts
dataset_uuids: set[str] = set()
for file_name, config in configs.items():
@@ -103,12 +93,4 @@ class ImportChartsCommand(ImportModelsCommand):
"datasource_name": dataset.table_name,
}
config = update_chart_config_dataset(config, dataset_dict)
chart = import_chart(config, overwrite=overwrite)
# Handle tags using import_tag function
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
if "tags" in config:
target_tag_names = config["tags"]
import_tag(
target_tag_names, contents, chart.id, "chart", db.session
)
import_chart(config, overwrite=overwrite)
+1 -19
View File
@@ -25,7 +25,6 @@ from collections.abc import Iterator
import yaml
from superset.commands.chart.export import ExportChartsCommand
from superset.commands.tag.export import ExportTagsCommand
from superset.commands.dashboard.exceptions import DashboardNotFoundError
from superset.commands.dashboard.importers.v1.utils import find_chart_uuids
from superset.daos.dashboard import DashboardDAO
@@ -34,11 +33,9 @@ from superset.commands.dataset.export import ExportDatasetsCommand
from superset.daos.dataset import DatasetDAO
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import TagType
from superset.utils.dict_import_export import EXPORT_VERSION
from superset.utils.file import get_filename
from superset.utils import json
from superset.extensions import feature_flag_manager # Import the feature flag manager
logger = logging.getLogger(__name__)
@@ -115,7 +112,6 @@ class ExportDashboardsCommand(ExportModelsCommand):
return f"dashboards/{file_name}.yaml"
@staticmethod
# ruff: noqa: C901
def _file_content(model: Dashboard) -> str:
payload = model.export_to_dict(
recursive=False,
@@ -163,16 +159,10 @@ class ExportDashboardsCommand(ExportModelsCommand):
payload["version"] = EXPORT_VERSION
# Check if the TAGGING_SYSTEM feature is enabled
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
tags = model.tags if hasattr(model, "tags") else []
payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
file_content = yaml.safe_dump(payload, sort_keys=False)
return file_content
@staticmethod
# ruff: noqa: C901
def _export(
model: Dashboard, export_related: bool = True
) -> Iterator[tuple[str, Callable[[], str]]]:
@@ -183,15 +173,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
if export_related:
chart_ids = [chart.id for chart in model.slices]
dashboard_ids = model.id
command = ExportChartsCommand(chart_ids)
command.disable_tag_export()
yield from command.run()
command.enable_tag_export()
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
yield from ExportTagsCommand.export(
dashboard_ids=dashboard_ids, chart_ids=chart_ids
)
yield from ExportChartsCommand(chart_ids).run()
payload = model.export_to_dict(
recursive=False,
@@ -15,8 +15,6 @@
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from typing import Any
from marshmallow import Schema
@@ -36,13 +34,11 @@ from superset.commands.dashboard.importers.v1.utils import (
from superset.commands.database.importers.v1.utils import import_database
from superset.commands.dataset.importers.v1.utils import import_dataset
from superset.commands.importers.v1 import ImportModelsCommand
from superset.commands.importers.v1.utils import import_tag
from superset.commands.utils import update_chart_config_dataset
from superset.daos.dashboard import DashboardDAO
from superset.dashboards.schemas import ImportV1DashboardSchema
from superset.databases.schemas import ImportV1DatabaseSchema
from superset.datasets.schemas import ImportV1DatasetSchema
from superset.extensions import feature_flag_manager
from superset.migrations.shared.native_filters import migrate_dashboard
from superset.models.dashboard import Dashboard, dashboard_slices
@@ -62,15 +58,9 @@ class ImportDashboardsCommand(ImportModelsCommand):
import_error = DashboardImportError
# TODO (betodealmeida): refactor to use code from other commands
# pylint: disable=too-many-branches, too-many-locals, too-many-statements
# pylint: disable=too-many-branches, too-many-locals
@staticmethod
# ruff: noqa: C901
def _import(
configs: dict[str, Any],
overwrite: bool = False,
contents: dict[str, Any] | None = None,
) -> None:
contents = {} if contents is None else contents
def _import(configs: dict[str, Any], overwrite: bool = False) -> None: # noqa: C901
# discover charts and datasets associated with dashboards
chart_uuids: set[str] = set()
dataset_uuids: set[str] = set()
@@ -130,14 +120,6 @@ class ImportDashboardsCommand(ImportModelsCommand):
charts.append(chart)
chart_ids[str(chart.uuid)] = chart.id
# Handle tags using import_tag function
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
if "tags" in config:
target_tag_names = config["tags"]
import_tag(
target_tag_names, contents, chart.id, "chart", db.session
)
# store the existing relationship between dashboards and charts
existing_relationships = db.session.execute(
select([dashboard_slices.c.dashboard_id, dashboard_slices.c.slice_id])
@@ -158,18 +140,6 @@ class ImportDashboardsCommand(ImportModelsCommand):
if (dashboard.id, chart_id) not in existing_relationships:
dashboard_chart_ids.append((dashboard.id, chart_id))
# Handle tags using import_tag function
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
if "tags" in config:
target_tag_names = config["tags"]
import_tag(
target_tag_names,
contents,
dashboard.id,
"dashboard",
db.session,
)
# set ref in the dashboard_slices table
values = [
{"dashboard_id": dashboard_id, "slice_id": chart_id}
@@ -15,8 +15,6 @@
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from typing import Any
from marshmallow import Schema
@@ -44,11 +42,7 @@ class ImportDatabasesCommand(ImportModelsCommand):
import_error = DatabaseImportError
@staticmethod
def _import(
configs: dict[str, Any],
overwrite: bool = False,
contents: dict[str, Any] | None = None,
) -> None:
def _import(configs: dict[str, Any], overwrite: bool = False) -> None:
# first import databases
database_ids: dict[str, int] = {}
for file_name, config in configs.items():
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from typing import Any, Optional
from typing import Any
from marshmallow import Schema
from sqlalchemy.orm import Session # noqa: F401
@@ -42,13 +42,7 @@ class ImportDatasetsCommand(ImportModelsCommand):
import_error = DatasetImportError
@staticmethod
def _import(
configs: dict[str, Any],
overwrite: bool = False,
contents: Optional[dict[str, Any]] = None,
) -> None:
if contents is None:
contents = {}
def _import(configs: dict[str, Any], overwrite: bool = False) -> None:
# discover databases associated with datasets
database_uuids: set[str] = set()
for file_name, config in configs.items():
-1
View File
@@ -53,7 +53,6 @@ class ExportAssetsCommand(BaseCommand):
ExportDashboardsCommand,
ExportSavedQueriesCommand,
]
for command in commands:
ids = [model.id for model in command.dao.find_all()]
for file_name, file_content in command(ids, export_related=False).run():
+4 -12
View File
@@ -14,11 +14,8 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import logging
from typing import Any
from typing import Any, Optional
from marshmallow import Schema, validate # noqa: F401
from marshmallow.exceptions import ValidationError
@@ -67,12 +64,7 @@ class ImportModelsCommand(BaseCommand):
self._configs: dict[str, Any] = {}
@staticmethod
# ruff: noqa: C901
def _import(
configs: dict[str, Any],
overwrite: bool = False,
contents: dict[str, Any] | None = None,
) -> None:
def _import(configs: dict[str, Any], overwrite: bool = False) -> None:
raise NotImplementedError("Subclasses MUST implement _import")
@classmethod
@@ -84,7 +76,7 @@ class ImportModelsCommand(BaseCommand):
self.validate()
try:
self._import(self._configs, self.overwrite, self.contents)
self._import(self._configs, self.overwrite)
except CommandException:
raise
except Exception as ex:
@@ -95,7 +87,7 @@ class ImportModelsCommand(BaseCommand):
# verify that the metadata file is present and valid
try:
metadata: dict[str, str] | None = load_metadata(self.contents)
metadata: Optional[dict[str, str]] = load_metadata(self.contents)
except ValidationError as exc:
exceptions.append(exc)
metadata = None
+1 -2
View File
@@ -14,7 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any, Optional
from typing import Any
from marshmallow import Schema
from sqlalchemy.exc import MultipleResultsFound
@@ -90,7 +90,6 @@ class ImportExamplesCommand(ImportModelsCommand):
def _import( # pylint: disable=too-many-locals, too-many-branches # noqa: C901
configs: dict[str, Any],
overwrite: bool = False,
contents: Optional[dict[str, Any]] = None,
force_data: bool = False,
) -> None:
# import databases
+1 -92
View File
@@ -21,17 +21,12 @@ from zipfile import ZipFile
import yaml
from marshmallow import fields, Schema, validate
from marshmallow.exceptions import ValidationError
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from superset import db
from superset.commands.importers.exceptions import IncorrectVersionError
from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.extensions import feature_flag_manager
from superset.models.core import Database
from superset.tags.models import Tag, TaggedObject
from superset.utils.core import check_is_safe_zip
from superset.utils.decorators import transaction
METADATA_FILE_NAME = "metadata.yaml"
IMPORT_VERSION = "1.0.0"
@@ -101,8 +96,7 @@ def validate_metadata_type(
# pylint: disable=too-many-locals,too-many-arguments
# ruff: noqa: C901
def load_configs(
def load_configs( # noqa: C901
contents: dict[str, str],
schemas: dict[str, Schema],
passwords: dict[str, str],
@@ -222,91 +216,6 @@ def get_contents_from_bundle(bundle: ZipFile) -> dict[str, str]:
}
# pylint: disable=consider-using-transaction
# ruff: noqa: C901
@transaction()
def import_tag(
target_tag_names: list[str],
contents: dict[str, Any],
object_id: int,
object_type: str,
db_session: Session,
) -> list[int]:
"""Handles the import logic for tags for charts and dashboards"""
if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
return []
tag_descriptions = {}
new_tag_ids = []
if "tags.yaml" in contents:
try:
tags_config = yaml.safe_load(contents["tags.yaml"])
except yaml.YAMLError as err:
logger.error("Error parsing tags.yaml: %s", err)
tags_config = {}
for tag_info in tags_config.get("tags", []):
tag_name = tag_info.get("tag_name")
description = tag_info.get("description", None)
if tag_name:
tag_descriptions[tag_name] = description
existing_assocs = (
db_session.query(TaggedObject)
.filter_by(object_id=object_id, object_type=object_type)
.all()
)
existing_tags = {
tag.name: tag
for tag in db_session.query(Tag).filter(Tag.name.in_(target_tag_names))
}
for tag_name in target_tag_names:
try:
tag = existing_tags.get(tag_name)
# If tag does not exist, create it
if tag is None:
description = tag_descriptions.get(tag_name, None)
tag = Tag(name=tag_name, description=description, type="custom")
db_session.add(tag)
existing_tags[tag_name] = tag # Update the existing_tags dictionary
# Ensure the association with the object
tagged_object = (
db_session.query(TaggedObject)
.filter_by(object_id=object_id, object_type=object_type, tag_id=tag.id)
.first()
)
if not tagged_object:
new_tagged_object = TaggedObject(
tag_id=tag.id, object_id=object_id, object_type=object_type
)
db_session.add(new_tagged_object)
new_tag_ids.append(tag.id)
except SQLAlchemyError as err:
logger.error(
"Error processing tag '%s' for %s ID %d: %s",
tag_name,
object_type,
object_id,
err,
)
continue # No need for manual rollback, handled by transaction decorator
# Remove old tags not in the new config
for tag in existing_assocs:
if tag.tag_id not in new_tag_ids:
db_session.delete(tag)
return new_tag_ids
def get_resource_mappings_batched(
model_class: Type[Any],
batch_size: int = 1000,
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from typing import Any, Optional
from typing import Any
from marshmallow import Schema
from sqlalchemy.orm import Session # noqa: F401
@@ -43,11 +43,7 @@ class ImportSavedQueriesCommand(ImportModelsCommand):
import_error = SavedQueryImportError
@staticmethod
def _import(
configs: dict[str, Any],
overwrite: bool = False,
contents: Optional[dict[str, Any]] = None,
) -> None:
def _import(configs: dict[str, Any], overwrite: bool = False) -> None:
# discover databases associated with saved queries
database_uuids: set[str] = set()
for file_name, config in configs.items():
-131
View File
@@ -1,131 +0,0 @@
# 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.
# isort:skip_file
from typing import Any, Callable, List, Optional, Union
from collections.abc import Iterator
import yaml
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.extensions import feature_flag_manager
from superset.tags.models import TagType
from superset.commands.tag.exceptions import TagNotFoundError
# pylint: disable=too-few-public-methods
class ExportTagsCommand:
not_found = TagNotFoundError
@staticmethod
def _file_name() -> str:
# Use the model to determine the filename
return "tags.yaml"
@staticmethod
def _merge_tags(
dashboard_tags: List[dict[str, Any]], chart_tags: List[dict[str, Any]]
) -> List[dict[str, Any]]:
# Create a dictionary to prevent duplicates based on tag name
tags_dict = {tag["tag_name"]: tag for tag in dashboard_tags}
# Add chart tags, preserving unique tag names
for tag in chart_tags:
if tag["tag_name"] not in tags_dict:
tags_dict[tag["tag_name"]] = tag
# Return merged tags as a list
return list(tags_dict.values())
@staticmethod
def _file_content(
dashboard_ids: Optional[Union[int, List[Union[int, str]]]] = None,
chart_ids: Optional[Union[int, List[Union[int, str]]]] = None,
) -> str:
payload: dict[str, list[dict[str, Any]]] = {"tags": []}
dashboard_tags = []
chart_tags = []
# Fetch dashboard tags if provided
if dashboard_ids:
# Ensure dashboard_ids is a list
if isinstance(dashboard_ids, int):
dashboard_ids = [
dashboard_ids
] # Convert single int to list for consistency
dashboards = [
dashboard
for dashboard in (
DashboardDAO.find_by_id(dashboard_id)
for dashboard_id in dashboard_ids
)
if dashboard is not None
]
for dashboard in dashboards:
tags = dashboard.tags if hasattr(dashboard, "tags") else []
filtered_tags = [
{"tag_name": tag.name, "description": tag.description}
for tag in tags
if tag.type == TagType.custom
]
dashboard_tags.extend(filtered_tags)
# Fetch chart tags if provided
if chart_ids:
# Ensure chart_ids is a list
if isinstance(chart_ids, int):
chart_ids = [chart_ids] # Convert single int to list for consistency
charts = [
chart
for chart in (ChartDAO.find_by_id(chart_id) for chart_id in chart_ids)
if chart is not None
]
for chart in charts:
tags = chart.tags if hasattr(chart, "tags") else []
filtered_tags = [
{"tag_name": tag.name, "description": tag.description}
for tag in tags
if "type:" not in tag.name and "owner:" not in tag.name
]
chart_tags.extend(filtered_tags)
# Merge the tags from both dashboards and charts
merged_tags = ExportTagsCommand._merge_tags(dashboard_tags, chart_tags)
payload["tags"].extend(merged_tags)
# Convert to YAML format
file_content = yaml.safe_dump(payload, sort_keys=False)
return file_content
@staticmethod
def export(
dashboard_ids: Optional[Union[int, List[Union[int, str]]]] = None,
chart_ids: Optional[Union[int, List[Union[int, str]]]] = None,
) -> Iterator[tuple[str, Callable[[], str]]]:
if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
return
yield (
ExportTagsCommand._file_name(),
lambda: ExportTagsCommand._file_content(dashboard_ids, chart_ids),
)
-1
View File
@@ -484,7 +484,6 @@ class ImportV1DashboardSchema(Schema):
certified_by = fields.String(allow_none=True)
certification_details = fields.String(allow_none=True)
published = fields.Boolean(allow_none=True)
tags = fields.List(fields.String(), allow_none=True)
class EmbeddedDashboardConfigSchema(Schema):
+2 -2
View File
@@ -353,7 +353,7 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec):
engine = "databricks"
engine_name = "Databricks (legacy)"
engine_name = "Databricks"
drivers = {"connector": "Native all-purpose driver"}
default_driver = "connector"
@@ -485,7 +485,7 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec):
class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec):
engine = "databricks"
engine_name = "Databricks"
engine_name = "Databricks Python Connector"
default_driver = "databricks-sql-python"
drivers = {"databricks-sql-python": "Databricks SQL Python"}
@@ -236,7 +236,6 @@ class MigrateDistBarChart(TimeseriesChart):
self.remove_keys.add("bar_stacked")
self.data["stack"] = "Stack" if self.data.get("bar_stacked") else None
self.data["x_ticks_layout"] = 45
class MigrateBubbleChart(MigrateViz):
@@ -18,10 +18,8 @@
import copy
from collections.abc import Generator
from unittest.mock import patch
import pytest
import yaml
from flask_appbuilder.security.sqla.models import Role, User
from pytest_mock import MockerFixture
from sqlalchemy.orm.session import Session
@@ -29,11 +27,8 @@ from sqlalchemy.orm.session import Session
from superset import security_manager
from superset.commands.chart.importers.v1.utils import import_chart
from superset.commands.exceptions import ImportFailedError
from superset.commands.importers.v1.utils import import_tag
from superset.connectors.sqla.models import Database, SqlaTable
from superset.extensions import feature_flag_manager
from superset.models.slice import Slice
from superset.tags.models import TaggedObject
from superset.utils.core import override_user
from tests.integration_tests.fixtures.importexport import chart_config
@@ -285,43 +280,3 @@ def test_import_existing_chart_with_permission(
# Assert that the can write to chart was checked
mock_can_access.assert_called_once_with("can_write", "Chart")
mock_can_access_chart.assert_called_once_with(slice)
def test_import_tag_logic_for_charts(session_with_schema: Session):
contents = {
"tags.yaml": yaml.dump(
{"tags": [{"tag_name": "tag_1", "description": "Description for tag_1"}]}
)
}
object_id = 1
object_type = "chart"
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
new_tag_ids = import_tag(
["tag_1"], contents, object_id, object_type, session_with_schema
)
assert len(new_tag_ids) > 0
assert (
session_with_schema.query(TaggedObject)
.filter_by(object_id=object_id, object_type=object_type)
.count()
> 0
)
session_with_schema.query(TaggedObject).filter_by(
object_id=object_id, object_type=object_type
).delete()
session_with_schema.commit()
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=False):
new_tag_ids_disabled = import_tag(
["tag_1"], contents, object_id, object_type, session_with_schema
)
assert len(new_tag_ids_disabled) == 0
associated_tags = (
session_with_schema.query(TaggedObject)
.filter_by(object_id=object_id, object_type=object_type)
.all()
)
assert len(associated_tags) == 0
+1 -64
View File
@@ -16,15 +16,9 @@
# under the License.
# pylint: disable=invalid-name, unused-argument, import-outside-toplevel
from unittest.mock import patch
import pytest
import yaml
from freezegun import freeze_time
from pytest_mock import MockerFixture
from superset.extensions import feature_flag_manager
def test_export_assets_command(mocker: MockerFixture) -> None:
"""
@@ -86,6 +80,7 @@ def test_export_assets_command(mocker: MockerFixture) -> None:
with freeze_time("2022-01-01T00:00:00Z"):
command = ExportAssetsCommand()
output = [(file[0], file[1]()) for file in list(command.run())]
assert output == [
(
"metadata.yaml",
@@ -97,61 +92,3 @@ def test_export_assets_command(mocker: MockerFixture) -> None:
("dashboards/sales.yaml", "<DASHBOARD CONTENTS>"),
("queries/example/metric.yaml", "<SAVED QUERY CONTENTS>"),
]
@pytest.fixture
def mock_export_tags_command_charts_dashboards(mocker):
export_tags = mocker.patch("superset.commands.tag.export.ExportTagsCommand")
def _mock_export(dashboard_ids=None, chart_ids=None):
if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
return iter([])
return [
(
"tags.yaml",
lambda: yaml.dump(
{
"tags": [
{
"tag_name": "tag_1",
"description": "Description for tag_1",
}
]
},
sort_keys=False,
),
),
("charts/pie.yaml", lambda: "tag:\n- tag_1"),
]
export_tags.return_value._export.side_effect = _mock_export
return export_tags
def test_export_tags_with_charts_dashboards(
mock_export_tags_command_charts_dashboards, mocker
):
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
command = mock_export_tags_command_charts_dashboards()
result = list(command._export(chart_ids=[1]))
file_name, file_content_func = result[0]
file_content = file_content_func()
assert file_name == "tags.yaml"
payload = yaml.safe_load(file_content)
assert payload["tags"] == [
{"tag_name": "tag_1", "description": "Description for tag_1"}
]
file_name, file_content_func = result[1]
file_content = file_content_func()
assert file_name == "charts/pie.yaml"
assert file_content == "tag:\n- tag_1"
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=False):
command = mock_export_tags_command_charts_dashboards()
result = list(command._export(chart_ids=[1]))
assert not any(file_name == "tags.yaml" for file_name, _ in result)
assert all(
file_content_func() != "tag:\n- tag_1" for _, file_content_func in result
)
@@ -18,10 +18,8 @@
import copy
from collections.abc import Generator
from unittest.mock import patch
import pytest
import yaml
from flask_appbuilder.security.sqla.models import Role, User
from pytest_mock import MockerFixture
from sqlalchemy.orm.session import Session
@@ -29,10 +27,7 @@ from sqlalchemy.orm.session import Session
from superset import security_manager
from superset.commands.dashboard.importers.v1.utils import import_dashboard
from superset.commands.exceptions import ImportFailedError
from superset.commands.importers.v1.utils import import_tag
from superset.extensions import feature_flag_manager
from superset.models.dashboard import Dashboard
from superset.tags.models import TaggedObject
from superset.utils.core import override_user
from tests.integration_tests.fixtures.importexport import dashboard_config
@@ -243,43 +238,3 @@ def test_import_existing_dashboard_with_permission(
# Assert that the can write to dashboard was checked
mock_can_access.assert_called_once_with("can_write", "Dashboard")
mock_can_access_dashboard.assert_called_once_with(dashboard)
def test_import_tag_logic_for_dashboards(session_with_schema: Session):
contents = {
"tags.yaml": yaml.dump(
{"tags": [{"tag_name": "tag_1", "description": "Description for tag_1"}]}
)
}
object_id = 1
object_type = "dashboards"
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
new_tag_ids = import_tag(
["tag_1"], contents, object_id, object_type, session_with_schema
)
assert len(new_tag_ids) > 0
assert (
session_with_schema.query(TaggedObject)
.filter_by(object_id=object_id, object_type=object_type)
.count()
> 0
)
session_with_schema.query(TaggedObject).filter_by(
object_id=object_id, object_type=object_type
).delete()
session_with_schema.commit()
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=False):
new_tag_ids_disabled = import_tag(
["tag_1"], contents, object_id, object_type, session_with_schema
)
assert len(new_tag_ids_disabled) == 0
associated_tags = (
session_with_schema.query(TaggedObject)
.filter_by(object_id=object_id, object_type=object_type)
.all()
)
assert len(associated_tags) == 0
@@ -187,7 +187,7 @@ def test_extract_errors() -> None:
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
level=ErrorLevel.ERROR,
extra={
"engine_name": "Databricks (legacy)",
"engine_name": "Databricks",
"issue_codes": [
{
"code": 1002,
@@ -214,7 +214,7 @@ def test_extract_errors_with_context() -> None:
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
level=ErrorLevel.ERROR,
extra={
"engine_name": "Databricks (legacy)",
"engine_name": "Databricks",
"issue_codes": [
{
"code": 1002,