You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by su...@apache.org on 2020/08/10 23:31:41 UTC
[incubator-superset] 04/06: add a backend for dynamic plugins
This is an automated email from the ASF dual-hosted git repository.
suddjian pushed a commit to branch dynamic-plugin-import
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
commit 64f9334e36243d1122c550aa2085070498981394
Author: David Aaron Suddjian <aa...@gmail.com>
AuthorDate: Fri Jul 10 00:40:46 2020 -0700
add a backend for dynamic plugins
---
.../DynamicPlugins/DynamicPluginProvider.tsx | 157 +++++++++++++++------
.../src/components/DynamicPlugins/PluginContext.ts | 28 ++--
.../explore/components/ControlPanelsContainer.jsx | 7 +-
.../explore/components/ExploreViewContainer.jsx | 7 +-
.../explore/components/controls/VizTypeControl.jsx | 28 ++--
superset/app.py | 9 ++
.../73fd22e742ab_add_dynamic_plugins_py.py | 55 ++++++++
superset/models/__init__.py | 1 +
superset/models/dynamic_plugins.py | 14 ++
superset/views/__init__.py | 1 +
superset/views/dynamic_plugins.py | 40 ++++++
11 files changed, 273 insertions(+), 74 deletions(-)
diff --git a/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx b/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
index 4e5da20..b14f4bd 100644
--- a/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
+++ b/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
@@ -1,12 +1,18 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useReducer } from 'react';
+import { SupersetClient, Json } from '@superset-ui/connection';
import {
PluginContext,
- initialPluginContext,
- LoadingStatus,
+ PluginContextType,
+ dummyPluginContext,
} from './PluginContext';
-// In future this should be provided by an api call
-const pluginUrls = ['http://localhost:8080/main.js'];
+// the plugin returned from the API
+type Plugin = {
+ name: string;
+ key: string;
+ bundle_url: string;
+ id: number;
+};
// TODO: Make this function an export of @superset-ui/chart or some such
async function defineSharedModule(name: string, promise: Promise<any>) {
@@ -29,50 +35,121 @@ async function defineSharedModules(moduleMap: { [key: string]: Promise<any> }) {
return Promise.all(
Object.entries(moduleMap).map(([name, promise]) => {
defineSharedModule(name, promise);
+ return promise;
}),
);
}
+type CompleteAction = {
+ type: 'complete';
+ key: string;
+ error: null | Error;
+};
+
+type BeginAction = {
+ type: 'begin';
+ keys: string[];
+};
+
+function pluginContextReducer(
+ state: PluginContextType,
+ action: BeginAction | CompleteAction,
+): PluginContextType {
+ switch (action.type) {
+ case 'begin': {
+ const plugins = { ...state.plugins };
+ for (const key of action.keys) {
+ plugins[key] = { key, error: null, loading: true };
+ }
+ return {
+ ...state,
+ loading: true,
+ plugins,
+ };
+ }
+ case 'complete': {
+ return {
+ ...state,
+ loading: Object.values(state.plugins).some(
+ plugin => plugin.loading && plugin.key !== action.key,
+ ),
+ plugins: {
+ ...state.plugins,
+ [action.key]: {
+ key: action.key,
+ loading: false,
+ error: action.error,
+ },
+ },
+ };
+ }
+ default:
+ return state;
+ }
+}
+
export type Props = React.PropsWithChildren<{}>;
export default function DynamicPluginProvider({ children }: Props) {
- const [pluginState, setPluginState] = useState(initialPluginContext);
- useEffect(() => {
- (async function () {
- try {
- await defineSharedModules({
- react: import('react'),
- lodash: import('lodash'),
- 'react-dom': import('react-dom'),
- '@superset-ui/chart': import('@superset-ui/chart'),
- '@superset-ui/chart-controls': import('@superset-ui/chart-controls'),
- '@superset-ui/connection': import('@superset-ui/connection'),
- '@superset-ui/color': import('@superset-ui/color'),
- '@superset-ui/core': import('@superset-ui/core'),
- '@superset-ui/dimension': import('@superset-ui/dimension'),
- '@superset-ui/query': import('@superset-ui/query'),
- '@superset-ui/style': import('@superset-ui/style'),
- '@superset-ui/translation': import('@superset-ui/translation'),
- '@superset-ui/validator': import('@superset-ui/validator'),
- });
+ const [pluginState, dispatch] = useReducer(pluginContextReducer, {
+ // use the dummy plugin context, and override the methods
+ ...dummyPluginContext,
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ fetchAll,
+ // TODO: Write fetchByKeys
+ });
- await Promise.all(
- pluginUrls.map(url => import(/* webpackIgnore: true */ url)),
- );
+ async function fetchAll() {
+ try {
+ await defineSharedModules({
+ react: import('react'),
+ lodash: import('lodash'),
+ 'react-dom': import('react-dom'),
+ '@superset-ui/chart': import('@superset-ui/chart'),
+ '@superset-ui/chart-controls': import('@superset-ui/chart-controls'),
+ '@superset-ui/connection': import('@superset-ui/connection'),
+ '@superset-ui/color': import('@superset-ui/color'),
+ '@superset-ui/core': import('@superset-ui/core'),
+ '@superset-ui/dimension': import('@superset-ui/dimension'),
+ '@superset-ui/query': import('@superset-ui/query'),
+ '@superset-ui/style': import('@superset-ui/style'),
+ '@superset-ui/translation': import('@superset-ui/translation'),
+ '@superset-ui/validator': import('@superset-ui/validator'),
+ });
+ const response = await SupersetClient.get({
+ endpoint: '/dynamic-plugins/api/read',
+ });
+ const plugins: Plugin[] = (response.json as Json).result;
+ dispatch({ type: 'begin', keys: plugins.map(plugin => plugin.key) });
+ await Promise.all(
+ plugins.map(async plugin => {
+ let error: Error | null = null;
+ try {
+ await import(/* webpackIgnore: true */ plugin.bundle_url);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error(
+ `Failed to load plugin ${plugin.key} with the following error:`,
+ err,
+ );
+ error = err;
+ }
+ dispatch({
+ type: 'complete',
+ key: plugin.key,
+ error,
+ });
+ }),
+ );
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error.stack || error);
+ }
+ }
- setPluginState({
- status: LoadingStatus.COMPLETE,
- error: null,
- });
- } catch (error) {
- console.error(error.stack || error);
- setPluginState({
- status: LoadingStatus.ERROR,
- error,
- });
- }
- })();
- }, [pluginUrls]);
+ useEffect(() => {
+ fetchAll();
+ }, []);
return (
<PluginContext.Provider value={pluginState}>
diff --git a/superset-frontend/src/components/DynamicPlugins/PluginContext.ts b/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
index d8e8080..aa66010 100644
--- a/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
+++ b/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
@@ -1,23 +1,25 @@
import React, { useContext } from 'react';
-export enum LoadingStatus {
- LOADING = 'loading',
- COMPLETE = 'complete',
- ERROR = 'error',
-}
-
export type PluginContextType = {
- status: LoadingStatus;
- error: null | {
- message: string;
+ loading: boolean;
+ plugins: {
+ [key: string]: {
+ key: string;
+ loading: boolean;
+ error: null | Error;
+ };
};
+ fetchAll: () => void;
+ // TODO: implement this
+ // fetchByKeys: (keys: string[]) => void;
};
-export const initialPluginContext: PluginContextType = {
- status: LoadingStatus.LOADING,
- error: null,
+export const dummyPluginContext: PluginContextType = {
+ loading: false,
+ plugins: {},
+ fetchAll: () => {},
};
-export const PluginContext = React.createContext(initialPluginContext);
+export const PluginContext = React.createContext(dummyPluginContext);
export const useDynamicPluginContext = () => useContext(PluginContext);
diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.jsx b/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
index 420d683..6d610e4 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
@@ -25,10 +25,7 @@ import { Alert, Tab, Tabs } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import styled from '@superset-ui/style';
-import {
- PluginContext,
- LoadingStatus,
-} from 'src/components/DynamicPlugins/PluginContext';
+import { PluginContext } from 'src/components/DynamicPlugins/PluginContext';
import ControlPanelSection from './ControlPanelSection';
import ControlRow from './ControlRow';
import Control from './Control';
@@ -177,7 +174,7 @@ class ControlPanelsContainer extends React.Component {
const cpRegistry = getChartControlPanelRegistry();
if (
!cpRegistry.has(this.props.form_data.viz_type) &&
- this.context.status === LoadingStatus.LOADING
+ this.context.loading
) {
// TODO replace with a snazzy loading spinner
return 'loading...';
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer.jsx b/superset-frontend/src/explore/components/ExploreViewContainer.jsx
index 93902a3..9ddef6b 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer.jsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer.jsx
@@ -24,10 +24,7 @@ import { connect } from 'react-redux';
import styled from '@superset-ui/style';
import { t } from '@superset-ui/translation';
-import {
- PluginContext,
- LoadingStatus,
-} from 'src/components/DynamicPlugins/PluginContext';
+import { PluginContext } from 'src/components/DynamicPlugins/PluginContext';
import ExploreChartPanel from './ExploreChartPanel';
import ControlPanelsContainer from './ControlPanelsContainer';
import SaveModal from './SaveModal';
@@ -334,7 +331,7 @@ class ExploreViewContainer extends React.Component {
}
render() {
- if (this.context.status === LoadingStatus.LOADING) {
+ if (this.context.loading) {
return 'loading...';
}
if (this.props.standalone) {
diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl.jsx b/superset-frontend/src/explore/components/controls/VizTypeControl.jsx
index c626c9a..97428f5 100644
--- a/superset-frontend/src/explore/components/controls/VizTypeControl.jsx
+++ b/superset-frontend/src/explore/components/controls/VizTypeControl.jsx
@@ -30,12 +30,9 @@ import {
import { t } from '@superset-ui/translation';
import { getChartMetadataRegistry } from '@superset-ui/chart';
+import { useDynamicPluginContext } from 'src/components/DynamicPlugins/PluginContext';
import ControlHeader from '../ControlHeader';
import './VizTypeControl.less';
-import {
- useDynamicPluginContext,
- LoadingStatus,
-} from 'src/components/DynamicPlugins/PluginContext';
const propTypes = {
description: PropTypes.string,
@@ -49,7 +46,7 @@ const defaultProps = {
onChange: () => {},
};
-const registry = getChartMetadataRegistry();
+const chartMetadataRegistry = getChartMetadataRegistry();
const IMAGE_PER_ROW = 6;
const LABEL_STYLE = { cursor: 'pointer' };
@@ -107,7 +104,7 @@ const typesWithDefaultOrder = new Set(DEFAULT_ORDER);
function VizSupportWarning({ registry, vizType }) {
const state = useDynamicPluginContext();
- if (state.status === LoadingStatus.LOADING || registry.has(vizType)) {
+ if (state.loading || registry.has(vizType)) {
return null;
}
return (
@@ -182,13 +179,17 @@ export default class VizTypeControl extends React.PureComponent {
const { value } = this.props;
const filterString = filter.toLowerCase();
- const filteredTypes = DEFAULT_ORDER.filter(type => registry.has(type))
+ const filteredTypes = DEFAULT_ORDER.filter(type =>
+ chartMetadataRegistry.has(type),
+ )
.map(type => ({
key: type,
- value: registry.get(type),
+ value: chartMetadataRegistry.get(type),
}))
.concat(
- registry.entries().filter(({ key }) => !typesWithDefaultOrder.has(key)),
+ chartMetadataRegistry
+ .entries()
+ .filter(({ key }) => !typesWithDefaultOrder.has(key)),
)
.filter(entry => entry.value.name.toLowerCase().includes(filterString));
@@ -218,9 +219,14 @@ export default class VizTypeControl extends React.PureComponent {
>
<>
<Label onClick={this.toggleModal} style={LABEL_STYLE}>
- {registry.has(value) ? registry.get(value).name : `${value}`}
+ {chartMetadataRegistry.has(value)
+ ? chartMetadataRegistry.get(value).name
+ : `${value}`}
</Label>
- <VizSupportWarning registry={registry} vizType={value} />
+ <VizSupportWarning
+ registry={chartMetadataRegistry}
+ vizType={value}
+ />
</>
</OverlayTrigger>
<Modal
diff --git a/superset/app.py b/superset/app.py
index b64ca69..5800001 100644
--- a/superset/app.py
+++ b/superset/app.py
@@ -166,6 +166,7 @@ class SupersetAppInitializer:
ExcelToDatabaseView,
)
from superset.views.datasource import Datasource
+ from superset.views.dynamic_plugins import DynamicPluginsView
from superset.views.key_value import KV
from superset.views.log.api import LogRestApi
from superset.views.log.views import LogModelView
@@ -239,6 +240,14 @@ class SupersetAppInitializer:
category_icon="",
)
appbuilder.add_view(
+ DynamicPluginsView,
+ "Custom Plugins",
+ label=__("Custom Plugins"),
+ category="Manage",
+ category_label=__("Manage"),
+ icon="fa-puzzle-piece",
+ )
+ appbuilder.add_view(
CssTemplateModelView,
"CSS Templates",
label=__("CSS Templates"),
diff --git a/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py b/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py
new file mode 100644
index 0000000..ac6f17f
--- /dev/null
+++ b/superset/migrations/versions/73fd22e742ab_add_dynamic_plugins_py.py
@@ -0,0 +1,55 @@
+# 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_dynamic_plugins.py
+
+Revision ID: 73fd22e742ab
+Revises: a72cb0ebeb22
+Create Date: 2020-07-09 17:12:00.686702
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "73fd22e742ab"
+down_revision = "a72cb0ebeb22"
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+
+def upgrade():
+ op.create_table(
+ "dynamic_plugin",
+ sa.Column("created_on", sa.DateTime(), nullable=True),
+ sa.Column("changed_on", sa.DateTime(), nullable=True),
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.Text(), nullable=False),
+ sa.Column("key", sa.Text(), nullable=False),
+ sa.Column("bundle_url", sa.Text(), 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.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("bundle_url"),
+ sa.UniqueConstraint("key"),
+ sa.UniqueConstraint("name"),
+ )
+
+
+def downgrade():
+ op.drop_table("dynamic_plugin")
diff --git a/superset/models/__init__.py b/superset/models/__init__.py
index 8eebf08..573d22d 100644
--- a/superset/models/__init__.py
+++ b/superset/models/__init__.py
@@ -18,6 +18,7 @@ from . import (
alerts,
core,
datasource_access_request,
+ dynamic_plugins,
schedules,
sql_lab,
user_attributes,
diff --git a/superset/models/dynamic_plugins.py b/superset/models/dynamic_plugins.py
new file mode 100644
index 0000000..b06ae2c
--- /dev/null
+++ b/superset/models/dynamic_plugins.py
@@ -0,0 +1,14 @@
+from flask_appbuilder import Model
+from sqlalchemy import Column, Integer, Text
+
+from superset.models.helpers import AuditMixinNullable
+
+
+class DynamicPlugin(Model, AuditMixinNullable):
+ id = Column(Integer, primary_key=True)
+ name = Column(Text, unique=True, nullable=False)
+ key = Column(Text, unique=True, nullable=False)
+ bundle_url = Column(Text, unique=True, nullable=False)
+
+ def __repr__(self):
+ return self.name
diff --git a/superset/views/__init__.py b/superset/views/__init__.py
index ceddb4c..c3a349c 100644
--- a/superset/views/__init__.py
+++ b/superset/views/__init__.py
@@ -24,6 +24,7 @@ from . import (
css_templates,
dashboard,
datasource,
+ dynamic_plugins,
health,
redirects,
schedules,
diff --git a/superset/views/dynamic_plugins.py b/superset/views/dynamic_plugins.py
new file mode 100644
index 0000000..2cc32c1
--- /dev/null
+++ b/superset/views/dynamic_plugins.py
@@ -0,0 +1,40 @@
+# 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.
+from flask_appbuilder import ModelView
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+
+from superset.models.dynamic_plugins import DynamicPlugin
+
+
+class DynamicPluginsView(ModelView):
+ """Dynamic plugin crud views -- To be replaced by fancy react UI"""
+
+ route_base = "/dynamic-plugins"
+ datamodel = SQLAInterface(DynamicPlugin)
+
+ add_columns = ["name", "key", "bundle_url"]
+ edit_columns = add_columns
+ show_columns = add_columns + ["id"]
+ list_columns = show_columns
+
+ label_columns = {"name": "Name", "key": "Key", "bundle_url": "Bundle URL"}
+
+ description_columns = {
+ "name": "A human-friendly name",
+ "key": "Should be set to the package name from the pluginʼs package.json",
+ "bundle_url": "A full URL pointing to the location of the built plugin (could be hosted on a CDN for example)",
+ }