mirror of
https://github.com/apache/superset.git
synced 2026-04-17 23:25:05 +00:00
feat(embedded-sdk): Add resolvePermalinkUrl callback for custom permalink URLs (#36924)
This commit is contained in:
committed by
GitHub
parent
1e8d648f47
commit
53dddf4db2
@@ -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}`
|
||||
});
|
||||
```
|
||||
|
||||
@@ -159,6 +163,20 @@ 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.
|
||||
@@ -166,3 +184,42 @@ By default, the Embedded SDK creates an `iframe` element without a `referrerPoli
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
@@ -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');
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function URLShortLinkButton({
|
||||
chartStates &&
|
||||
Object.keys(chartStates).length > 0;
|
||||
|
||||
const url = await getDashboardPermalink({
|
||||
const result = await getDashboardPermalink({
|
||||
dashboardId,
|
||||
dataMask,
|
||||
activeTabs,
|
||||
@@ -77,7 +77,9 @@ export default function URLShortLinkButton({
|
||||
chartStates: includeChartState ? chartStates : undefined,
|
||||
includeChartState,
|
||||
});
|
||||
setShortUrl(url);
|
||||
if (result?.url) {
|
||||
setShortUrl(result.url);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
addDangerToast(
|
||||
|
||||
@@ -94,7 +94,7 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
|
||||
chartStates &&
|
||||
Object.keys(chartStates).length > 0;
|
||||
|
||||
return getDashboardPermalink({
|
||||
const result = await getDashboardPermalink({
|
||||
dashboardId,
|
||||
dataMask,
|
||||
activeTabs,
|
||||
@@ -102,6 +102,10 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
|
||||
chartStates: includeChartState ? chartStates : undefined,
|
||||
includeChartState,
|
||||
});
|
||||
if (!result?.url) {
|
||||
throw new Error('Failed to generate permalink URL');
|
||||
}
|
||||
return result.url;
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ const getDashboardPermalink = async ({
|
||||
chartStates &&
|
||||
Object.keys(chartStates).length > 0;
|
||||
|
||||
return getDashboardPermalinkUtil({
|
||||
const { url } = await getDashboardPermalinkUtil({
|
||||
dashboardId,
|
||||
dataMask,
|
||||
activeTabs,
|
||||
@@ -75,6 +75,8 @@ const getDashboardPermalink = async ({
|
||||
chartStates: includeChartState ? chartStates : undefined,
|
||||
includeChartState,
|
||||
});
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || [];
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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.'));
|
||||
@@ -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.'));
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
QueryFormData,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import Switchboard from '@superset-ui/switchboard';
|
||||
import rison from 'rison';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from '../constants';
|
||||
import { getActiveFilters } from '../dashboard/util/activeDashboardFilters';
|
||||
import serializeActiveFilterValues from '../dashboard/util/serializeActiveFilterValues';
|
||||
import getBootstrapData from './getBootstrapData';
|
||||
|
||||
export type UrlParamType = 'string' | 'number' | 'boolean' | 'object' | 'rison';
|
||||
export type UrlParam = (typeof URL_PARAMS)[keyof typeof URL_PARAMS];
|
||||
@@ -139,24 +141,69 @@ export function getDashboardUrlParams(
|
||||
return getUrlParamEntries(urlParams);
|
||||
}
|
||||
|
||||
function getPermalink(endpoint: string, jsonPayload: JsonObject) {
|
||||
export type PermalinkResult = {
|
||||
key: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function getPermalink(
|
||||
endpoint: string,
|
||||
jsonPayload: JsonObject,
|
||||
): Promise<PermalinkResult> {
|
||||
return SupersetClient.post({
|
||||
endpoint,
|
||||
jsonPayload,
|
||||
}).then(result => result.json.url as string);
|
||||
}).then(result => ({
|
||||
key: result.json.key as string,
|
||||
url: result.json.url as string,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getChartPermalink(
|
||||
/**
|
||||
* Resolves a permalink URL using the host app's custom callback if in embedded mode.
|
||||
* Falls back to the default URL if not embedded or if no callback is provided.
|
||||
*/
|
||||
async function resolvePermalinkUrl(
|
||||
result: PermalinkResult,
|
||||
): Promise<PermalinkResult> {
|
||||
const { key, url } = result;
|
||||
|
||||
// In embedded mode, check if the host app has a custom resolvePermalinkUrl callback
|
||||
const bootstrapData = getBootstrapData();
|
||||
if (bootstrapData.embedded) {
|
||||
try {
|
||||
// Ask the SDK to resolve the permalink URL
|
||||
// Returns null if no callback was provided by the host
|
||||
const resolvedUrl = await Switchboard.get<string | null>(
|
||||
'resolvePermalinkUrl',
|
||||
{ key },
|
||||
);
|
||||
|
||||
// If callback returned a valid URL string, use it; otherwise use Superset's default URL
|
||||
if (typeof resolvedUrl === 'string' && resolvedUrl.length > 0) {
|
||||
return { key, url: resolvedUrl };
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fall back to default URL if Switchboard call fails
|
||||
// (e.g., if not in embedded context or callback throws)
|
||||
}
|
||||
}
|
||||
|
||||
return { key, url };
|
||||
}
|
||||
|
||||
export async function getChartPermalink(
|
||||
formData: Pick<QueryFormData, 'datasource'>,
|
||||
excludedUrlParams?: string[],
|
||||
) {
|
||||
return getPermalink('/api/v1/explore/permalink', {
|
||||
): Promise<PermalinkResult> {
|
||||
const result = await getPermalink('/api/v1/explore/permalink', {
|
||||
formData,
|
||||
urlParams: getChartUrlParams(excludedUrlParams),
|
||||
});
|
||||
return resolvePermalinkUrl(result);
|
||||
}
|
||||
|
||||
export function getDashboardPermalink({
|
||||
export async function getDashboardPermalink({
|
||||
dashboardId,
|
||||
dataMask,
|
||||
activeTabs,
|
||||
@@ -186,7 +233,7 @@ export function getDashboardPermalink({
|
||||
* Whether to include chart state in the permalink (default: false)
|
||||
*/
|
||||
includeChartState?: boolean;
|
||||
}) {
|
||||
}): Promise<PermalinkResult> {
|
||||
const payload: JsonObject = {
|
||||
urlParams: getDashboardUrlParams(),
|
||||
dataMask,
|
||||
@@ -199,7 +246,11 @@ export function getDashboardPermalink({
|
||||
payload.chartStates = chartStates;
|
||||
}
|
||||
|
||||
return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, payload);
|
||||
const result = await getPermalink(
|
||||
`/api/v1/dashboard/${dashboardId}/permalink`,
|
||||
payload,
|
||||
);
|
||||
return resolvePermalinkUrl(result);
|
||||
}
|
||||
|
||||
const externalUrlRegex =
|
||||
|
||||
Reference in New Issue
Block a user