Skip to content

Commit

Permalink
feat: Embedded dashboard configuration (#19364)
Browse files Browse the repository at this point in the history
* embedded dashboard model

* embedded dashboard endpoints

* DRY up using the with_dashboard decorator elsewhere

* wip

* check feature flags and permissions

* wip

* sdk

* urls

* dao option for id column

* got it working

* Update superset/embedded/view.py

* use the curator check

* put back old endpoint, for now

* allow access by either embedded.uuid or dashboard.id

* keep the old endpoint around, for the time being

* openapi

* lint

* lint

* lint

* test stuff

* lint, test

* typo

* Update superset-frontend/src/embedded/index.tsx

* Update superset-frontend/src/embedded/index.tsx

* fix tests

* bump sdk
  • Loading branch information
suddjian committed Mar 30, 2022
1 parent a4c261d commit 8e29ec5
Show file tree
Hide file tree
Showing 33 changed files with 1,015 additions and 120 deletions.
2 changes: 1 addition & 1 deletion superset-embedded-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@superset-ui/embedded-sdk",
"version": "0.1.0-alpha.6",
"version": "0.1.0-alpha.7",
"description": "SDK for embedding resources from Superset into your own application",
"access": "public",
"keywords": [
Expand Down
2 changes: 1 addition & 1 deletion superset-embedded-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export async function embedDashboard({
resolve(new Switchboard({ port: ourPort, name: 'superset-embedded-sdk', debug }));
});

iframe.src = `${supersetDomain}/dashboard/${id}/embedded${dashboardConfig}`;
iframe.src = `${supersetDomain}/embedded/${id}${dashboardConfig}`;
mountPoint.replaceChildren(iframe);
log('placed the iframe')
});
Expand Down
228 changes: 228 additions & 0 deletions superset-frontend/src/dashboard/components/DashboardEmbedControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* 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 React, { useCallback, useEffect, useState } from 'react';
import { makeApi, styled, SupersetApiError, t } from '@superset-ui/core';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import Modal from 'src/components/Modal';
import Loading from 'src/components/Loading';
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 '../types';

type Props = {
dashboardId: string;
show: boolean;
onHide: () => void;
};

type EmbeddedApiPayload = { allowed_domains: string[] };

const stringToList = (stringyList: string): string[] =>
stringyList.split(/(?:\s|,)+/).filter(x => x);

const ButtonRow = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
`;

export const DashboardEmbedControls = ({ dashboardId, 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<EmbeddedDashboard | null>(null); // the embedded dashboard config
const [allowedDomains, setAllowedDomains] = useState<string>('');

const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`;
// whether saveable changes have been made to the config
const isDirty =
!embedded ||
stringToList(allowedDomains).join() !== embedded.allowed_domains.join();

const enableEmbedded = useCallback(() => {
setLoading(true);
makeApi<EmbeddedApiPayload, { result: EmbeddedDashboard }>({
method: 'POST',
endpoint,
})({
allowed_domains: stringToList(allowedDomains),
})
.then(
({ result }) => {
setEmbedded(result);
setAllowedDomains(result.allowed_domains.join(', '));
addInfoToast(t('Changes saved.'));
},
err => {
console.error(err);
addDangerToast(
t(
t('Sorry, something went wrong. The changes could not be saved.'),
),
);
},
)
.finally(() => {
setLoading(false);
});
}, [endpoint, allowedDomains]);

const disableEmbedded = useCallback(() => {
Modal.confirm({
title: t('Disable embedding?'),
content: t('This will remove your current embed configuration.'),
okType: 'danger',
onOk: () => {
setLoading(true);
makeApi<{}>({ method: 'DELETE', endpoint })({})
.then(
() => {
setEmbedded(null);
setAllowedDomains('');
addInfoToast(t('Embedding deactivated.'));
onHide();
},
err => {
console.error(err);
addDangerToast(
t(
'Sorry, something went wrong. Embedding could not be deactivated.',
),
);
},
)
.finally(() => {
setLoading(false);
});
},
});
}, [endpoint]);

useEffect(() => {
setReady(false);
makeApi<{}, { result: EmbeddedDashboard }>({
method: 'GET',
endpoint,
})({})
.catch(err => {
if ((err as SupersetApiError).status === 404) {
// 404 just means the dashboard isn't currently embedded
return { result: null };
}
throw err;
})
.then(({ result }) => {
setReady(true);
setEmbedded(result);
setAllowedDomains(result ? result.allowed_domains.join(', ') : '');
});
}, [dashboardId]);

if (!ready) {
return <Loading />;
}

return (
<>
<p>
{embedded ? (
<>
{t(
'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
)}
<br />
<code>{embedded.uuid}</code>
</>
) : (
t(
'Configure this dashboard to embed it into an external web application.',
)
)}
</p>
<p>
{t('For further instructions, consult the')}{' '}
<a
href="https://www.npmjs.com/package/@superset-ui/embedded-sdk"
target="_blank"
rel="noreferrer"
>
{t('Superset Embedded SDK documentation.')}
</a>
</p>
<h3>Settings</h3>
<FormItem>
<label htmlFor="allowed-domains">
{t('Allowed Domains (comma separated)')}{' '}
<InfoTooltipWithTrigger
tooltip={t(
'A list of domain names that can embed this dashboard. Leaving this field empty will allow embedding from any domain.',
)}
/>
</label>
<Input
name="allowed-domains"
value={allowedDomains}
placeholder="superset.example.com"
onChange={event => setAllowedDomains(event.target.value)}
/>
</FormItem>
<ButtonRow>
{embedded ? (
<>
<Button
onClick={disableEmbedded}
buttonStyle="secondary"
loading={loading}
>
{t('Deactivate')}
</Button>
<Button
onClick={enableEmbedded}
buttonStyle="primary"
disabled={!isDirty}
loading={loading}
>
{t('Save changes')}
</Button>
</>
) : (
<Button
onClick={enableEmbedded}
buttonStyle="primary"
loading={loading}
>
{t('Enable embedding')}
</Button>
)}
</ButtonRow>
</>
);
};

export const DashboardEmbedModal = (props: Props) => {
const { show, onHide } = props;

return (
<Modal show={show} onHide={onHide} title={t('Embed')} hideFooter>
<DashboardEmbedControls {...props} />
</Modal>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,13 @@ const propTypes = {
userCanEdit: PropTypes.bool.isRequired,
userCanShare: PropTypes.bool.isRequired,
userCanSave: PropTypes.bool.isRequired,
userCanCurate: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
layout: PropTypes.object.isRequired,
expandedSlices: PropTypes.object.isRequired,
onSave: PropTypes.func.isRequired,
showPropertiesModal: PropTypes.func.isRequired,
manageEmbedded: PropTypes.func.isRequired,
refreshLimit: PropTypes.number,
refreshWarning: PropTypes.string,
lastModifiedTime: PropTypes.number.isRequired,
Expand All @@ -88,6 +90,7 @@ const MENU_KEYS = {
EDIT_CSS: 'edit-css',
DOWNLOAD_AS_IMAGE: 'download-as-image',
TOGGLE_FULLSCREEN: 'toggle-fullscreen',
MANAGE_EMBEDDED: 'manage-embedded',
};

const DropdownButton = styled.div`
Expand Down Expand Up @@ -182,6 +185,10 @@ class HeaderActionsDropdown extends React.PureComponent {
window.location.replace(url);
break;
}
case MENU_KEYS.MANAGE_EMBEDDED: {
this.props.manageEmbedded();
break;
}
default:
break;
}
Expand All @@ -204,6 +211,7 @@ class HeaderActionsDropdown extends React.PureComponent {
userCanEdit,
userCanShare,
userCanSave,
userCanCurate,
isLoading,
refreshLimit,
refreshWarning,
Expand Down Expand Up @@ -313,6 +321,12 @@ class HeaderActionsDropdown extends React.PureComponent {
</Menu.Item>
)}

{!editMode && userCanCurate && (
<Menu.Item key={MENU_KEYS.MANAGE_EMBEDDED}>
{t('Embed dashboard')}
</Menu.Item>
)}

{!editMode && (
<Menu.Item key={MENU_KEYS.DOWNLOAD_AS_IMAGE}>
{t('Download as image')}
Expand Down
23 changes: 23 additions & 0 deletions superset-frontend/src/dashboard/components/Header/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ import setPeriodicRunner, {
stopPeriodicRender,
} from 'src/dashboard/util/setPeriodicRunner';
import { options as PeriodicRefreshOptions } from 'src/dashboard/components/RefreshIntervalModal';
import findPermission from 'src/dashboard/util/findPermission';
import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
import { DashboardEmbedModal } from '../DashboardEmbedControls';

const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
Expand Down Expand Up @@ -420,6 +422,14 @@ class Header extends React.PureComponent {
this.setState({ showingReportModal: false });
}

showEmbedModal = () => {
this.setState({ showingEmbedModal: true });
};

hideEmbedModal = () => {
this.setState({ showingEmbedModal: false });
};

renderReportModal() {
const attachedReportExists = !!Object.keys(this.props.reports).length;
return attachedReportExists ? (
Expand Down Expand Up @@ -498,6 +508,9 @@ class Header extends React.PureComponent {
const userCanSaveAs =
dashboardInfo.dash_save_perm &&
filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.REVIEWING;
const userCanCurate =
isFeatureEnabled(FeatureFlag.EMBEDDED_SUPERSET) &&
findPermission('can_set_embedded', 'Dashboard', user.roles);
const shouldShowReport = !editMode && this.canAddReports();
const refreshLimit =
dashboardInfo.common?.conf?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
Expand Down Expand Up @@ -658,6 +671,14 @@ class Header extends React.PureComponent {
/>
)}

{userCanCurate && (
<DashboardEmbedModal
show={this.state.showingEmbedModal}
onHide={this.hideEmbedModal}
dashboardId={dashboardInfo.id}
/>
)}

<HeaderActionsDropdown
addSuccessToast={this.props.addSuccessToast}
addDangerToast={this.props.addDangerToast}
Expand All @@ -683,8 +704,10 @@ class Header extends React.PureComponent {
userCanEdit={userCanEdit}
userCanShare={userCanShare}
userCanSave={userCanSaveAs}
userCanCurate={userCanCurate}
isLoading={isLoading}
showPropertiesModal={this.showPropertiesModal}
manageEmbedded={this.showEmbedModal}
refreshLimit={refreshLimit}
refreshWarning={refreshWarning}
lastModifiedTime={lastModifiedTime}
Expand Down
8 changes: 5 additions & 3 deletions superset-frontend/src/dashboard/containers/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
} from '@superset-ui/core';
import { useDispatch, useSelector } from 'react-redux';
import { Global } from '@emotion/react';
import { useParams } from 'react-router-dom';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import Loading from 'src/components/Loading';
import FilterBoxMigrationModal from 'src/dashboard/components/FilterBoxMigrationModal';
Expand Down Expand Up @@ -79,14 +78,17 @@ const DashboardContainer = React.lazy(

const originalDocumentTitle = document.title;

const DashboardPage: FC = () => {
type PageProps = {
idOrSlug: string;
};

export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
const dispatch = useDispatch();
const theme = useTheme();
const user = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
const { addDangerToast } = useToasts();
const { idOrSlug } = useParams<{ idOrSlug: string }>();
const { result: dashboard, error: dashboardApiError } =
useDashboard(idOrSlug);
const { result: charts, error: chartsApiError } =
Expand Down
Loading

0 comments on commit 8e29ec5

Please sign in to comment.