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
4 changes: 4 additions & 0 deletions RESOURCES/INTHEWILD.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,10 @@ categories:
url: https://www.skyscanner.net/
contributors: ["@cleslie", "@stanhoucke"]

Logistics:
- name: Stockarea
url: https://stockarea.io

Others:
- name: 10Web
url: https://10web.io/
Expand Down
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 @@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/

import { GenericDataType } from '@apache-superset/core/api/core';
import { TimeseriesDataRecord } from '../../chart';
import { AnnotationData } from './AnnotationLayer';
Expand All @@ -42,6 +41,11 @@ export interface ChartDataResponseResult {
cache_key: string | null;
cache_timeout: number | null;
cached_dttm: string | null;
/**
* UTC timestamp when the query was executed (ISO 8601 format).
* For cached queries, this is when the original query ran.
*/
queried_dttm: string | null;
/**
* Array of data records as dictionary
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const basicQueryResult: ChartDataResponseResult = {
cache_key: null,
cached_dttm: null,
cache_timeout: null,
queried_dttm: null,
data: [],
colnames: [],
coltypes: [],
Expand Down
57 changes: 57 additions & 0 deletions superset-frontend/src/components/LastQueriedLabel/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* 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 { FC } from 'react';
import { t } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/ui';
import { extendedDayjs } from '@superset-ui/core/utils/dates';

interface LastQueriedLabelProps {
queriedDttm: string | null;
}

const LastQueriedLabel: FC<LastQueriedLabelProps> = ({ queriedDttm }) => {
const theme = useTheme();

if (!queriedDttm) {
return null;
}

const parsedDate = extendedDayjs.utc(queriedDttm);
if (!parsedDate.isValid()) {
return null;
}

const formattedTime = parsedDate.local().format('L LTS');

return (
<div
css={css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextLabel};
padding: ${theme.sizeUnit / 2}px ${theme.sizeUnit}px;
text-align: right;
`}
data-test="last-queried-label"
>
{t('Last queried at')}: {formattedTime}
</div>
);
};

export default LastQueriedLabel;
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const PropertiesModal = ({
const [customCss, setCustomCss] = useState('');
const [refreshFrequency, setRefreshFrequency] = useState(0);
const [selectedThemeId, setSelectedThemeId] = useState<number | null>(null);
const [showChartTimestamps, setShowChartTimestamps] = useState(false);
const [themes, setThemes] = useState<
Array<{
id: number;
Expand All @@ -140,7 +141,11 @@ const PropertiesModal = ({
const handleErrorResponse = async (response: Response) => {
const { error, statusText, message } = await getClientErrorObject(response);
let errorText = error || statusText || t('An error has occurred');
if (typeof message === 'object' && 'json_metadata' in message) {
if (
typeof message === 'object' &&
'json_metadata' in message &&
typeof (message as { json_metadata: unknown }).json_metadata === 'string'
) {
errorText = (message as { json_metadata: string }).json_metadata;
} else if (typeof message === 'string') {
errorText = message;
Expand All @@ -150,7 +155,7 @@ const PropertiesModal = ({
}
}

addDangerToast(errorText);
addDangerToast(String(errorText));
};

const handleDashboardData = useCallback(
Expand Down Expand Up @@ -192,10 +197,12 @@ const PropertiesModal = ({
'shared_label_colors',
'map_label_colors',
'color_scheme_domain',
'show_chart_timestamps',
]);

setJsonMetadata(metaDataCopy ? jsonStringify(metaDataCopy) : '');
setRefreshFrequency(metadata?.refresh_frequency || 0);
setShowChartTimestamps(metadata?.show_chart_timestamps ?? false);
originalDashboardMetadata.current = metadata;
},
[form],
Expand Down Expand Up @@ -320,11 +327,13 @@ const PropertiesModal = ({
: false;
const jsonMetadataObj = getJsonMetadata();
jsonMetadataObj.refresh_frequency = refreshFrequency;
jsonMetadataObj.show_chart_timestamps = Boolean(showChartTimestamps);
const customLabelColors = jsonMetadataObj.label_colors || {};
const updatedDashboardMetadata = {
...originalDashboardMetadata.current,
label_colors: customLabelColors,
color_scheme: updatedColorScheme,
show_chart_timestamps: showChartTimestamps,
};

originalDashboardMetadata.current = updatedDashboardMetadata;
Expand Down Expand Up @@ -711,9 +720,11 @@ const PropertiesModal = ({
colorScheme={colorScheme}
customCss={customCss}
hasCustomLabelsColor={hasCustomLabelsColor}
showChartTimestamps={showChartTimestamps}
onThemeChange={handleThemeChange}
onColorSchemeChange={onColorSchemeChange}
onCustomCssChange={setCustomCss}
onShowChartTimestampsChange={setShowChartTimestamps}
addDangerToast={addDangerToast}
/>
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ const defaultProps = {
colorScheme: 'supersetColors',
customCss: '',
hasCustomLabelsColor: false,
showChartTimestamps: false,
onThemeChange: jest.fn(),
onColorSchemeChange: jest.fn(),
onCustomCssChange: jest.fn(),
onShowChartTimestampsChange: jest.fn(),
addDangerToast: jest.fn(),
};

Expand Down Expand Up @@ -156,6 +158,49 @@ test('displays current color scheme value', () => {
expect(colorSchemeInput).toHaveValue('testColors');
});

test('renders chart timestamps field', () => {
render(<StylingSection {...defaultProps} />);

expect(
screen.getByTestId('dashboard-show-timestamps-field'),
).toBeInTheDocument();
expect(
screen.getByTestId('dashboard-show-timestamps-switch'),
).toBeInTheDocument();
});

test('chart timestamps switch reflects showChartTimestamps prop', () => {
const { rerender } = render(
<StylingSection {...defaultProps} showChartTimestamps={false} />,
);

let timestampSwitch = screen.getByTestId('dashboard-show-timestamps-switch');
expect(timestampSwitch).not.toBeChecked();

rerender(<StylingSection {...defaultProps} showChartTimestamps />);

timestampSwitch = screen.getByTestId('dashboard-show-timestamps-switch');
expect(timestampSwitch).toBeChecked();
});

test('calls onShowChartTimestampsChange when switch is toggled', async () => {
const onShowChartTimestampsChange = jest.fn();
render(
<StylingSection
{...defaultProps}
onShowChartTimestampsChange={onShowChartTimestampsChange}
/>,
);

const timestampSwitch = screen.getByTestId(
'dashboard-show-timestamps-switch',
);
await userEvent.click(timestampSwitch);

expect(onShowChartTimestampsChange).toHaveBeenCalled();
expect(onShowChartTimestampsChange.mock.calls[0][0]).toBe(true);
});

// CSS Template Tests
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('CSS Template functionality', () => {
Expand Down
Loading
Loading