({
method: 'POST',
endpoint,
})({
@@ -82,9 +98,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
err => {
console.error(err);
addDangerToast(
- t(
- t('Sorry, something went wrong. The changes could not be saved.'),
- ),
+ t('Sorry, something went wrong. The changes could not be saved.'),
);
},
)
@@ -126,13 +140,12 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
useEffect(() => {
setReady(false);
- makeApi<{}, { result: EmbeddedDashboard }>({
+ makeApi<{}, { result: EmbeddedResource }>({
method: 'GET',
endpoint,
})({})
.catch(err => {
if ((err as SupersetApiError).status === 404) {
- // 404 just means the dashboard isn't currently embedded
return { result: null };
}
addDangerToast(t('Sorry, something went wrong. Please try again.'));
@@ -143,7 +156,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
setEmbedded(result);
setAllowedDomains(result ? result.allowed_domains.join(', ') : '');
});
- }, [dashboardId]);
+ }, [resourceId, resourceType]);
if (!ready) {
return ;
@@ -166,18 +179,26 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
) : (
- {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:`,
+ )}
{embedded.uuid}
)
) : (
- {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.`,
+ )}
)}
@@ -194,7 +215,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
{t('Allowed Domains (comma separated)')}{' '}
@@ -239,17 +260,17 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
);
};
-const DashboardEmbedModal = (props: Props) => {
+const EmbedModal = (props: Props) => {
const { show, onHide } = props;
- const DashboardEmbedModalExtension = extensionsRegistry.get('embedded.modal');
+ const EmbedModalExtension = extensionsRegistry.get('embedded.modal');
- return DashboardEmbedModalExtension ? (
-
+ return EmbedModalExtension ? (
+
) : (
-
+
);
};
-export default DashboardEmbedModal;
+export default EmbedModal;
diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx
index cdc587cef036..37cdf91be6be 100644
--- a/superset-frontend/src/dashboard/components/Header/index.jsx
+++ b/superset-frontend/src/dashboard/components/Header/index.jsx
@@ -813,7 +813,8 @@ const Header = () => {
)}
(
const exploreUrl = `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${slice.slice_id}`;
+ const [showingEmbedModal, setShowingEmbedModal] = useState(false);
+
+ const showEmbedModal = useCallback(() => {
+ setShowingEmbedModal(true);
+ }, []);
+
+ const hideEmbedModal = useCallback(() => {
+ setShowingEmbedModal(false);
+ }, []);
+
+ // @ts-ignore
+ const user: User = useSelector(state => state.user);
+
+ const userCanCurate =
+ isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
+ findPermission('can_set_embedded', 'Dashboard', user.roles);
+
return (
@@ -265,6 +292,16 @@ const SliceHeader = forwardRef
(
{!uiConfig.hideChartControls && (
)}
+
+ {userCanCurate && (
+
+ )}
+
{!uiConfig.hideChartControls && (
(
formData={formData}
exploreUrl={exploreUrl}
crossFiltersEnabled={isCrossFiltersEnabled}
+ showEmbedModal={showEmbedModal}
/>
)}
>
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx
index 0ef74cf227ac..1a4367867ad8 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx
@@ -89,6 +89,7 @@ const createProps = (viz_type = VizType.Sunburst) =>
},
exploreUrl: '/explore',
defaultOpen: true,
+ showEmbedModal: () => {},
}) as SliceHeaderControlsProps;
const renderWrapper = (
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
index 83b163927895..9064e5aeba7f 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
@@ -56,6 +56,8 @@ import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import { usePermissions } from 'src/hooks/usePermissions';
import Button from 'src/components/Button';
+import { findPermission } from 'src/utils/findPermission';
+import { User } from 'src/dashboard/reducers/types';
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
@@ -144,6 +146,7 @@ export interface SliceHeaderControlsProps {
supersetCanCSV?: boolean;
crossFiltersEnabled?: boolean;
+ showEmbedModal: () => void;
}
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
RouteComponentProps;
@@ -172,6 +175,8 @@ const SliceHeaderControls = (
const [modalFilters, setFilters] = useState(
[],
);
+ // @ts-ignore
+ const user: User = useSelector(state => state.user);
const canEditCrossFilters =
useSelector(
@@ -281,6 +286,9 @@ const SliceHeaderControls = (
}
break;
}
+ case MenuKeys.ManageEmbedded:
+ props.showEmbedModal();
+ break;
default:
break;
}
@@ -335,6 +343,9 @@ const SliceHeaderControls = (
animationDuration: '0s',
};
+ const userCanCurate =
+ isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
+ findPermission('can_set_embedded', 'Dashboard', user.roles);
const menu = (
)}
+ {userCanCurate && (
+ {t('Embed Chart')}
+ )}
{props.supersetCanCSV && (
diff --git a/superset-frontend/src/dashboard/reducers/dashboardInfo.js b/superset-frontend/src/dashboard/reducers/dashboardInfo.js
index b0130f044eb0..b71098d6a5a1 100644
--- a/superset-frontend/src/dashboard/reducers/dashboardInfo.js
+++ b/superset-frontend/src/dashboard/reducers/dashboardInfo.js
@@ -17,6 +17,7 @@
* under the License.
*/
+import { HYDRATE_EMBEDDED } from 'src/embedded/embeddedChart/hydrateEmbedded';
import {
DASHBOARD_INFO_UPDATED,
SET_FILTER_BAR_ORIENTATION,
@@ -50,6 +51,10 @@ export default function dashboardStateReducer(state = {}, action) {
...action.data.dashboardInfo,
// set async api call data
};
+ case HYDRATE_EMBEDDED:
+ return {
+ ...action.data.dashboardInfo,
+ };
case SET_FILTER_BAR_ORIENTATION:
return {
...state,
diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js
index 3c7c65c60114..fb78e1f4f241 100644
--- a/superset-frontend/src/dashboard/reducers/dashboardState.js
+++ b/superset-frontend/src/dashboard/reducers/dashboardState.js
@@ -17,6 +17,7 @@
* under the License.
*/
/* eslint-disable camelcase */
+import { HYDRATE_EMBEDDED } from 'src/embedded/embeddedChart/hydrateEmbedded';
import {
ADD_SLICE,
ON_CHANGE,
@@ -58,6 +59,9 @@ export default function dashboardStateReducer(state = {}, action) {
[HYDRATE_DASHBOARD]() {
return { ...state, ...action.data.dashboardState };
},
+ [HYDRATE_EMBEDDED]() {
+ return { ...state, ...action.data.dashboardState };
+ },
[UPDATE_CSS]() {
return { ...state, css: action.css };
},
diff --git a/superset-frontend/src/dashboard/reducers/datasources.ts b/superset-frontend/src/dashboard/reducers/datasources.ts
index 386657455a93..0f6ecf5e4031 100644
--- a/superset-frontend/src/dashboard/reducers/datasources.ts
+++ b/superset-frontend/src/dashboard/reducers/datasources.ts
@@ -18,6 +18,7 @@
*/
import { keyBy } from 'lodash';
import { DatasourcesState } from 'src/dashboard/types';
+import { HYDRATE_EMBEDDED } from 'src/embedded/embeddedChart/hydrateEmbedded';
import {
DatasourcesActionPayload,
DatasourcesAction,
@@ -39,5 +40,10 @@ export default function datasourcesReducer(
[action.key]: action.datasource,
};
}
+ if (action.type === HYDRATE_EMBEDDED) {
+ return {
+ ...action.data.datasources,
+ };
+ }
return datasources || {};
}
diff --git a/superset-frontend/src/dashboard/reducers/sliceEntities.js b/superset-frontend/src/dashboard/reducers/sliceEntities.js
index 1a065b11fab0..15b60d99d004 100644
--- a/superset-frontend/src/dashboard/reducers/sliceEntities.js
+++ b/superset-frontend/src/dashboard/reducers/sliceEntities.js
@@ -18,6 +18,7 @@
*/
import { t } from '@superset-ui/core';
+import { HYDRATE_EMBEDDED } from 'src/embedded/embeddedChart/hydrateEmbedded';
import {
FETCH_ALL_SLICES_FAILED,
FETCH_ALL_SLICES_STARTED,
@@ -43,6 +44,11 @@ export default function sliceEntitiesReducer(
...action.data.sliceEntities,
};
},
+ [HYDRATE_EMBEDDED]() {
+ return {
+ ...action.data.sliceEntities,
+ };
+ },
[FETCH_ALL_SLICES_STARTED]() {
return {
...state,
diff --git a/superset-frontend/src/dataMask/reducer.ts b/superset-frontend/src/dataMask/reducer.ts
index bb5c3a5d1c30..f42d54c04230 100644
--- a/superset-frontend/src/dataMask/reducer.ts
+++ b/superset-frontend/src/dataMask/reducer.ts
@@ -34,6 +34,7 @@ import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/Fil
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
import { isEqual } from 'lodash';
+import { HYDRATE_EMBEDDED } from 'src/embedded/embeddedChart/hydrateEmbedded';
import {
AnyDataMaskAction,
CLEAR_DATA_MASK_STATE,
@@ -187,6 +188,10 @@ const dataMaskReducer = produce(
action.filters,
);
return cleanState;
+ // @ts-ignore
+ case HYDRATE_EMBEDDED:
+ // @ts-ignore
+ return action.data.dataMask;
default:
return draft;
}
diff --git a/superset-frontend/src/embedded/embeddedChart/hydrateEmbedded.ts b/superset-frontend/src/embedded/embeddedChart/hydrateEmbedded.ts
new file mode 100644
index 000000000000..25fc60324ae2
--- /dev/null
+++ b/superset-frontend/src/embedded/embeddedChart/hydrateEmbedded.ts
@@ -0,0 +1,165 @@
+/**
+ * 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 } from '@superset-ui/core';
+import { keyBy } from 'lodash';
+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';
+
+export const HYDRATE_EMBEDDED = 'HYDRATE_EMBEDDED';
+
+// Define proper interfaces for our objects
+interface ChartQueries {
+ [key: number]: {
+ id: number;
+ form_data: any;
+ };
+}
+
+interface Slices {
+ [key: number]: {
+ slice_id: number;
+ slice_url: string;
+ slice_name: string;
+ form_data: any;
+ viz_type: string;
+ datasource: any;
+ description: string;
+ description_markeddown: string;
+ owners: any[];
+ modified: string;
+ changed_on: number;
+ };
+}
+
+interface DataMask {
+ [key: number]: DataMaskWithId;
+}
+
+interface ExploreData {
+ slice: {
+ slice_id: number;
+ slice_url: string;
+ slice_name: string;
+ form_data: {
+ viz_type: string;
+ datasource: any;
+ [key: string]: any;
+ };
+ description: string;
+ description_markeddown: string;
+ owners: any[];
+ modified: string;
+ changed_on: string;
+ };
+ dataset: {
+ uid: string;
+ [key: string]: any;
+ };
+}
+
+export interface HydrateEmbeddedAction {
+ type: typeof HYDRATE_EMBEDDED;
+ data: {
+ charts: ChartQueries;
+ datasources: Record;
+ sliceEntities: {
+ slices: Slices;
+ };
+ dataMask: DataMask;
+ dashboardInfo: {
+ common: CommonBootstrapData;
+ superset_can_explore: boolean;
+ superset_can_share: boolean;
+ suerset_can_csv: boolean;
+ crossFiltersEnabled: boolean;
+ };
+ dashboardState: {
+ expandedSlices: {
+ [sliceId: number]: boolean;
+ };
+ };
+ };
+}
+
+const hydrateEmbedded = (
+ exploreData: ExploreData,
+ common: CommonBootstrapData,
+): HydrateEmbeddedAction => {
+ const chartQueries: ChartQueries = {};
+ const slices: Slices = {};
+ const { slice } = exploreData;
+ const key = slice.slice_id;
+ const formData = {
+ ...slice.form_data,
+ };
+ const cleanStateForDataMask: DataMask = {};
+
+ cleanStateForDataMask[key] = {
+ ...(getInitialDataMask(key) as DataMaskWithId),
+ };
+
+ chartQueries[key] = {
+ ...chart,
+ id: key,
+ form_data: applyDefaultFormData(formData),
+ };
+
+ 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,
+ owners: slice.owners,
+ modified: slice.modified,
+ changed_on: new Date(slice.changed_on).getTime(),
+ };
+
+ const datasourceObj = [exploreData.dataset];
+ const modifiedDs = keyBy(datasourceObj, 'uid');
+
+ return {
+ type: HYDRATE_EMBEDDED,
+ data: {
+ charts: chartQueries,
+ datasources: modifiedDs,
+ sliceEntities: { slices },
+ dataMask: cleanStateForDataMask,
+ dashboardInfo: {
+ common,
+ superset_can_explore: true,
+ superset_can_share: false,
+ suerset_can_csv: true,
+ crossFiltersEnabled: false,
+ },
+ dashboardState: {
+ expandedSlices: {
+ [key]: false,
+ },
+ },
+ },
+ };
+};
+
+export default hydrateEmbedded;
diff --git a/superset-frontend/src/embedded/embeddedChart/index.tsx b/superset-frontend/src/embedded/embeddedChart/index.tsx
new file mode 100644
index 000000000000..4757ef0c1b0b
--- /dev/null
+++ b/superset-frontend/src/embedded/embeddedChart/index.tsx
@@ -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.
+ */
+/* eslint-disable theme-colors/no-literal-colors */
+/* eslint-disable consistent-return */
+import { useEffect, useRef, useState } from 'react';
+import { styled } from '@superset-ui/core';
+import Chart from 'src/dashboard/components/gridComponents/Chart';
+import Loading from 'src/components/Loading';
+import { store } from 'src/views/store';
+import getBootstrapData from 'src/utils/getBootstrapData';
+import useExploreData from './useExploreData';
+import hydrateEmbedded from './hydrateEmbedded';
+
+// Styled Components
+const Container = styled.div`
+ width: 100%;
+ min-height: 400px;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #fff;
+ border-radius: 4px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
+`;
+
+const LoadingContainer = styled.div`
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+interface Dimensions {
+ width: number;
+ height: number;
+}
+
+function EmbeddedChart({ sliceId }: { sliceId: number }) {
+ const [isHydrated, setIsHydrated] = useState(false);
+ const [isDimensionsSet, setIsDimensionsSet] = useState(false);
+ const containerRef = useRef(null);
+ const [dimensions, setDimensions] = useState(null);
+ const { common } = getBootstrapData();
+
+ // Get app element dimensions
+ useEffect(() => {
+ if (!isHydrated) return;
+
+ const appElement = document.getElementById('app');
+ if (!appElement) {
+ console.warn('App element not found');
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver(entries => {
+ const entry = entries[0];
+ if (entry) {
+ const { width, height } = entry.contentRect;
+ const newWidth = Math.floor(width);
+ const newHeight = Math.floor(height);
+
+ if (!dimensions) {
+ // Initial dimensions set
+ setDimensions({
+ width: newWidth,
+ height: newHeight,
+ });
+ setIsDimensionsSet(true);
+ return;
+ }
+
+ const widthDiff = Math.abs(newWidth - dimensions.width);
+ const heightDiff = Math.abs(newHeight - dimensions.height);
+
+ // Only update if either dimension has changed by more than 3px
+ if (widthDiff > 3 || heightDiff > 3) {
+ setDimensions({
+ width: newWidth,
+ height: newHeight,
+ });
+ }
+ }
+ });
+
+ resizeObserver.observe(appElement);
+
+ return () => {
+ resizeObserver.unobserve(appElement);
+ resizeObserver.disconnect();
+ };
+ }, [isHydrated, dimensions]);
+
+ const { data: exploreData, isLoading, error } = useExploreData(sliceId);
+
+ useEffect(() => {
+ if (exploreData?.result) {
+ try {
+ // store must be immediately hydrated with
+ // necessary data for initial render
+ store.dispatch(hydrateEmbedded(exploreData.result, common));
+ setIsHydrated(true);
+ } catch (err) {
+ console.error('Error dispatching hydrate action:', err);
+ }
+ }
+ }, [exploreData]);
+
+ if (isLoading || !isHydrated || !isDimensionsSet || !dimensions) {
+ return ;
+ }
+
+ if (error) {
+ return Error: {error};
+ }
+
+ // @ts-ignore
+ const chartId = exploreData?.result?.slice?.slice_id;
+ // @ts-ignore
+ const chartName = exploreData?.result?.slice?.slice_name || '';
+
+ if (!chartId) {
+ return Invalid chart data;
+ }
+
+ return (
+
+ {}}
+ isComponentVisible
+ handleToggleFullSize={() => {}}
+ setControlValue={() => {}}
+ extraControls={{}}
+ isInView
+ />
+
+ );
+}
+
+export default EmbeddedChart;
diff --git a/superset-frontend/src/embedded/embeddedChart/useExploreData.ts b/superset-frontend/src/embedded/embeddedChart/useExploreData.ts
new file mode 100644
index 000000000000..46c1f7339c90
--- /dev/null
+++ b/superset-frontend/src/embedded/embeddedChart/useExploreData.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 { useState, useEffect } from 'react';
+import { makeApi, t } from '@superset-ui/core';
+
+export interface ExploreResponse {
+ result: {
+ slice: {
+ slice_id: number;
+ slice_url: string;
+ slice_name: string;
+ form_data: {
+ viz_type: string;
+ datasource: any;
+ [key: string]: any;
+ };
+ description: string;
+ description_markeddown: string;
+ owners: any[];
+ modified: string;
+ changed_on: string;
+ };
+ dataset: {
+ uid: string;
+ [key: string]: any;
+ };
+ };
+}
+
+const useExploreData = (sliceId: number) => {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const response = await makeApi<{}, ExploreResponse>({
+ method: 'GET',
+ endpoint: 'api/v1/explore/',
+ })(new URLSearchParams({ slice_id: String(sliceId) }));
+
+ setData(response);
+ } catch (err) {
+ setError(t('Failed to load explore data'));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [sliceId]);
+
+ return { data, isLoading, error };
+};
+
+export default useExploreData;
diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx
index 1932ac005d30..df7c7046e5cd 100644
--- a/superset-frontend/src/embedded/index.tsx
+++ b/superset-frontend/src/embedded/index.tsx
@@ -55,6 +55,13 @@ const LazyDashboardPage = lazy(
),
);
+const LazyChartPage = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "ChartPage" */ 'src/embedded/embeddedChart/index'
+ ),
+);
+
const EmbededLazyDashboardPage = () => {
const uiConfig = useUiConfig();
@@ -78,7 +85,9 @@ const EmbededLazyDashboardPage = () => {
}
});
}
-
+ if (bootstrapData.embedded!.resource_type === 'chart') {
+ return ;
+ }
return ;
};
diff --git a/superset-frontend/src/explore/reducers/datasourcesReducer.ts b/superset-frontend/src/explore/reducers/datasourcesReducer.ts
index 50393dbd2450..6019723210bc 100644
--- a/superset-frontend/src/explore/reducers/datasourcesReducer.ts
+++ b/superset-frontend/src/explore/reducers/datasourcesReducer.ts
@@ -18,6 +18,7 @@
*/
import { Dataset } from '@superset-ui/chart-controls';
import { getDatasourceUid } from 'src/utils/getDatasourceUid';
+import { HYDRATE_EMBEDDED } from 'src/embedded/embeddedChart/hydrateEmbedded';
import {
AnyDatasourcesAction,
SET_DATASOURCE,
@@ -38,5 +39,9 @@ export default function datasourcesReducer(
if (action.type === HYDRATE_EXPLORE) {
return { ...(action as HydrateExplore).data.datasources };
}
+ if (action.type === HYDRATE_EMBEDDED) {
+ // @ts-ignore
+ return { ...action.data.datasources };
+ }
return datasources || {};
}
diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts
index ff46f6ab8ce1..e24bc049cd3c 100644
--- a/superset-frontend/src/types/bootstrapTypes.ts
+++ b/superset-frontend/src/types/bootstrapTypes.ts
@@ -164,6 +164,8 @@ export interface BootstrapData {
config?: any;
embedded?: {
dashboard_id: string;
+ chart_id: string;
+ resource_type: 'dashboard' | 'chart';
};
requested_query?: JsonObject;
}
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 68b6f2cb0c90..54d5d16b66bb 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -15,13 +15,15 @@
# specific language governing permissions and limitations
# under the License.
# pylint: disable=too-many-lines
+import functools
import logging
from datetime import datetime
from io import BytesIO
-from typing import Any, cast, Optional
+from typing import Any, Callable, cast, Optional
from zipfile import is_zipfile, ZipFile
from flask import redirect, request, Response, send_file, url_for
+from flask_appbuilder import permission_name
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.hooks import before_request
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -30,7 +32,7 @@
from werkzeug.wrappers import Response as WerkzeugResponse
from werkzeug.wsgi import FileWrapper
-from superset import app, is_feature_enabled
+from superset import app, db, is_feature_enabled
from superset.charts.filters import (
ChartAllTextFilter,
ChartCertifiedFilter,
@@ -47,6 +49,8 @@
ChartCacheWarmUpRequestSchema,
ChartPostSchema,
ChartPutSchema,
+ EmbeddedChartConfigSchema,
+ EmbeddedChartResponseSchema,
get_delete_ids_schema,
get_export_ids_schema,
get_fav_star_ids_schema,
@@ -55,7 +59,10 @@
thumbnail_query_schema,
)
from superset.commands.chart.create import CreateChartCommand
-from superset.commands.chart.delete import DeleteChartCommand
+from superset.commands.chart.delete import (
+ DeleteChartCommand,
+ DeleteEmbeddedChartCommand,
+)
from superset.commands.chart.exceptions import (
ChartCreateFailedError,
ChartDeleteFailedError,
@@ -78,8 +85,9 @@
)
from superset.commands.importers.v1.utils import get_contents_from_bundle
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.extensions import event_logger
+from superset.models.embedded_chart import EmbeddedChart
from superset.models.slice import Slice
from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.tasks.utils import get_current_user
@@ -104,6 +112,29 @@
config = app.config
+def with_chart(
+ f: Callable[[BaseSupersetModelRestApi, Slice], Response],
+) -> Callable[[BaseSupersetModelRestApi, str], Response]:
+ """
+ A decorator that looks up the chart by id and passes it to the api.
+ Route must include an parameter.
+ Responds with 403 or 404 without calling the route, if necessary.
+ """
+
+ def wraps(self: BaseSupersetModelRestApi, pk: str) -> Response:
+ try:
+ chart = ChartDAO.find_by_id(pk)
+ if not chart:
+ return self.response_404()
+ return f(self, chart)
+ except ChartForbiddenError:
+ return self.response_403()
+ except ValueError:
+ return self.response_404()
+
+ return functools.update_wrapper(wraps, f)
+
+
class ChartRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(Slice)
@@ -129,6 +160,9 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]:
"screenshot",
"cache_screenshot",
"warm_up_cache",
+ "get_embedded",
+ "set_embedded",
+ "delete_embedded",
}
class_permission_name = "Chart"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -255,6 +289,8 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]:
add_model_schema = ChartPostSchema()
edit_model_schema = ChartPutSchema()
+ embedded_config_schema = EmbeddedChartConfigSchema()
+ embedded_response_schema = EmbeddedChartResponseSchema()
openapi_spec_tag = "Charts"
""" Override the name set for this collection of endpoints """
@@ -1151,3 +1187,171 @@ def import_(self) -> Response:
)
command.run()
return self.response(200, message="OK")
+
+ @expose("//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,
+ )
+ @with_chart
+ def get_embedded(self, chart: Slice) -> Response:
+ """Get the chart's embedded configuration.
+ ---
+ get:
+ summary: Get the chart's embedded configuration
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: id
+ description: The chart ID or slug
+ 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'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ if not chart.embedded:
+ return self.response(404)
+ embedded = chart.embedded[0]
+ result: EmbeddedChart = self.embedded_response_schema.dump(embedded)
+ return self.response(200, result=result)
+
+ @expose("//embedded", methods=["POST", "PUT"])
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.set_embedded",
+ log_to_statsd=False,
+ )
+ @with_chart
+ def set_embedded(self, chart: Slice) -> Response:
+ """Set a chart's embedded configuration.
+ ---
+ post:
+ summary: Set a chart's embedded configuration
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: id_or_slug
+ description: The chart id or slug
+ 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'
+ 500:
+ $ref: '#/components/responses/500'
+ put:
+ description: >-
+ Sets a chart's embedded configuration.
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: id_or_slug
+ 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'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ 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("//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,
+ )
+ @with_chart
+ def delete_embedded(self, chart: Slice) -> Response:
+ """Delete a chart's embedded configuration.
+ ---
+ delete:
+ summary: Delete a chart's embedded configuration
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: id
+ 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'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ DeleteEmbeddedChartCommand(chart).run()
+ return self.response(200, message="OK")
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index 7d004431b0b2..f87d1f434a3b 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -26,6 +26,7 @@
from superset import app
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
+from superset.dashboards.schemas import UserSchema
from superset.db_engine_specs.base import builtin_time_grains
from superset.utils import pandas_postprocessing, schema as utils
from superset.utils.core import (
@@ -1603,6 +1604,18 @@ class ChartCacheWarmUpResponseSchema(Schema):
)
+class EmbeddedChartConfigSchema(Schema):
+ allowed_domains = fields.List(fields.String())
+
+
+class EmbeddedChartResponseSchema(Schema):
+ uuid = fields.String()
+ allowed_domains = fields.List(fields.String())
+ dashboard_id = fields.String()
+ changed_on = fields.DateTime()
+ changed_by = fields.Nested(UserSchema)
+
+
CHART_SCHEMAS = (
ChartCacheWarmUpRequestSchema,
ChartCacheWarmUpResponseSchema,
@@ -1628,4 +1641,6 @@ class ChartCacheWarmUpResponseSchema(Schema):
ChartGetDatasourceResponseSchema,
ChartCacheScreenshotResponseSchema,
GetFavStarIdsSchema,
+ EmbeddedChartConfigSchema,
+ EmbeddedChartResponseSchema,
)
diff --git a/superset/commands/chart/delete.py b/superset/commands/chart/delete.py
index 00e6d201bcc9..23598f12b33f 100644
--- a/superset/commands/chart/delete.py
+++ b/superset/commands/chart/delete.py
@@ -23,12 +23,13 @@
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.chart.exceptions import (
+ ChartDeleteEmbeddedFailedError,
ChartDeleteFailedError,
ChartDeleteFailedReportsExistError,
ChartForbiddenError,
ChartNotFoundError,
)
-from superset.daos.chart import ChartDAO
+from superset.daos.chart import ChartDAO, EmbeddedChartDAO
from superset.daos.report import ReportScheduleDAO
from superset.exceptions import SupersetSecurityException
from superset.models.slice import Slice
@@ -37,6 +38,19 @@
logger = logging.getLogger(__name__)
+class DeleteEmbeddedChartCommand(BaseCommand):
+ def __init__(self, chart: Slice):
+ self._chart = chart
+
+ @transaction(on_error=partial(on_error, reraise=ChartDeleteEmbeddedFailedError))
+ def run(self) -> None:
+ self.validate()
+ return EmbeddedChartDAO.delete(self._chart.embedded)
+
+ def validate(self) -> None:
+ pass
+
+
class DeleteChartCommand(BaseCommand):
def __init__(self, model_ids: list[int]):
self._model_ids = model_ids
diff --git a/superset/commands/chart/exceptions.py b/superset/commands/chart/exceptions.py
index 72ef71f466e8..d2e753978c9b 100644
--- a/superset/commands/chart/exceptions.py
+++ b/superset/commands/chart/exceptions.py
@@ -123,6 +123,10 @@ class ChartDeleteFailedError(DeleteFailedError):
message = _("Charts could not be deleted.")
+class ChartDeleteEmbeddedFailedError(DeleteFailedError):
+ message = _("Embedded chart could not be deleted.")
+
+
class ChartDeleteFailedReportsExistError(ChartDeleteFailedError):
message = _("There are associated alerts or reports")
diff --git a/superset/config.py b/superset/config.py
index a01c380a7e98..b328ea8aeef3 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -491,7 +491,7 @@ class D3TimeFormat(TypedDict, total=False):
"DASHBOARD_VIRTUALIZATION": True,
# This feature flag is stil in beta and is not recommended for production use.
"GLOBAL_ASYNC_QUERIES": False,
- "EMBEDDED_SUPERSET": False,
+ "EMBEDDED_SUPERSET": True,
# Enables Alerts and reports new implementation
"ALERT_REPORTS": False,
"ALERT_REPORT_TABS": False,
diff --git a/superset/daos/chart.py b/superset/daos/chart.py
index 35afb7f7a91b..6f455e0b4e1d 100644
--- a/superset/daos/chart.py
+++ b/superset/daos/chart.py
@@ -24,6 +24,7 @@
from superset.daos.base import BaseDAO
from superset.extensions import db
from superset.models.core import FavStar, FavStarClassName
+from superset.models.embedded_chart import EmbeddedChart
from superset.models.slice import Slice
from superset.utils.core import get_user_id
@@ -76,3 +77,20 @@ def remove_favorite(chart: Slice) -> None:
)
if fav:
db.session.delete(fav)
+
+
+class EmbeddedChartDAO(BaseDAO[EmbeddedChart]):
+ 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
diff --git a/superset/embedded/view.py b/superset/embedded/view.py
index 6e5419286d38..b57d724b7b23 100644
--- a/superset/embedded/view.py
+++ b/superset/embedded/view.py
@@ -22,6 +22,7 @@
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
@@ -41,21 +42,33 @@ def embedded(
add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
) -> FlaskResponse:
"""
- Server side rendering for the embedded dashboard page
- :param uuid: identifier for embedded dashboard
- :param add_extra_log_payload: added by `log_this_with_manual_updates`, set a
- default value to appease pylint
+ Server side rendering for embedded dashboard/chart page
+ :param uuid: identifier for embedded dashboard or chart
"""
if not is_feature_enabled("EMBEDDED_SUPERSET"):
abort(404)
+ # Try to find in dashboard first
embedded = EmbeddedDashboardDAO.find_by_id(uuid)
+ resource_type = "dashboard"
+ resource_id = None
+
+ # If not found in dashboard, try chart
+ if not embedded:
+ embedded = EmbeddedChartDAO.find_by_id(uuid)
+ resource_type = "chart"
if not embedded:
abort(404)
assert embedded is not None
+ # Set resource ID based on type
+ if resource_type == "dashboard":
+ resource_id = embedded.dashboard_id
+ else:
+ resource_id = embedded.slice_id
+
# validate request referrer in allowed domains
is_referrer_allowed = not embedded.allowed_domains
for domain in embedded.allowed_domains:
@@ -66,14 +79,13 @@ def embedded(
if not is_referrer_allowed:
abort(403)
- # Log in as an anonymous user, just for this view.
- # This view needs to be visible to all users,
- # and building the page fails if g.user and/or ctx.user aren't present.
+ # Log in as anonymous user
login_user(AnonymousUserMixin(), force=True)
add_extra_log_payload(
- embedded_dashboard_id=uuid,
- dashboard_version="v2",
+ embedded_id=uuid,
+ resource_type=resource_type,
+ resource_id=resource_id,
)
bootstrap_data = {
@@ -82,7 +94,9 @@ def embedded(
},
"common": common_bootstrap_payload(),
"embedded": {
- "dashboard_id": embedded.dashboard_id,
+ "dashboard_id": resource_id if resource_type == "dashboard" else None,
+ "chart_id": resource_id if resource_type == "chart" else None,
+ "resource_type": resource_type,
},
}
diff --git a/superset/migrations/versions/2025-05-14_03-40_67dd9e3429b2_add_embedded_charts_table.py b/superset/migrations/versions/2025-05-14_03-40_67dd9e3429b2_add_embedded_charts_table.py
new file mode 100644
index 000000000000..519f6efe25c4
--- /dev/null
+++ b/superset/migrations/versions/2025-05-14_03-40_67dd9e3429b2_add_embedded_charts_table.py
@@ -0,0 +1,54 @@
+# 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: 67dd9e3429b2
+Revises: f1edd4a4d4f2
+Create Date: 2025-05-14 03:40:13.406669
+
+"""
+
+import sqlalchemy as sa
+import sqlalchemy_utils
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "67dd9e3429b2"
+down_revision = "f1edd4a4d4f2"
+
+
+def upgrade():
+ op.create_table(
+ "embedded_charts",
+ sa.Column("created_on", sa.DateTime(), nullable=True),
+ sa.Column("changed_on", sa.DateTime(), nullable=True),
+ sa.Column(
+ "uuid", sqlalchemy_utils.types.uuid.UUIDType(binary=True), nullable=False
+ ),
+ sa.Column("allow_domain_list", sa.Text(), nullable=True),
+ sa.Column("slice_id", sa.Integer(), nullable=False),
+ sa.Column("created_by_fk", sa.Integer(), nullable=True),
+ sa.Column("changed_by_fk", sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
+ sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
+ sa.ForeignKeyConstraint(["slice_id"], ["slices.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("uuid"),
+ )
+
+
+def downgrade():
+ op.drop_table("embedded_charts")
diff --git a/superset/models/embedded_chart.py b/superset/models/embedded_chart.py
new file mode 100644
index 000000000000..32e632b7462b
--- /dev/null
+++ b/superset/models/embedded_chart.py
@@ -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.
+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/slice.
+ References the chart/slice, and contains a config for embedding that chart.
+ """
+
+ __tablename__ = "embedded_charts"
+
+ uuid = Column(UUIDType(binary=True), default=uuid.uuid4, primary_key=True)
+ allow_domain_list = Column(Text)
+ slice_id = Column(
+ Integer,
+ ForeignKey("slices.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ slice = relationship(
+ "Slice",
+ 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 []
diff --git a/superset/models/slice.py b/superset/models/slice.py
index 2469db90ad06..45fd2a50d6ad 100644
--- a/superset/models/slice.py
+++ b/superset/models/slice.py
@@ -40,6 +40,7 @@
from superset import db, is_feature_enabled, security_manager
from superset.legacy import update_time_range
+from superset.models.embedded_chart import EmbeddedChart
from superset.models.helpers import AuditMixinNullable, ImportExportMixin
from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.tasks.utils import get_current_user
@@ -118,6 +119,11 @@ class Slice( # pylint: disable=too-many-public-methods
remote_side="SqlaTable.id",
lazy="subquery",
)
+ embedded = relationship(
+ EmbeddedChart,
+ back_populates="slice",
+ cascade="all, delete-orphan",
+ )
token = ""