Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions superset-embedded-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,14 @@ embedDashboard({
// ...
}
},
// optional additional iframe sandbox attributes
// optional additional iframe sandbox attributes
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'],
// optional Permissions Policy features
iframeAllowExtras: ['clipboard-write', 'fullscreen'],
// optional config to enforce a particular referrerPolicy
referrerPolicy: "same-origin"
referrerPolicy: "same-origin",
// optional callback to customize permalink URLs
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`
});
```

Expand Down Expand Up @@ -159,10 +163,63 @@ To pass additional sandbox attributes you can use `iframeSandboxExtras`:
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox']
```

### Permissions Policy

To enable specific browser features within the embedded iframe, use `iframeAllowExtras` to set the iframe's [Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy) (the `allow` attribute):

```js
// optional Permissions Policy features
iframeAllowExtras: ['clipboard-write', 'fullscreen']
```

Common permissions you might need:
- `clipboard-write` - Required for "Copy permalink to clipboard" functionality
- `fullscreen` - Required for fullscreen chart viewing
- `camera`, `microphone` - If your dashboards include media capture features

### Enforcing a ReferrerPolicy on the request triggered by the iframe

By default, the Embedded SDK creates an `iframe` element without a `referrerPolicy` value enforced. This means that a policy defined for `iframe` elements at the host app level would reflect to it.

This can be an issue as during the embedded enablement for a dashboard it's possible to specify which domain(s) are allowed to embed the dashboard, and this validation happens throuth the `Referrer` header. That said, in case the hosting app has a more restrictive policy that would omit this header, this validation would fail.

Use the `referrerPolicy` parameter in the `embedDashboard` method to specify [a particular policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy) that works for your implementation.

### Customizing Permalink URLs

When users click share buttons inside an embedded dashboard, Superset generates permalinks using Superset's domain. If you want to use your own domain and URL format for these permalinks, you can provide a `resolvePermalinkUrl` callback:

```js
embedDashboard({
id: "abc123",
supersetDomain: "https://superset.example.com",
mountPoint: document.getElementById("my-superset-container"),
fetchGuestToken: () => fetchGuestTokenFromBackend(),

// Customize permalink URLs
resolvePermalinkUrl: ({ key }) => {
// key: the permalink key (e.g., "xyz789")
return `https://my-app.com/analytics/share/${key}`;
}
});
```

To restore the dashboard state from a permalink in your app:

```js
// In your route handler for /analytics/share/:key
const permalinkKey = routeParams.key;

embedDashboard({
id: "abc123",
supersetDomain: "https://superset.example.com",
mountPoint: document.getElementById("my-superset-container"),
fetchGuestToken: () => fetchGuestTokenFromBackend(),
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
dashboardUiConfig: {
urlParams: {
permalink_key: permalinkKey, // Restores filters, tabs, chart states, and scrolls to anchor
}
}
});
```
38 changes: 37 additions & 1 deletion superset-embedded-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,13 @@ export type EmbedDashboardParams = {
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 = {
Expand All @@ -83,6 +88,15 @@ export type ObserveDataMaskCallbackFn = (
) => 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;
Expand Down Expand Up @@ -110,7 +124,9 @@ export async function embedDashboard({
debug = false,
iframeTitle = 'Embedded Dashboard',
iframeSandboxExtras = [],
iframeAllowExtras = [],
referrerPolicy,
resolvePermalinkUrl,
}: EmbedDashboardParams): Promise<EmbeddedDashboard> {
function log(...info: unknown[]) {
if (debug) {
Expand Down Expand Up @@ -216,6 +232,9 @@ export async function embedDashboard({
});
iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`;
iframe.title = iframeTitle;
if (iframeAllowExtras.length > 0) {
iframe.setAttribute('allow', iframeAllowExtras.join('; '));
}
//@ts-ignore
mountPoint.replaceChildren(iframe);
log('placed the iframe');
Expand All @@ -238,6 +257,24 @@ export async function embedDashboard({

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
Expand All @@ -255,7 +292,6 @@ export async function embedDashboard({
const observeDataMask = (
callbackFn: ObserveDataMaskCallbackFn,
) => {
ourPort.start();
ourPort.defineMethod('observeDataMask', callbackFn);
};
// TODO: Add proper types once theming branch is merged
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,17 @@ export default function URLShortLinkButton({
chartStates &&
Object.keys(chartStates).length > 0;

const url = await getDashboardPermalink({
const result = await getDashboardPermalink({
dashboardId,
dataMask,
activeTabs,
anchor: anchorLinkId,
chartStates: includeChartState ? chartStates : undefined,
includeChartState,
});
setShortUrl(url);
if (result?.url) {
setShortUrl(result.url);
}
} catch (error) {
if (error) {
addDangerToast(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,18 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
chartStates &&
Object.keys(chartStates).length > 0;

return getDashboardPermalink({
const result = await getDashboardPermalink({
dashboardId,
dataMask,
activeTabs,
anchor: dashboardComponentId,
chartStates: includeChartState ? chartStates : undefined,
includeChartState,
});
if (!result?.url) {
throw new Error('Failed to generate permalink URL');
}
return result.url;
}

async function onCopyLink() {
Expand Down
17 changes: 15 additions & 2 deletions superset-frontend/src/dashboard/containers/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,12 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
// the currently stored value when hydrating
let activeTabs: string[] | undefined;
let chartStates: DashboardChartStates | undefined;
let anchor: string | undefined;
if (permalinkKey) {
const permalinkValue = await getPermalinkValue(permalinkKey);
if (permalinkValue) {
({ dataMask, activeTabs, chartStates } = permalinkValue.state);
if (permalinkValue?.state) {
({ dataMask, activeTabs, chartStates, anchor } =
permalinkValue.state);
}
} else if (nativeFilterKeyValue) {
dataMask = await getFilterValue(id, nativeFilterKeyValue);
Expand All @@ -203,6 +205,17 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
chartStates,
}),
);

// Scroll to anchor element if specified in permalink state
if (anchor) {
// Use setTimeout to ensure the DOM has been updated after hydration
setTimeout(() => {
const element = document.getElementById(anchor);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}, 0);
}
}
return null;
}
Expand Down
4 changes: 3 additions & 1 deletion superset-frontend/src/embedded/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,16 @@ const getDashboardPermalink = async ({
chartStates &&
Object.keys(chartStates).length > 0;

return getDashboardPermalinkUtil({
const { url } = await getDashboardPermalinkUtil({
dashboardId,
dataMask,
activeTabs,
anchor,
chartStates: includeChartState ? chartStates : undefined,
includeChartState,
});

return url;
};

const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || [];
Expand Down
8 changes: 5 additions & 3 deletions superset-frontend/src/explore/components/EmbedCodeContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
const updateUrl = useCallback(() => {
setUrl('');
getChartPermalink(formData)
.then(url => {
setUrl(url);
setErrorMessage('');
.then(result => {
if (result?.url) {
setUrl(result.url);
setErrorMessage('');
}
})
.catch(() => {
setErrorMessage(t('Error'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,13 @@ export const useExploreAdditionalActionsMenu = (
const shareByEmail = useCallback(async () => {
try {
const subject = t('Superset Chart');
const url = await getChartPermalink(latestQueryFormData);
const body = encodeURIComponent(t('%s%s', 'Check out this chart: ', url));
const result = await getChartPermalink(latestQueryFormData);
if (!result?.url) {
throw new Error('Failed to generate permalink');
}
const body = encodeURIComponent(
t('%s%s', 'Check out this chart: ', result.url),
);
window.location.href = `mailto:?Subject=${subject}%20&Body=${body}`;
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
Expand Down Expand Up @@ -315,7 +320,13 @@ export const useExploreAdditionalActionsMenu = (
if (!latestQueryFormData) {
throw new Error();
}
await copyTextToClipboard(() => getChartPermalink(latestQueryFormData));
await copyTextToClipboard(async () => {
const result = await getChartPermalink(latestQueryFormData);
if (!result?.url) {
throw new Error('Failed to generate permalink');
}
return result.url;
});
addSuccessToast(t('Copied to clipboard!'));
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
Expand Down
Loading
Loading