mirror of
https://github.com/apache/superset.git
synced 2026-09-09 00:34:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a658e8be09 | ||
|
|
6a23050f04 | ||
|
|
ab0a0b60bf | ||
|
|
1f772b3059 | ||
|
|
ccc6315890 | ||
|
|
cc9b1d8c28 | ||
|
|
5bc5f5f19c | ||
|
|
66b884f199 | ||
|
|
63d6c3b151 | ||
|
|
34f38ead76 | ||
|
|
35ebc7d322 | ||
|
|
73f03c1b3e | ||
|
|
39fd3ddd04 | ||
|
|
fa28a97583 | ||
|
|
7909ddb775 | ||
|
|
5f439b18f3 | ||
|
|
4c80658354 | ||
|
|
8609fab3c6 | ||
|
|
a4be5ffa0c | ||
|
|
3620c4cde5 | ||
|
|
d9f3ed34dd |
@@ -44,6 +44,9 @@ const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
type Props = {
|
||||
dashboardId: string;
|
||||
// Which resource the id refers to. Defaults to 'dashboard' so existing
|
||||
// call sites are unaffected; charts reuse the same controls.
|
||||
resourceType?: 'dashboard' | 'chart';
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
};
|
||||
@@ -59,7 +62,11 @@ const ButtonRow = styled.div`
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
export const DashboardEmbedControls = ({
|
||||
dashboardId,
|
||||
resourceType = 'dashboard',
|
||||
onHide,
|
||||
}: Props) => {
|
||||
const { addInfoToast, addDangerToast } = useToasts();
|
||||
const [ready, setReady] = useState(true); // whether we have initialized yet
|
||||
const [loading, setLoading] = useState(false); // whether we are currently doing an async thing
|
||||
@@ -67,7 +74,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
const [allowedDomains, setAllowedDomains] = useState<string>('');
|
||||
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false);
|
||||
|
||||
const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`;
|
||||
const endpoint = `/api/v1/${resourceType}/${dashboardId}/embedded`;
|
||||
// whether saveable changes have been made to the config
|
||||
const isDirty =
|
||||
!embedded ||
|
||||
@@ -172,18 +179,26 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
<DocsConfigDetails embeddedId={embedded.uuid} />
|
||||
) : (
|
||||
<p>
|
||||
{t(
|
||||
'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
|
||||
)}
|
||||
{resourceType === 'chart'
|
||||
? t(
|
||||
'This chart is ready to embed. In your application, pass the following id to the SDK:',
|
||||
)
|
||||
: t(
|
||||
'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
|
||||
)}
|
||||
<br />
|
||||
<code>{embedded.uuid}</code>
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p>
|
||||
{t(
|
||||
'Configure this dashboard to embed it into an external web application.',
|
||||
)}
|
||||
{resourceType === 'chart'
|
||||
? t(
|
||||
'Configure this chart to embed it into an external web application.',
|
||||
)
|
||||
: t(
|
||||
'Configure this dashboard to embed it into an external web application.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
|
||||
@@ -64,6 +64,8 @@ import {
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF,
|
||||
} from 'src/logger/LogUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import DashboardEmbedModal from 'src/dashboard/components/EmbeddedModal';
|
||||
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
@@ -173,6 +175,13 @@ const SliceHeaderControls = (
|
||||
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
|
||||
) => {
|
||||
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
|
||||
const [embedModalIsOpen, setEmbedModalIsOpen] = useState(false);
|
||||
const user = useSelector((state: RootState) => state.user);
|
||||
// Mirrors the dashboard's `userCanCurate`: embedding must be enabled and the
|
||||
// user must hold the chart-level set_embedded permission.
|
||||
const canEmbed =
|
||||
isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
|
||||
findPermission('can_set_embedded', 'Chart', user?.roles);
|
||||
// setting openKeys undefined falls back to uncontrolled behaviour
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
|
||||
@@ -263,6 +272,9 @@ const SliceHeaderControls = (
|
||||
refreshChart();
|
||||
props.addSuccessToast(t('Data refreshed'));
|
||||
break;
|
||||
case MenuKeys.ManageEmbedded:
|
||||
setEmbedModalIsOpen(true);
|
||||
break;
|
||||
case MenuKeys.ToggleChartDescription:
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
props.toggleExpandSlice?.(props.slice.slice_id);
|
||||
@@ -513,6 +525,14 @@ const SliceHeaderControls = (
|
||||
key: MenuKeys.Fullscreen,
|
||||
label: fullscreenLabel,
|
||||
},
|
||||
...(canEmbed
|
||||
? [
|
||||
{
|
||||
key: MenuKeys.ManageEmbedded,
|
||||
label: t('Embed chart'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
@@ -806,6 +826,14 @@ const SliceHeaderControls = (
|
||||
dataset={datasetWithVerboseMap}
|
||||
/>
|
||||
{canEditCrossFilters && scopingModal}
|
||||
{canEmbed && (
|
||||
<DashboardEmbedModal
|
||||
show={embedModalIsOpen}
|
||||
onHide={() => setEmbedModalIsOpen(false)}
|
||||
dashboardId={String(slice.slice_id)}
|
||||
resourceType="chart"
|
||||
/>
|
||||
)}
|
||||
{isFullSize && <Global styles={fullscreenStyles(theme)} />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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 { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
|
||||
import dashboardLayout from 'src/dashboard/reducers/dashboardLayout';
|
||||
import nativeFilters from 'src/dashboard/reducers/nativeFilters';
|
||||
import dashboardStateReducer from 'src/dashboard/reducers/dashboardState';
|
||||
import sliceEntities from 'src/dashboard/reducers/sliceEntities';
|
||||
import { CommonBootstrapData } from 'src/types/bootstrapTypes';
|
||||
import hydrateEmbedded, { EmbeddedChartData } from './hydrateEmbedded';
|
||||
|
||||
const SLICE_ID = 103;
|
||||
|
||||
const chartData = {
|
||||
slice: {
|
||||
slice_id: SLICE_ID,
|
||||
slice_url: `/explore/?slice_id=${SLICE_ID}`,
|
||||
slice_name: 'Preferred Employment Style',
|
||||
form_data: {
|
||||
viz_type: 'treemap_v2',
|
||||
datasource: '4__table',
|
||||
slice_id: SLICE_ID,
|
||||
},
|
||||
description: null,
|
||||
changed_on: '2026-01-01T00:00:00',
|
||||
},
|
||||
dataset: { uid: '4__table', id: 4 },
|
||||
} as unknown as EmbeddedChartData;
|
||||
|
||||
const common = { locale: 'en' } as unknown as CommonBootstrapData;
|
||||
|
||||
const build = () => hydrateEmbedded(chartData, common);
|
||||
|
||||
test('dispatches HYDRATE_DASHBOARD rather than a parallel action', () => {
|
||||
expect(build().type).toEqual(HYDRATE_DASHBOARD);
|
||||
});
|
||||
|
||||
test('keys the fabricated state by slice id', () => {
|
||||
const { data } = build();
|
||||
expect(Object.keys(data.charts)).toEqual([String(SLICE_ID)]);
|
||||
expect(data.sliceEntities.slices[SLICE_ID].slice_name).toEqual(
|
||||
'Preferred Employment Style',
|
||||
);
|
||||
expect(data.dataMask[SLICE_ID]).toBeDefined();
|
||||
expect(data.dashboardState.sliceIds).toEqual([SLICE_ID]);
|
||||
});
|
||||
|
||||
test('keeps the actions that would navigate out of the iframe switched off', () => {
|
||||
const { dashboardInfo } = build().data;
|
||||
expect(dashboardInfo.superset_can_explore).toBe(false);
|
||||
expect(dashboardInfo.superset_can_share).toBe(false);
|
||||
expect(dashboardInfo.crossFiltersEnabled).toBe(false);
|
||||
// Chart.tsx reads this one, so downloads stay available.
|
||||
expect(dashboardInfo.superset_can_download).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* The regression this file exists for. `nativeFilters` and `dashboardLayout`
|
||||
* read `action.data.<slice>` with no optional chaining, so omitting either one
|
||||
* throws at runtime — and only in the embedded path, where a dashboard
|
||||
* developer would never see it. Run the real reducers against the real payload
|
||||
* rather than asserting on shape, so this keeps holding if they change.
|
||||
*/
|
||||
describe('every HYDRATE_DASHBOARD handler survives the fabricated payload', () => {
|
||||
const cases: [string, (state: any, action: any) => unknown][] = [
|
||||
['dashboardLayout', dashboardLayout],
|
||||
['nativeFilters', nativeFilters],
|
||||
['dashboardState', dashboardStateReducer],
|
||||
['sliceEntities', sliceEntities],
|
||||
];
|
||||
|
||||
test.each(cases)('%s', (_name, reducer) => {
|
||||
expect(() => reducer(undefined, build())).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test('carries a layout tree so the layout reducer has a root to hydrate', () => {
|
||||
const layout = build().data.dashboardLayout.present;
|
||||
expect(layout.ROOT_ID).toBeDefined();
|
||||
expect(layout.GRID_ID).toBeDefined();
|
||||
});
|
||||
|
||||
test('carries an empty native filter map', () => {
|
||||
expect(build().data.nativeFilters.filters).toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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 { DataMaskWithId, JsonObject } from '@superset-ui/core';
|
||||
import { chart } from 'src/components/Chart/chartReducer';
|
||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||
import { applyDefaultFormData } from 'src/explore/store';
|
||||
import { CommonBootstrapData } from 'src/types/bootstrapTypes';
|
||||
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
|
||||
import { Datasource } from 'src/dashboard/types';
|
||||
import {
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_GRID_ID,
|
||||
} from 'src/dashboard/util/constants';
|
||||
import {
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
} from 'src/dashboard/util/componentTypes';
|
||||
|
||||
/**
|
||||
* A chart embedded on its own still renders through the dashboard's chart
|
||||
* stack, because that is where cross-filtering, drill, and the header controls
|
||||
* live. Rather than reimplement any of that, this builds the minimum slice of
|
||||
* dashboard state a single chart needs and lets the existing components run
|
||||
* against it unchanged.
|
||||
*
|
||||
* It reuses HYDRATE_DASHBOARD rather than introducing a parallel action, so
|
||||
* every dashboard reducer stays untouched: `charts`, `sliceEntities`,
|
||||
* `dataMask`, `dashboardInfo` and `dashboardState` all already handle it.
|
||||
* `dashboardLayout` and `nativeFilters` handle it too but dereference their
|
||||
* slice unconditionally, so the payload carries an empty stand-in for each.
|
||||
* `datasources` is the one slice with no hydrate handler at all, so the caller
|
||||
* dispatches `setDatasources` for it separately.
|
||||
*
|
||||
* Every slice any HYDRATE_DASHBOARD handler reads has to appear here; the
|
||||
* accompanying test asserts that, because a missing one only fails at runtime
|
||||
* and only in the embedded path.
|
||||
*/
|
||||
|
||||
export interface EmbeddedChartData {
|
||||
slice: {
|
||||
slice_id: number;
|
||||
slice_url: string;
|
||||
slice_name: string;
|
||||
form_data: JsonObject & { viz_type: string; datasource: string };
|
||||
description?: string | null;
|
||||
description_markeddown?: string | null;
|
||||
modified?: string | null;
|
||||
changed_on?: string | number | null;
|
||||
};
|
||||
// The explore endpoint returns the full datasource, and `setDatasources`
|
||||
// stores it as one, so it is typed as such rather than loosely.
|
||||
dataset: Datasource;
|
||||
}
|
||||
|
||||
export interface HydrateEmbeddedAction {
|
||||
type: typeof HYDRATE_DASHBOARD;
|
||||
data: {
|
||||
charts: Record<number, JsonObject>;
|
||||
sliceEntities: { slices: Record<number, JsonObject> };
|
||||
dataMask: Record<number, DataMaskWithId>;
|
||||
dashboardInfo: JsonObject;
|
||||
dashboardState: JsonObject;
|
||||
dashboardLayout: { present: JsonObject };
|
||||
nativeFilters: { filters: JsonObject };
|
||||
};
|
||||
}
|
||||
|
||||
const hydrateEmbedded = (
|
||||
{ slice }: EmbeddedChartData,
|
||||
common: CommonBootstrapData,
|
||||
): HydrateEmbeddedAction => {
|
||||
const key = slice.slice_id;
|
||||
|
||||
return {
|
||||
type: HYDRATE_DASHBOARD,
|
||||
data: {
|
||||
charts: {
|
||||
[key]: {
|
||||
...chart,
|
||||
id: key,
|
||||
form_data: applyDefaultFormData(slice.form_data),
|
||||
},
|
||||
},
|
||||
sliceEntities: {
|
||||
slices: {
|
||||
[key]: {
|
||||
slice_id: key,
|
||||
slice_url: slice.slice_url,
|
||||
slice_name: slice.slice_name,
|
||||
form_data: slice.form_data,
|
||||
viz_type: slice.form_data.viz_type,
|
||||
datasource: slice.form_data.datasource,
|
||||
description: slice.description,
|
||||
description_markeddown: slice.description_markeddown,
|
||||
modified: slice.modified,
|
||||
changed_on: slice.changed_on
|
||||
? new Date(slice.changed_on).getTime()
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
[key]: getInitialDataMask(key) as DataMaskWithId,
|
||||
},
|
||||
dashboardInfo: {
|
||||
common,
|
||||
// A guest viewing an embedded chart has no Superset UI to navigate to,
|
||||
// so the actions that would leave the iframe stay off.
|
||||
metadata: {},
|
||||
superset_can_explore: false,
|
||||
superset_can_share: false,
|
||||
// Chart.tsx reads `superset_can_download`; `superset_can_csv` is not
|
||||
// a key the current dashboard chart stack looks at.
|
||||
superset_can_download: true,
|
||||
crossFiltersEnabled: false,
|
||||
},
|
||||
dashboardState: {
|
||||
expandedSlices: { [key]: false },
|
||||
sliceIds: [key],
|
||||
},
|
||||
// Both of these reducers dereference their slice unconditionally on
|
||||
// HYDRATE_DASHBOARD, so they have to be present even though a lone chart
|
||||
// has no layout tree and no native filters of its own.
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
[DASHBOARD_ROOT_ID]: {
|
||||
id: DASHBOARD_ROOT_ID,
|
||||
type: DASHBOARD_ROOT_TYPE,
|
||||
children: [DASHBOARD_GRID_ID],
|
||||
},
|
||||
[DASHBOARD_GRID_ID]: {
|
||||
id: DASHBOARD_GRID_ID,
|
||||
type: DASHBOARD_GRID_TYPE,
|
||||
parents: [DASHBOARD_ROOT_ID],
|
||||
children: [],
|
||||
meta: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default hydrateEmbedded;
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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 { RefObject, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { AntdThemeProvider, Loading } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import Chart from 'src/dashboard/components/gridComponents/Chart';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { setDatasources } from 'src/dashboard/actions/datasources';
|
||||
import useExploreData from './useExploreData';
|
||||
import hydrateEmbedded from './hydrateEmbedded';
|
||||
|
||||
/**
|
||||
* Fills the iframe. The chart is measured by its container rather than the
|
||||
* dashboard grid, so the wrapper owns the dimensions the dashboard would
|
||||
* normally supply.
|
||||
*/
|
||||
const Fill = styled.div`
|
||||
${() => css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The dashboard gives each chart a holder element that owns two things the
|
||||
* header controls reach for: the node passed to `requestFullscreen`, and the
|
||||
* `dashboard-chart-id-<id>` class the screenshot exports select on. An embedded
|
||||
* chart renders without `ChartHolder`, so it has to provide both itself.
|
||||
*/
|
||||
const Holder = styled.div`
|
||||
${({ theme }) => css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
/* Without this the chart title and the header menu sit flush against the
|
||||
iframe edge. On a dashboard the grid gutter supplies this breathing
|
||||
room; an embed has no grid, so the holder supplies it. */
|
||||
padding: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Tracks the holder's content box, which excludes its padding, so the chart is
|
||||
* laid out inside that padding rather than overflowing it. Falls back to the
|
||||
* viewport for the first paint and where ResizeObserver is unavailable.
|
||||
*/
|
||||
const useContainerSize = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
enabled: boolean,
|
||||
): { width: number; height: number } => {
|
||||
const [size, setSize] = useState({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!enabled || !element || typeof ResizeObserver === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const box = entries[0]?.contentRect;
|
||||
if (box?.width && box?.height) {
|
||||
setSize({ width: box.width, height: box.height });
|
||||
}
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref, enabled]);
|
||||
|
||||
return size;
|
||||
};
|
||||
|
||||
export default function EmbeddedChart({ chartId }: { chartId: string }) {
|
||||
const dispatch = useDispatch();
|
||||
const { data, loading, error } = useExploreData(chartId);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [isFullSize, setIsFullSize] = useState(false);
|
||||
const holderRef = useRef<HTMLDivElement>(null);
|
||||
const { width, height } = useContainerSize(holderRef, hydrated);
|
||||
|
||||
const handleToggleFullSize = useCallback(() => {
|
||||
setIsFullSize(current => !current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const bootstrapData = getBootstrapData();
|
||||
// `datasources` has no HYDRATE_DASHBOARD handler, so it is populated
|
||||
// through its own action rather than the hydrate payload.
|
||||
dispatch(setDatasources([data.dataset]));
|
||||
dispatch(hydrateEmbedded(data, bootstrapData.common));
|
||||
setHydrated(true);
|
||||
}, [data, dispatch]);
|
||||
|
||||
if (loading || (!hydrated && !error)) return <Loading />;
|
||||
if (error || !data)
|
||||
return <div>{error ?? t('The chart could not be loaded.')}</div>;
|
||||
|
||||
return (
|
||||
<Fill>
|
||||
<ErrorBoundary>
|
||||
<Holder
|
||||
ref={holderRef}
|
||||
className={`dashboard-component-chart-holder dashboard-chart-id-${data.slice.slice_id}`}
|
||||
>
|
||||
<AntdThemeProvider
|
||||
getPopupContainer={(triggerNode?: HTMLElement) => {
|
||||
// Only the fullscreen element's subtree is painted, so popups
|
||||
// have to be portaled into it rather than to document.body,
|
||||
// otherwise the header menu is unreachable while fullscreen.
|
||||
const fullscreenElement =
|
||||
document.fullscreenElement as HTMLElement | null;
|
||||
return triggerNode && fullscreenElement?.contains(triggerNode)
|
||||
? fullscreenElement
|
||||
: document.body;
|
||||
}}
|
||||
>
|
||||
<Chart
|
||||
id={data.slice.slice_id}
|
||||
componentId={`EMBEDDED_CHART-${data.slice.slice_id}`}
|
||||
// There is no dashboard behind an embedded chart; the fabricated
|
||||
// state is keyed by slice id and nothing reads this as a lookup.
|
||||
dashboardId={0}
|
||||
width={width}
|
||||
height={height}
|
||||
sliceName={data.slice.slice_name}
|
||||
isComponentVisible
|
||||
isInView
|
||||
chartHolderRef={holderRef}
|
||||
isFullSize={isFullSize}
|
||||
handleToggleFullSize={handleToggleFullSize}
|
||||
// Renaming is a dashboard-owner action with no meaning here.
|
||||
updateSliceName={() => {}}
|
||||
/>
|
||||
</AntdThemeProvider>
|
||||
</Holder>
|
||||
</ErrorBoundary>
|
||||
</Fill>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 { useEffect, useState } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { EmbeddedChartData } from './hydrateEmbedded';
|
||||
|
||||
interface State {
|
||||
data: EmbeddedChartData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the one chart this iframe renders, in the shape `hydrateEmbedded`
|
||||
* expects. Uses the explore endpoint because it returns the slice and its
|
||||
* dataset together, which is exactly the pair the fabricated dashboard state
|
||||
* needs and avoids a second round trip for the datasource.
|
||||
*/
|
||||
export default function useExploreData(chartId: string | number): State {
|
||||
const [state, setState] = useState<State>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/explore/?slice_id=${chartId}`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
if (cancelled) return;
|
||||
const result = json?.result;
|
||||
if (!result?.slice || !result?.dataset) {
|
||||
setState({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: t('The chart could not be loaded.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
data: {
|
||||
slice: {
|
||||
...result.slice,
|
||||
// `form_data` on the explore payload already carries the
|
||||
// datasource and viz_type the chart stack keys off.
|
||||
form_data: result.form_data ?? result.slice.form_data,
|
||||
},
|
||||
dataset: result.dataset,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: t('The chart could not be loaded.'),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chartId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
getThemeController,
|
||||
} from './EmbeddedContextProviders';
|
||||
import { embeddedApi } from './api';
|
||||
import EmbeddedChart from './embeddedChart';
|
||||
import { getDataMaskChangeTrigger } from './utils';
|
||||
import { validateMessageEvent } from './originValidation';
|
||||
|
||||
@@ -131,6 +132,16 @@ const EmbeddedLazyDashboardPage = () => {
|
||||
return <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />;
|
||||
};
|
||||
|
||||
// A uuid resolves to either a dashboard or a single chart. Payloads written
|
||||
// before charts were embeddable omit `resource_type`, so anything other than
|
||||
// an explicit 'chart' keeps the original dashboard behaviour.
|
||||
const EmbeddedResource = () =>
|
||||
bootstrapData.embedded?.resource_type === 'chart' ? (
|
||||
<EmbeddedChart chartId={bootstrapData.embedded.chart_id!} />
|
||||
) : (
|
||||
<EmbeddedLazyDashboardPage />
|
||||
);
|
||||
|
||||
const EmbeddedRoute = () => (
|
||||
<EmbeddedContextProviders>
|
||||
<Global
|
||||
@@ -145,7 +156,7 @@ const EmbeddedRoute = () => (
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ErrorBoundary>
|
||||
<EmbeddedLazyDashboardPage />
|
||||
<EmbeddedResource />
|
||||
</ErrorBoundary>
|
||||
<ToastContainer position="top" />
|
||||
</Suspense>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
|
||||
// Matches antd's screenSMMax token; used only when no theme is in scope.
|
||||
const FALLBACK_MOBILE_MAX_WIDTH = 767;
|
||||
@@ -29,7 +30,12 @@ const FALLBACK_MOBILE_MAX_WIDTH = 767;
|
||||
* interpolations; prefer `useIsMobile` in components.
|
||||
*/
|
||||
export function isMobileConsumptionEnabled(): boolean {
|
||||
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode);
|
||||
// Inside an iframe the viewport is the size the host chose for the embed,
|
||||
// not the size of the device, so a narrow embed on a desktop would
|
||||
// otherwise be served the phone experience and lose its chart controls.
|
||||
// Mobile consumption mode is a whole-app experience (route guarding,
|
||||
// drawer navigation) that an embed does not have in the first place.
|
||||
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode) && !isEmbedded();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -183,8 +183,12 @@ export interface BootstrapData {
|
||||
common: CommonBootstrapData;
|
||||
config?: any;
|
||||
embedded?: {
|
||||
// Which resource this uuid resolves to. Older payloads predate charts
|
||||
// being embeddable and omit it, so treat a missing value as a dashboard.
|
||||
resource_type?: 'dashboard' | 'chart';
|
||||
dashboard_id: string;
|
||||
// Domains allowed to embed this dashboard. An empty/undefined list means
|
||||
chart_id?: string;
|
||||
// Domains allowed to embed this resource. An empty/undefined list means
|
||||
// any domain is allowed (no restriction).
|
||||
allowed_domains?: string[];
|
||||
};
|
||||
|
||||
+205
-4
@@ -22,7 +22,13 @@ from typing import Any, cast, Optional
|
||||
from zipfile import is_zipfile, ZipFile
|
||||
|
||||
from flask import current_app, redirect, request, Response, url_for
|
||||
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
|
||||
from flask_appbuilder.api import (
|
||||
expose,
|
||||
permission_name,
|
||||
protect,
|
||||
rison as parse_rison,
|
||||
safe,
|
||||
)
|
||||
from flask_appbuilder.hooks import before_request
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_babel import ngettext
|
||||
@@ -51,6 +57,8 @@ from superset.charts.schemas import (
|
||||
ChartGetResponseSchema,
|
||||
ChartPostSchema,
|
||||
ChartPutSchema,
|
||||
EmbeddedChartConfigSchema,
|
||||
EmbeddedChartResponseSchema,
|
||||
get_delete_ids_schema,
|
||||
get_export_ids_schema,
|
||||
get_fav_star_ids_schema,
|
||||
@@ -86,11 +94,12 @@ from superset.commands.importers.exceptions import (
|
||||
from superset.commands.importers.v1.utils import get_contents_from_bundle
|
||||
from superset.commands.purge import PurgeArchivedCommand, SoftDeleteBinding
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.chart import ChartDAO, EmbeddedChartDAO
|
||||
from superset.exceptions import (
|
||||
ScreenshotImageNotAvailableException,
|
||||
)
|
||||
from superset.extensions import event_logger, security_manager
|
||||
from superset.extensions import db, event_logger, security_manager
|
||||
from superset.models.embedded_chart import EmbeddedChart
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.manager import (
|
||||
get_extra_editor_subject_ids,
|
||||
@@ -175,6 +184,9 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
"get_version",
|
||||
"activity",
|
||||
"restore_version",
|
||||
"get_embedded",
|
||||
"set_embedded",
|
||||
"delete_embedded",
|
||||
}
|
||||
class_permission_name = "Chart"
|
||||
# Custom methods (``restore``) need an explicit entry; FAB's @protect()
|
||||
@@ -194,6 +206,9 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
# single chart's metadata can resolve a Multiple Layers container's
|
||||
# declared layers too.
|
||||
"deck_layers": "read",
|
||||
"get_embedded": "read",
|
||||
"set_embedded": "set_embedded",
|
||||
"delete_embedded": "set_embedded",
|
||||
}
|
||||
|
||||
list_columns = [
|
||||
@@ -307,9 +322,17 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
edit_model_schema = ChartPutSchema()
|
||||
chart_get_response_schema = ChartGetResponseSchema()
|
||||
|
||||
embedded_response_schema = EmbeddedChartResponseSchema()
|
||||
embedded_config_schema = EmbeddedChartConfigSchema()
|
||||
|
||||
openapi_spec_tag = "Charts"
|
||||
""" Override the name set for this collection of endpoints """
|
||||
openapi_spec_component_schemas = CHART_SCHEMAS + (VersionListItemSchema,)
|
||||
openapi_spec_component_schemas = CHART_SCHEMAS + (
|
||||
VersionListItemSchema,
|
||||
# Referenced by $ref from the embedded endpoints, so it has to be
|
||||
# registered as a component rather than only inlined.
|
||||
EmbeddedChartResponseSchema,
|
||||
)
|
||||
|
||||
apispec_parameter_schemas = {
|
||||
"screenshot_query_schema": screenshot_query_schema,
|
||||
@@ -1874,3 +1897,181 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
return restore_version_endpoint(
|
||||
self, Slice, RestoreChartVersionCommand, uuid_str, version_uuid_str
|
||||
)
|
||||
|
||||
@expose("/<pk>/embedded", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@permission_name("read")
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_embedded",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def get_embedded(self, pk: int) -> Response:
|
||||
"""Get the chart's embedded configuration.
|
||||
---
|
||||
get:
|
||||
summary: Get the chart's embedded configuration
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
description: The chart id
|
||||
responses:
|
||||
200:
|
||||
description: Result contains the embedded chart config
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/EmbeddedChartResponseSchema'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
chart = ChartDAO.find_by_id(pk)
|
||||
if not chart:
|
||||
return self.response_404()
|
||||
if not chart.embedded:
|
||||
return self.response(404)
|
||||
embedded: EmbeddedChart = chart.embedded[0]
|
||||
result = self.embedded_response_schema.dump(embedded)
|
||||
return self.response(200, result=result)
|
||||
|
||||
@expose("/<pk>/embedded", methods=("POST", "PUT"))
|
||||
@protect()
|
||||
@safe
|
||||
@permission_name("set_embedded")
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.set_embedded",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def set_embedded(self, pk: int) -> Response:
|
||||
"""Set a chart's embedded configuration.
|
||||
---
|
||||
post:
|
||||
summary: Set a chart's embedded configuration
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
description: The chart id
|
||||
requestBody:
|
||||
description: The embedded configuration to set
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: EmbeddedChartConfigSchema
|
||||
responses:
|
||||
200:
|
||||
description: Successfully set the configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/EmbeddedChartResponseSchema'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
put:
|
||||
summary: Update a chart's embedded configuration
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
description: The chart id
|
||||
requestBody:
|
||||
description: The embedded configuration to set
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: EmbeddedChartConfigSchema
|
||||
responses:
|
||||
200:
|
||||
description: Successfully set the configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/EmbeddedChartResponseSchema'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
chart = ChartDAO.find_by_id(pk)
|
||||
if not chart:
|
||||
return self.response_404()
|
||||
try:
|
||||
body = self.embedded_config_schema.load(request.json)
|
||||
embedded = EmbeddedChartDAO.upsert(chart, body["allowed_domains"])
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
result = self.embedded_response_schema.dump(embedded)
|
||||
return self.response(200, result=result)
|
||||
except ValidationError as error:
|
||||
db.session.rollback() # pylint: disable=consider-using-transaction
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
@expose("/<pk>/embedded", methods=("DELETE",))
|
||||
@protect()
|
||||
@safe
|
||||
@permission_name("set_embedded")
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: (
|
||||
f"{self.__class__.__name__}.delete_embedded"
|
||||
),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def delete_embedded(self, pk: int) -> Response:
|
||||
"""Delete a chart's embedded configuration.
|
||||
---
|
||||
delete:
|
||||
summary: Delete a chart's embedded configuration
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
description: The chart id
|
||||
responses:
|
||||
200:
|
||||
description: Successfully removed the configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
chart = ChartDAO.find_by_id(pk)
|
||||
if not chart:
|
||||
return self.response_404()
|
||||
chart.embedded = []
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
return self.response(200, message="OK")
|
||||
|
||||
@@ -1998,3 +1998,15 @@ CHART_SCHEMAS = (
|
||||
ChartCacheScreenshotResponseSchema,
|
||||
GetFavStarIdsSchema,
|
||||
)
|
||||
|
||||
|
||||
class EmbeddedChartConfigSchema(Schema):
|
||||
allowed_domains = fields.List(fields.String(), required=True)
|
||||
|
||||
|
||||
class EmbeddedChartResponseSchema(Schema):
|
||||
uuid = fields.String()
|
||||
allowed_domains = fields.List(fields.String())
|
||||
chart_id = fields.String()
|
||||
changed_on = fields.DateTime()
|
||||
changed_by = fields.Nested(UserSchema)
|
||||
|
||||
@@ -415,6 +415,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
# avoid circular import: model listener registration imports neutral event helpers
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.embedded_chart import EmbeddedChart # noqa: F401
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard # noqa: F401
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.user_attributes import UserAttribute # noqa: F401
|
||||
@@ -550,6 +551,13 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
fk("slices", "chart_editors", "id", "chart_id", "inbound"),
|
||||
fk("slices", "chart_viewers", "id", "chart_id", "inbound"),
|
||||
fk("slices", "dashboard_slices", "id", "slice_id", "inbound"),
|
||||
fk(
|
||||
"slices",
|
||||
"embedded_charts",
|
||||
"id",
|
||||
"slice_id",
|
||||
"inbound",
|
||||
),
|
||||
fk("slices", "report_schedule", "id", "chart_id", "inbound"),
|
||||
version("slices", "slices_version"),
|
||||
relationship("slices", "tables", "manytoone", "table"),
|
||||
@@ -574,6 +582,27 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
"id",
|
||||
"outbound",
|
||||
),
|
||||
fk(
|
||||
"embedded_charts",
|
||||
"ab_user",
|
||||
"changed_by_fk",
|
||||
"id",
|
||||
"outbound",
|
||||
),
|
||||
fk(
|
||||
"embedded_charts",
|
||||
"ab_user",
|
||||
"created_by_fk",
|
||||
"id",
|
||||
"outbound",
|
||||
),
|
||||
fk(
|
||||
"embedded_charts",
|
||||
"slices",
|
||||
"slice_id",
|
||||
"id",
|
||||
"outbound",
|
||||
),
|
||||
),
|
||||
(
|
||||
DependencyClassification.PRESERVE,
|
||||
@@ -582,6 +611,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
DependencyClassification.ASSOCIATION,
|
||||
DependencyClassification.ASSOCIATION,
|
||||
DependencyClassification.ASSOCIATION,
|
||||
DependencyClassification.OWNED,
|
||||
DependencyClassification.BLOCK,
|
||||
DependencyClassification.VERSION_OWNED,
|
||||
DependencyClassification.PRESERVE,
|
||||
@@ -592,6 +622,9 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
|
||||
DependencyClassification.PRESERVE,
|
||||
DependencyClassification.PRESERVE,
|
||||
DependencyClassification.PRESERVE,
|
||||
DependencyClassification.PRESERVE,
|
||||
DependencyClassification.PRESERVE,
|
||||
DependencyClassification.PRESERVE,
|
||||
),
|
||||
(tag_cleanup, chart_membership_versions),
|
||||
# Keyed by related table; the audit code is declared, not derived.
|
||||
|
||||
+32
-1
@@ -18,7 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from sqlalchemy import or_, select
|
||||
@@ -29,6 +29,7 @@ from superset.commands.chart.exceptions import ChartNotFoundError
|
||||
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
|
||||
from superset.extensions import db
|
||||
from superset.models.core import FavStar, FavStarClassName
|
||||
from superset.models.embedded_chart import EmbeddedChart
|
||||
from superset.models.slice import id_or_uuid_filter, Slice
|
||||
from superset.utils.core import get_user_id
|
||||
|
||||
@@ -166,3 +167,33 @@ class ChartDAO(BaseDAO[Slice]):
|
||||
)
|
||||
if fav:
|
||||
db.session.delete(fav)
|
||||
|
||||
|
||||
class EmbeddedChartDAO(BaseDAO[EmbeddedChart]):
|
||||
# There isn't really a regular scenario where we would rather get Embedded by id
|
||||
id_column_name = "uuid"
|
||||
|
||||
@staticmethod
|
||||
def upsert(chart: Slice, allowed_domains: list[str]) -> EmbeddedChart:
|
||||
"""
|
||||
Sets up a chart to be embeddable.
|
||||
Upsert is used to preserve the embedded_chart uuid across updates.
|
||||
"""
|
||||
embedded: EmbeddedChart = (
|
||||
chart.embedded[0] if chart.embedded else EmbeddedChart()
|
||||
)
|
||||
embedded.allow_domain_list = ",".join(allowed_domains)
|
||||
chart.embedded = [embedded]
|
||||
return embedded
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
item: EmbeddedChart | None = None,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Use EmbeddedChartDAO.upsert() instead.
|
||||
At least, until we are ok with more than one embedded item per chart.
|
||||
"""
|
||||
raise NotImplementedError("Use EmbeddedChartDAO.upsert() instead.")
|
||||
|
||||
+37
-10
@@ -22,6 +22,7 @@ from flask_login import AnonymousUserMixin, login_user
|
||||
from flask_wtf.csrf import same_origin
|
||||
|
||||
from superset import event_logger, is_feature_enabled
|
||||
from superset.daos.chart import EmbeddedChartDAO
|
||||
from superset.daos.dashboard import EmbeddedDashboardDAO
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import json
|
||||
@@ -45,28 +46,45 @@ class EmbeddedView(BaseSupersetView):
|
||||
add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
|
||||
) -> FlaskResponse:
|
||||
"""
|
||||
Server side rendering for the embedded dashboard page
|
||||
:param uuid: identifier for embedded dashboard
|
||||
Server side rendering for the embedded dashboard or chart page
|
||||
:param uuid: identifier for the embedded dashboard or chart
|
||||
:param add_extra_log_payload: added by `log_this_with_manual_updates`, set a
|
||||
default value to appease pylint
|
||||
"""
|
||||
if not is_feature_enabled("EMBEDDED_SUPERSET"):
|
||||
abort(404)
|
||||
|
||||
# A uuid identifies either an embedded dashboard or an embedded chart.
|
||||
# Dashboards are looked up first since they are the older, more common
|
||||
# resource; the two id spaces are distinct so ordering is not ambiguous.
|
||||
resource_type = "dashboard"
|
||||
embedded = EmbeddedDashboardDAO.find_by_id(uuid)
|
||||
|
||||
if not embedded:
|
||||
embedded = EmbeddedChartDAO.find_by_id(uuid)
|
||||
resource_type = "chart"
|
||||
|
||||
if not embedded:
|
||||
abort(404)
|
||||
|
||||
assert embedded is not None
|
||||
dashboard = embedded.dashboard
|
||||
resource = (
|
||||
embedded.dashboard if resource_type == "dashboard" else embedded.slice
|
||||
)
|
||||
|
||||
# validate request referrer in allowed domains
|
||||
is_referrer_allowed = not embedded.allowed_domains
|
||||
for domain in embedded.allowed_domains:
|
||||
if same_origin(request.referrer, domain):
|
||||
is_referrer_allowed = True
|
||||
break
|
||||
try:
|
||||
if same_origin(request.referrer, domain):
|
||||
is_referrer_allowed = True
|
||||
break
|
||||
except ValueError:
|
||||
# The referrer is attacker-controlled and same_origin parses it
|
||||
# eagerly, so a malformed authority (e.g. a host that looks like
|
||||
# it carries a non-numeric port) raises rather than returning
|
||||
# False. Treat it as a non-match instead of a 500.
|
||||
continue
|
||||
|
||||
if not is_referrer_allowed:
|
||||
abort(403)
|
||||
@@ -91,7 +109,8 @@ class EmbeddedView(BaseSupersetView):
|
||||
login_user(AnonymousUserMixin(), force=True)
|
||||
|
||||
add_extra_log_payload(
|
||||
embedded_dashboard_id=uuid,
|
||||
embedded_id=uuid,
|
||||
resource_type=resource_type,
|
||||
dashboard_version="v2",
|
||||
)
|
||||
|
||||
@@ -101,7 +120,11 @@ class EmbeddedView(BaseSupersetView):
|
||||
},
|
||||
"common": common_bootstrap_payload(),
|
||||
"embedded": {
|
||||
"dashboard_id": embedded.dashboard_id,
|
||||
"resource_type": resource_type,
|
||||
"dashboard_id": (
|
||||
embedded.dashboard_id if resource_type == "dashboard" else None
|
||||
),
|
||||
"chart_id": embedded.slice_id if resource_type == "chart" else None,
|
||||
# The list of domains allowed to embed this dashboard. An empty
|
||||
# list means any domain is allowed (no restriction). The frontend
|
||||
# uses this to validate the origin of incoming postMessage events.
|
||||
@@ -112,8 +135,12 @@ class EmbeddedView(BaseSupersetView):
|
||||
return self.render_template(
|
||||
"superset/spa.html",
|
||||
entry="embedded",
|
||||
title=dashboard.dashboard_title,
|
||||
dashboard_description=dashboard.description,
|
||||
title=(
|
||||
resource.dashboard_title
|
||||
if resource_type == "dashboard"
|
||||
else resource.slice_name
|
||||
),
|
||||
dashboard_description=resource.description,
|
||||
bootstrap_data=json.dumps(
|
||||
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
|
||||
),
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# 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.
|
||||
"""add embedded_charts table
|
||||
|
||||
Revision ID: a1c7e4b62f18
|
||||
Revises: 39097d124752
|
||||
Create Date: 2026-09-01 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.migrations.shared.utils import create_table, drop_table
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a1c7e4b62f18"
|
||||
down_revision = "8f31c5d726ab"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
create_table(
|
||||
"embedded_charts",
|
||||
sa.Column("created_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("changed_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("uuid", UUIDType(binary=True), primary_key=True),
|
||||
sa.Column("allow_domain_list", sa.Text(), nullable=True),
|
||||
sa.Column("guest_token_revoked_before", sa.Integer(), nullable=True),
|
||||
sa.Column("slice_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["slice_id"], ["slices.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
|
||||
sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
drop_table("embedded_charts")
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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 uuid
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.models.helpers import AuditMixinNullable
|
||||
|
||||
|
||||
class EmbeddedChart(Model, AuditMixinNullable):
|
||||
"""
|
||||
A configuration of embedding for a chart.
|
||||
|
||||
References the chart, and contains a config for embedding that chart.
|
||||
Mirrors ``EmbeddedDashboard`` so both embeddable resource types share the
|
||||
same guest-token and allowed-domain semantics.
|
||||
|
||||
This data model allows multiple configurations for a given chart,
|
||||
but at this time the API only allows setting one.
|
||||
"""
|
||||
|
||||
__tablename__ = "embedded_charts"
|
||||
|
||||
uuid = Column(UUIDType(binary=True), default=uuid.uuid4, primary_key=True)
|
||||
allow_domain_list = Column(Text) # reference the `allowed_domains` property instead
|
||||
# Epoch seconds; guest tokens whose `iat` predates this are rejected. Set to
|
||||
# "now" to revoke all currently-issued guest tokens for this embedded
|
||||
# chart. NULL = no revocation.
|
||||
guest_token_revoked_before = Column(Integer, nullable=True)
|
||||
slice_id = Column(
|
||||
Integer,
|
||||
ForeignKey("slices.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
slice = relationship(
|
||||
"Slice",
|
||||
back_populates="embedded",
|
||||
foreign_keys=[slice_id],
|
||||
)
|
||||
|
||||
@property
|
||||
def allowed_domains(self) -> list[str]:
|
||||
"""
|
||||
A list of domains which are allowed to embed the chart.
|
||||
An empty list means any domain can embed.
|
||||
"""
|
||||
return self.allow_domain_list.split(",") if self.allow_domain_list else []
|
||||
@@ -155,6 +155,11 @@ class Slice( # pylint: disable=too-many-public-methods
|
||||
secondary=chart_viewers,
|
||||
passive_deletes=True,
|
||||
)
|
||||
embedded = relationship(
|
||||
"EmbeddedChart",
|
||||
back_populates="slice",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
tags = relationship(
|
||||
"Tag",
|
||||
|
||||
@@ -121,6 +121,7 @@ class GuestTokenUser(TypedDict, total=False):
|
||||
|
||||
class GuestTokenResourceType(StrEnum):
|
||||
DASHBOARD = "dashboard"
|
||||
CHART = "chart"
|
||||
|
||||
|
||||
class GuestTokenResource(TypedDict):
|
||||
|
||||
@@ -4642,6 +4642,33 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
|
||||
return self.is_viewer(viewer_slc) or self.is_editor(viewer_slc)
|
||||
|
||||
def has_embedded_chart_access() -> bool:
|
||||
# A chart embedded on its own has no parent dashboard, so the
|
||||
# dashboard leg below can never authorize it. Grant datasource
|
||||
# access when the guest token was issued for this very chart and
|
||||
# the request is for that chart's own datasource.
|
||||
# Resolve the guest user before touching the database: without
|
||||
# one the chart grant can never hold, so the lookup below would
|
||||
# be a query issued on every datasource check for nothing.
|
||||
if not (
|
||||
is_feature_enabled("EMBEDDED_SUPERSET")
|
||||
and self.get_current_guest_user_if_guest()
|
||||
and form_data
|
||||
and form_data.get("type") != "NATIVE_FILTER"
|
||||
and (embedded_slice_id := form_data.get("slice_id"))
|
||||
and (
|
||||
embedded_slc := self.session.query(Slice)
|
||||
.filter(Slice.id == embedded_slice_id)
|
||||
.one_or_none()
|
||||
)
|
||||
):
|
||||
return False
|
||||
|
||||
return (
|
||||
embedded_slc.datasource == datasource
|
||||
and self.has_guest_access_to_chart(embedded_slc)
|
||||
)
|
||||
|
||||
if not (
|
||||
self.can_access_schema(datasource)
|
||||
or self.can_access("datasource_access", datasource.perm or "")
|
||||
@@ -4748,6 +4775,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
# access if the user is a viewer or editor of the chart
|
||||
# and promiscuous mode is enabled.
|
||||
or has_promiscuous_chart_access()
|
||||
# Standalone embedded chart, authorized by its own guest token.
|
||||
or has_embedded_chart_access()
|
||||
):
|
||||
raise SupersetSecurityException(
|
||||
self.get_datasource_access_error_object(datasource)
|
||||
@@ -4816,8 +4845,12 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
if (
|
||||
is_feature_enabled("EMBEDDED_SUPERSET")
|
||||
and self.is_guest_user()
|
||||
and any(
|
||||
self.has_guest_access(dashboard_) for dashboard_ in chart.dashboards
|
||||
and (
|
||||
self.has_guest_access_to_chart(chart)
|
||||
or any(
|
||||
self.has_guest_access(dashboard_)
|
||||
for dashboard_ in chart.dashboards
|
||||
)
|
||||
)
|
||||
and self._guest_token_allows_dataset(
|
||||
chart.datasource.id if chart.datasource else None
|
||||
@@ -5150,6 +5183,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
from superset.commands.dashboard.embedded.exceptions import (
|
||||
EmbeddedDashboardNotFoundError,
|
||||
)
|
||||
from superset.daos.chart import EmbeddedChartDAO
|
||||
from superset.daos.dashboard import EmbeddedDashboardDAO
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
@@ -5165,6 +5199,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
# A raw dashboard id must still reference an embedded dashboard;
|
||||
# otherwise a guest token could be scoped to a non-embedded one.
|
||||
raise EmbeddedDashboardNotFoundError()
|
||||
elif resource["type"] == GuestTokenResourceType.CHART.value:
|
||||
# Charts are only ever addressed by the embedded uuid; there is
|
||||
# no legacy raw-id path to support.
|
||||
if not EmbeddedChartDAO.find_by_id(str(resource["id"])):
|
||||
raise EmbeddedDashboardNotFoundError()
|
||||
|
||||
def create_guest_access_token(
|
||||
self,
|
||||
@@ -5519,6 +5558,21 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
and datasource_id in allowed_datasets
|
||||
)
|
||||
|
||||
def has_guest_access_to_chart(self, chart: "Slice") -> bool:
|
||||
"""
|
||||
Whether the current guest token grants this chart directly, i.e. the
|
||||
chart is embedded on its own rather than through a dashboard.
|
||||
"""
|
||||
user = self.get_current_guest_user_if_guest()
|
||||
if not user or not chart.embedded:
|
||||
return False
|
||||
|
||||
embedded_uuid = str(chart.embedded[0].uuid)
|
||||
return any(
|
||||
r["type"] == GuestTokenResourceType.CHART and str(r["id"]) == embedded_uuid
|
||||
for r in user.resources
|
||||
)
|
||||
|
||||
def has_guest_access(self, dashboard: "Dashboard") -> bool:
|
||||
user = self.get_current_guest_user_if_guest()
|
||||
if not user:
|
||||
|
||||
@@ -320,6 +320,7 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
|
||||
"can_write",
|
||||
"can_export",
|
||||
"can_warm_up_cache",
|
||||
"can_set_embedded",
|
||||
}
|
||||
|
||||
def test_delete_chart(self):
|
||||
|
||||
@@ -60,6 +60,10 @@ def _sm_for_chart_access(is_guest: bool) -> MagicMock:
|
||||
sm.can_access_datasource.return_value = False
|
||||
sm.is_guest_user.return_value = is_guest
|
||||
sm._guest_token_allows_dataset.return_value = True
|
||||
# A chart embedded on its own is granted through its own token rather than
|
||||
# through a dashboard. Closed by default so each test opens exactly one
|
||||
# guest path and the assertion reflects that path alone.
|
||||
sm.has_guest_access_to_chart.return_value = False
|
||||
return sm
|
||||
|
||||
|
||||
@@ -90,6 +94,18 @@ def test_guest_cannot_access_chart_outside_granted_dashboards() -> None:
|
||||
SupersetSecurityManager.raise_for_access(sm, chart=chart)
|
||||
|
||||
|
||||
def test_guest_can_access_chart_granted_directly() -> None:
|
||||
"""A guest whose token was issued for the chart itself may access it, even
|
||||
when none of its dashboards are granted."""
|
||||
chart = _make_chart([MagicMock()])
|
||||
sm = _sm_for_chart_access(is_guest=True)
|
||||
sm.has_guest_access.return_value = False
|
||||
sm.has_guest_access_to_chart.return_value = True
|
||||
|
||||
with patch("superset.is_feature_enabled", return_value=True):
|
||||
SupersetSecurityManager.raise_for_access(sm, chart=chart) # no exception
|
||||
|
||||
|
||||
def test_guest_denied_member_chart_outside_dataset_allowlist() -> None:
|
||||
"""Even on a granted dashboard, a chart whose dataset the token's allowlist
|
||||
excludes stays inaccessible."""
|
||||
|
||||
Reference in New Issue
Block a user