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)",
+    }