diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts index 3944c8cc4f26599228f00c8fb391f16f03d55317..f0ae1cab42e9851620316bbe0ead55bc58da5a89 100644 --- a/superset-embedded-sdk/src/index.ts +++ b/superset-embedded-sdk/src/index.ts @@ -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,51 +34,61 @@ 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; + emitDataMasks?: 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[]>; - getDataMasks: (callbackFn: (dataMasks: any[]) => void) => void; + observeDataMask: ( + callbackFn: ObserveDataMaskCallbackFn, + ) => void; + getDataMask: () => Record<string, any>; }; /** @@ -91,7 +101,7 @@ export async function embedDashboard({ fetchGuestToken, dashboardUiConfig, debug = false, - iframeTitle = "Embedded Dashboard", + iframeTitle = 'Embedded Dashboard', iframeSandboxExtras = [], referrerPolicy, }: EmbedDashboardParams): Promise<EmbeddedDashboard> { @@ -103,55 +113,67 @@ 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.hideChartControls) { + configNumber += 8; } if (dashboardUiConfig.emitDataMasks) { - configNumber += 16 + configNumber += 16; } } - 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; } @@ -167,20 +189,26 @@ 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'); }); } @@ -210,17 +238,20 @@ export async function embedDashboard({ const getDashboardPermalink = (anchor: string) => ourPort.get<string>('getDashboardPermalink', { anchor }); const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs'); - const getDataMasks = (callbackFn: (dataMasks: any[]) => void) => { + const getDataMask = () => ourPort.get<Record<string, any>>('getDataMask'); + const observeDataMask = ( + callbackFn: ObserveDataMaskCallbackFn, + ) => { ourPort.start(); - ourPort.defineMethod("getDataMasks", callbackFn); + ourPort.defineMethod('observeDataMask', callbackFn); }; - return { getScrollSize, unmount, getDashboardPermalink, getActiveTabs, - getDataMasks, + observeDataMask, + getDataMask, }; } diff --git a/superset-frontend/src/embedded/api.tsx b/superset-frontend/src/embedded/api.tsx index 9d37daf2e01bc92d6f43b3e5bfdf1aeefe88201b..2665916758b0c2a1cff0ee1dddcd927442a245a7 100644 --- a/superset-frontend/src/embedded/api.tsx +++ b/superset-frontend/src/embedded/api.tsx @@ -16,6 +16,7 @@ * 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'; @@ -31,6 +32,7 @@ type EmbeddedSupersetApi = { getScrollSize: () => Size; getDashboardPermalink: ({ anchor }: { anchor: string }) => Promise<string>; getActiveTabs: () => string[]; + getDataMask: () => DataMaskStateWithId; }; const getScrollSize = (): Size => ({ @@ -61,8 +63,11 @@ const getDashboardPermalink = async ({ const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || []; +const getDataMask = () => store?.getState()?.dataMask || {}; + export const embeddedApi: EmbeddedSupersetApi = { getScrollSize, getDashboardPermalink, getActiveTabs, + getDataMask, }; diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index 5331c20421c902e8aa9017abc74ad2b7e28d5149..4263de96092d906daa829cbae9f8f16b67df504f 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -33,6 +33,7 @@ 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(); @@ -59,9 +60,20 @@ const EmbededLazyDashboardPage = () => { if (uiConfig?.emitDataMasks) { log('setting up Switchboard event emitter'); + let previousDataMask = store.getState().dataMask; + store.subscribe(() => { - const state = store.getState(); - Switchboard.emit('getDataMasks', state.dataMask); + 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; + } }); } @@ -226,6 +238,7 @@ window.addEventListener('message', function embeddedPageInitializer(event) { embeddedApi.getDashboardPermalink, ); Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs); + Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask); Switchboard.start(); } }); diff --git a/superset-frontend/src/embedded/utils.test.ts b/superset-frontend/src/embedded/utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..06ce75afa0f1f4d3970c57db0914162729bdac51 --- /dev/null +++ b/superset-frontend/src/embedded/utils.test.ts @@ -0,0 +1,76 @@ +/** + * 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, + }); +}); diff --git a/superset-frontend/src/embedded/utils.ts b/superset-frontend/src/embedded/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..8645dd7f08a3650741684a1e05d4ad4a51dbdbe2 --- /dev/null +++ b/superset-frontend/src/embedded/utils.ts @@ -0,0 +1,46 @@ +/** + * 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 }; +};