mirror of
https://github.com/apache/superset.git
synced 2026-05-11 19:05:24 +00:00
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
/*
|
|
* 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 {
|
|
DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY,
|
|
IFRAME_COMMS_MESSAGE_TYPE,
|
|
} from './const';
|
|
|
|
// We can swap this out for the actual switchboard package once it gets published
|
|
import { Switchboard } from '@superset-ui/switchboard';
|
|
import { getGuestTokenRefreshTiming } from './guestTokenRefresh';
|
|
|
|
/**
|
|
* The function to fetch a guest token from your Host App's backend server.
|
|
* The Host App backend must supply an API endpoint
|
|
* which returns a guest token with appropriate resource access.
|
|
*/
|
|
export type GuestTokenFetchFn = () => Promise<string>;
|
|
|
|
export type UiConfigType = {
|
|
hideTitle?: boolean;
|
|
hideTab?: boolean;
|
|
hideChartControls?: boolean;
|
|
emitDataMasks?: boolean;
|
|
filters?: {
|
|
[key: string]: boolean | undefined;
|
|
visible?: boolean;
|
|
expanded?: boolean;
|
|
};
|
|
urlParams?: {
|
|
[key: string]: any;
|
|
};
|
|
showRowLimitWarning?: boolean;
|
|
};
|
|
|
|
export type EmbedDashboardParams = {
|
|
/** The id provided by the embed configuration UI in Superset */
|
|
id: string;
|
|
/** The domain where Superset can be located, with protocol, such as: https://superset.example.com */
|
|
supersetDomain: string;
|
|
/** The html element within which to mount the iframe */
|
|
mountPoint: HTMLElement;
|
|
/** A function to fetch a guest token from the Host App's backend server */
|
|
fetchGuestToken: GuestTokenFetchFn;
|
|
/** The dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded **/
|
|
dashboardUiConfig?: UiConfigType;
|
|
/** Are we in debug mode? */
|
|
debug?: boolean;
|
|
/** The iframe title attribute */
|
|
iframeTitle?: string;
|
|
/** additional iframe sandbox attributes ex (allow-top-navigation, allow-popups-to-escape-sandbox) **/
|
|
iframeSandboxExtras?: string[];
|
|
/** iframe allow attribute for Permissions Policy (e.g., ['clipboard-write', 'fullscreen']) **/
|
|
iframeAllowExtras?: string[];
|
|
/** force a specific refererPolicy to be used in the iframe request **/
|
|
referrerPolicy?: ReferrerPolicy;
|
|
/** Callback to resolve permalink URLs. If provided, this will be called when generating permalinks
|
|
* to allow the host app to customize the URL. If not provided, Superset's default URL is used. */
|
|
resolvePermalinkUrl?: ResolvePermalinkUrlFn;
|
|
};
|
|
|
|
export type Size = {
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export type ObserveDataMaskCallbackFn = (
|
|
dataMask: Record<string, any> & {
|
|
crossFiltersChanged: boolean;
|
|
nativeFiltersChanged: boolean;
|
|
},
|
|
) => void;
|
|
export type ThemeMode = 'default' | 'dark' | 'system';
|
|
|
|
/**
|
|
* Callback to resolve permalink URLs.
|
|
* Receives the permalink key and returns the full URL to use for the permalink.
|
|
*/
|
|
export type ResolvePermalinkUrlFn = (params: {
|
|
/** The permalink key (e.g., "xyz789") */
|
|
key: string;
|
|
}) => string | Promise<string>;
|
|
|
|
export type EmbeddedDashboard = {
|
|
getScrollSize: () => Promise<Size>;
|
|
unmount: () => void;
|
|
getDashboardPermalink: (anchor: string) => Promise<string>;
|
|
getActiveTabs: () => Promise<string[]>;
|
|
observeDataMask: (
|
|
callbackFn: ObserveDataMaskCallbackFn,
|
|
) => void;
|
|
getDataMask: () => Promise<Record<string, any>>;
|
|
getChartStates: () => Promise<Record<string, any>>;
|
|
getChartDataPayloads: (params?: { chartId?: number }) => Promise<Record<string, any>>;
|
|
setThemeConfig: (themeConfig: Record<string, any>) => void;
|
|
setThemeMode: (mode: ThemeMode) => void;
|
|
};
|
|
|
|
/**
|
|
* Embeds a Superset dashboard into the page using an iframe.
|
|
*/
|
|
export async function embedDashboard({
|
|
id,
|
|
supersetDomain,
|
|
mountPoint,
|
|
fetchGuestToken,
|
|
dashboardUiConfig,
|
|
debug = false,
|
|
iframeTitle = 'Embedded Dashboard',
|
|
iframeSandboxExtras = [],
|
|
iframeAllowExtras = [],
|
|
referrerPolicy,
|
|
resolvePermalinkUrl,
|
|
}: EmbedDashboardParams): Promise<EmbeddedDashboard> {
|
|
function log(...info: unknown[]) {
|
|
if (debug) {
|
|
console.debug(`[superset-embedded-sdk][dashboard ${id}]`, ...info);
|
|
}
|
|
}
|
|
|
|
log('embedding');
|
|
|
|
if (supersetDomain.endsWith('/')) {
|
|
supersetDomain = supersetDomain.slice(0, -1);
|
|
}
|
|
|
|
function calculateConfig() {
|
|
let configNumber = 0;
|
|
if (dashboardUiConfig) {
|
|
if (dashboardUiConfig.hideTitle) {
|
|
configNumber += 1;
|
|
}
|
|
if (dashboardUiConfig.hideTab) {
|
|
configNumber += 2;
|
|
}
|
|
if (dashboardUiConfig.hideChartControls) {
|
|
configNumber += 8;
|
|
}
|
|
if (dashboardUiConfig.emitDataMasks) {
|
|
configNumber += 16;
|
|
}
|
|
if (dashboardUiConfig.showRowLimitWarning) {
|
|
configNumber += 32;
|
|
}
|
|
}
|
|
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],
|
|
]),
|
|
);
|
|
|
|
// 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()
|
|
: '';
|
|
|
|
// 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
|
|
// additional sandbox props
|
|
iframeSandboxExtras.forEach((key: string) => {
|
|
iframe.sandbox.add(key);
|
|
});
|
|
// force a specific refererPolicy to be used in the iframe request
|
|
if (referrerPolicy) {
|
|
iframe.referrerPolicy = referrerPolicy;
|
|
}
|
|
|
|
// add the event listener before setting src, to be 100% sure that we capture the load event
|
|
iframe.addEventListener('load', () => {
|
|
// MessageChannel allows us to send and receive messages smoothly between our window and the iframe
|
|
// See https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API
|
|
const commsChannel = new MessageChannel();
|
|
const ourPort = commsChannel.port1;
|
|
const theirPort = commsChannel.port2;
|
|
|
|
// Send one of the message channel ports to the iframe to initialize embedded comms
|
|
// 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' },
|
|
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,
|
|
}),
|
|
);
|
|
});
|
|
iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`;
|
|
iframe.title = iframeTitle;
|
|
iframe.style.background = 'transparent';
|
|
if (iframeAllowExtras.length > 0) {
|
|
iframe.setAttribute('allow', iframeAllowExtras.join('; '));
|
|
}
|
|
//@ts-ignore
|
|
mountPoint.replaceChildren(iframe);
|
|
log('placed the iframe');
|
|
});
|
|
}
|
|
|
|
const [guestToken, ourPort]: [string, Switchboard] = await Promise.all([
|
|
fetchGuestToken(),
|
|
mountIframe(),
|
|
]);
|
|
|
|
ourPort.emit('guestToken', { guestToken });
|
|
log('sent guest token');
|
|
|
|
async function refreshGuestToken() {
|
|
const newGuestToken = await fetchGuestToken();
|
|
ourPort.emit('guestToken', { guestToken: newGuestToken });
|
|
setTimeout(refreshGuestToken, getGuestTokenRefreshTiming(newGuestToken));
|
|
}
|
|
|
|
setTimeout(refreshGuestToken, getGuestTokenRefreshTiming(guestToken));
|
|
|
|
// Register the resolvePermalinkUrl method for the iframe to call
|
|
// Returns null if no callback provided or on error, allowing iframe to use default URL
|
|
ourPort.start();
|
|
ourPort.defineMethod(
|
|
'resolvePermalinkUrl',
|
|
async ({ key }: { key: string }): Promise<string | null> => {
|
|
if (!resolvePermalinkUrl) {
|
|
return null;
|
|
}
|
|
try {
|
|
return await resolvePermalinkUrl({ key });
|
|
} catch (error) {
|
|
log('Error in resolvePermalinkUrl callback:', error);
|
|
return null;
|
|
}
|
|
},
|
|
);
|
|
|
|
function unmount() {
|
|
log('unmounting');
|
|
//@ts-ignore
|
|
mountPoint.replaceChildren();
|
|
}
|
|
|
|
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 getChartStates = () => ourPort.get<Record<string, any>>('getChartStates');
|
|
const getChartDataPayloads = (params?: { chartId?: number }) =>
|
|
ourPort.get<Record<string, any>>('getChartDataPayloads', params);
|
|
const observeDataMask = (
|
|
callbackFn: ObserveDataMaskCallbackFn,
|
|
) => {
|
|
ourPort.defineMethod('observeDataMask', callbackFn);
|
|
};
|
|
// TODO: Add proper types once theming branch is merged
|
|
const setThemeConfig = async (themeConfig: Record<string, any>): Promise<void> => {
|
|
try {
|
|
ourPort.emit('setThemeConfig', { themeConfig });
|
|
log('Theme config sent successfully (or at least message dispatched)');
|
|
} catch (error) {
|
|
log(
|
|
'Error sending theme config. Ensure the iframe side implements the "setThemeConfig" method.',
|
|
);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const setThemeMode = (mode: ThemeMode): void => {
|
|
try {
|
|
ourPort.emit('setThemeMode', { mode });
|
|
log(`Theme mode set to: ${mode}`);
|
|
} catch (error) {
|
|
log(
|
|
'Error sending theme mode. Ensure the iframe side implements the "setThemeMode" method.',
|
|
);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
return {
|
|
getScrollSize,
|
|
unmount,
|
|
getDashboardPermalink,
|
|
getActiveTabs,
|
|
observeDataMask,
|
|
getDataMask,
|
|
getChartStates,
|
|
getChartDataPayloads,
|
|
setThemeConfig,
|
|
setThemeMode,
|
|
};
|
|
}
|