diff --git a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts index 6583bee73284..874829039431 100644 --- a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts @@ -159,12 +159,14 @@ export interface SliceHeaderExtension { dashboardId: number; } +type ResourceType = 'dashboard' | 'chart'; + /** * Interface for extensions to Embed Modal */ export interface DashboardEmbedModalExtensions { - dashboardId: string; - show: boolean; + resourceId: string; + resourceType: ResourceType; onHide: () => void; } diff --git a/superset-frontend/src/components/Chart/chartReducer.ts b/superset-frontend/src/components/Chart/chartReducer.ts index 997c5bf7b7c3..5377b06bf44f 100644 --- a/superset-frontend/src/components/Chart/chartReducer.ts +++ b/superset-frontend/src/components/Chart/chartReducer.ts @@ -25,6 +25,7 @@ import { ChartState } from 'src/explore/types'; import { getFormDataFromControls } from 'src/explore/controlUtils'; import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore'; import { now } from 'src/utils/dates'; +import { HYDRATE_EMBEDDED } from 'src/embedded/embeddedChart/hydrateEmbedded'; import * as actions from './chartAction'; export const chart: ChartState = { @@ -192,7 +193,11 @@ export default function chartReducer( delete charts[key]; return charts; } - if (action.type === HYDRATE_DASHBOARD || action.type === HYDRATE_EXPLORE) { + if ( + action.type === HYDRATE_DASHBOARD || + action.type === HYDRATE_EXPLORE || + action.type === HYDRATE_EMBEDDED + ) { return { ...action.data.charts }; } if (action.type === DatasourcesAction.SetDatasources) { diff --git a/superset-frontend/src/dashboard/actions/datasources.ts b/superset-frontend/src/dashboard/actions/datasources.ts index f48c12428459..3b4c3f5f6b41 100644 --- a/superset-frontend/src/dashboard/actions/datasources.ts +++ b/superset-frontend/src/dashboard/actions/datasources.ts @@ -19,6 +19,7 @@ import { Dispatch } from 'redux'; import { SupersetClient } from '@superset-ui/core'; import { Datasource, RootState } from 'src/dashboard/types'; +import { HydrateEmbeddedAction } from 'src/embedded/embeddedChart/hydrateEmbedded'; // update datasources index for Dashboard export enum DatasourcesAction { @@ -35,7 +36,8 @@ export type DatasourcesActionPayload = type: DatasourcesAction.SetDatasource; key: Datasource['uid']; datasource: Datasource; - }; + } + | HydrateEmbeddedAction; export function setDatasources(datasources: Datasource[] | null) { return { diff --git a/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx b/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx index 27e570e88685..d74c923122d5 100644 --- a/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx +++ b/superset-frontend/src/dashboard/components/EmbeddedModal/EmbeddedModal.test.tsx @@ -57,7 +57,15 @@ const setMockApiNotFound = () => { }; const setup = () => { - render(, { useRedux: true }); + render( + , + { useRedux: true }, + ); resetMockApi(); }; @@ -79,7 +87,15 @@ test('renders loading state', async () => { }); test('renders the modal default content', async () => { - render(, { useRedux: true }); + render( + , + { useRedux: true }, + ); expect(await screen.findByText('Settings')).toBeInTheDocument(); expect( screen.getByText(new RegExp(/Allowed Domains/, 'i')), diff --git a/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx b/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx index ff8cf42886c1..9ba5438428ae 100644 --- a/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx +++ b/superset-frontend/src/dashboard/components/EmbeddedModal/index.tsx @@ -31,18 +31,30 @@ import Button from 'src/components/Button'; import { Input } from 'src/components/Input'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { FormItem } from 'src/components/Form'; -import { EmbeddedDashboard } from 'src/dashboard/types'; const extensionsRegistry = getExtensionsRegistry(); +type ResourceType = 'dashboard' | 'chart'; + type Props = { - dashboardId: string; + resourceId: string; + resourceType: ResourceType; show: boolean; onHide: () => void; }; type EmbeddedApiPayload = { allowed_domains: string[] }; +type EmbeddedResource = { + uuid: string; + allowed_domains: string[]; + changed_on: string; + changed_by: { + first_name: string; + last_name: string; + }; +}; + const stringToList = (stringyList: string): string[] => stringyList.split(/(?:\s|,)+/).filter(x => x); @@ -52,22 +64,26 @@ const ButtonRow = styled.div` justify-content: flex-end; `; -export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => { +export const ResourceEmbedControls = ({ + resourceId, + resourceType, + 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 - const [embedded, setEmbedded] = useState(null); // the embedded dashboard config + const [ready, setReady] = useState(true); + const [loading, setLoading] = useState(false); + const [embedded, setEmbedded] = useState(null); const [allowedDomains, setAllowedDomains] = useState(''); - const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`; - // whether saveable changes have been made to the config + const endpoint = `/api/v1/${resourceType}/${resourceId}/embedded`; + const isDirty = !embedded || stringToList(allowedDomains).join() !== embedded.allowed_domains.join(); const enableEmbedded = useCallback(() => { setLoading(true); - makeApi({ + makeApi({ 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 = ""