mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c2e9f524c | ||
|
|
abac881f1a | ||
|
|
cfbda8162f |
@@ -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
|
||||
|
||||
@@ -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. We’ll 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! You’ve 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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
-27
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user