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:37 UTC

[incubator-superset] branch dynamic-plugin-import created (now 42f97e7)

This is an automated email from the ASF dual-hosted git repository.

suddjian pushed a change to branch dynamic-plugin-import
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git.


      at 42f97e7  more work on making it work, + feature flag

This branch includes the following new commits:

     new 9b0dfc4  first attempts at dynamic plugin loading
     new fee3247  dynamic import working for explore
     new a212218  memoize appropriately
     new 64f9334  add a backend for dynamic plugins
     new 0b3d809  hack at getting dynamic plugins working with dashboards
     new 42f97e7  more work on making it work, + feature flag

The 6 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



[incubator-superset] 03/06: memoize appropriately

Posted by su...@apache.org.
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 a21221878b056b29936aec253e495ac294005ef6
Author: David Aaron Suddjian <aa...@gmail.com>
AuthorDate: Thu Jul 2 10:45:43 2020 -0700

    memoize appropriately
---
 superset-frontend/src/explore/controlUtils.js | 119 ++++++++++++++------------
 1 file changed, 64 insertions(+), 55 deletions(-)

diff --git a/superset-frontend/src/explore/controlUtils.js b/superset-frontend/src/explore/controlUtils.js
index 8ce05d9..627cba1 100644
--- a/superset-frontend/src/explore/controlUtils.js
+++ b/superset-frontend/src/explore/controlUtils.js
@@ -69,22 +69,25 @@ function findControlItem(controlPanelSections, controlKey) {
   return null;
 }
 
-export const getControlConfig = memoizeOne(function getControlConfig(
-  controlKey,
-  vizType,
-) {
+const getMemoizedControlConfig = memoizeOne(
+  (controlKey, controlPanelConfig) => {
+    const {
+      controlOverrides = {},
+      controlPanelSections = [],
+    } = controlPanelConfig;
+
+    const control = expandControlConfig(
+      findControlItem(controlPanelSections, controlKey),
+      controlOverrides,
+    );
+    return control?.config || control;
+  },
+);
+
+export const getControlConfig = function getControlConfig(controlKey, vizType) {
   const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {};
-  const {
-    controlOverrides = {},
-    controlPanelSections = [],
-  } = controlPanelConfig;
-
-  const control = expandControlConfig(
-    findControlItem(controlPanelSections, controlKey),
-    controlOverrides,
-  );
-  return control?.config || control;
-});
+  return getMemoizedControlConfig(controlKey, controlPanelConfig);
+};
 
 function handleMissingChoice(control) {
   // If the value is not valid anymore based on choices, clear it
@@ -169,53 +172,59 @@ export function getControlState(controlKey, vizType, state, value) {
   );
 }
 
+const getMemoizedSectionsToRender = memoizeOne(
+  (datasourceType, controlPanelConfig) => {
+    const {
+      sectionOverrides = {},
+      controlOverrides,
+      controlPanelSections = [],
+    } = controlPanelConfig;
+
+    // default control panel sections
+    const sections = { ...SECTIONS };
+
+    // apply section overrides
+    Object.entries(sectionOverrides).forEach(([section, overrides]) => {
+      if (typeof overrides === 'object' && overrides.constructor === Object) {
+        sections[section] = {
+          ...sections[section],
+          ...overrides,
+        };
+      } else {
+        sections[section] = overrides;
+      }
+    });
+
+    const { datasourceAndVizType, sqlaTimeSeries, druidTimeSeries } = sections;
+    const timeSection =
+      datasourceType === 'table' ? sqlaTimeSeries : druidTimeSeries;
+
+    return []
+      .concat(datasourceAndVizType, timeSection, controlPanelSections)
+      .filter(section => !!section)
+      .map(section => {
+        const { controlSetRows } = section;
+        return {
+          ...section,
+          controlSetRows:
+            controlSetRows?.map(row =>
+              row.map(item => expandControlConfig(item, controlOverrides)),
+            ) || [],
+        };
+      });
+  },
+);
+
 /**
  * Get the clean and processed control panel sections
  */
-export const sectionsToRender = memoizeOne(function sectionsToRender(
+export const sectionsToRender = function sectionsToRender(
   vizType,
   datasourceType,
 ) {
   const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {};
-  const {
-    sectionOverrides = {},
-    controlOverrides,
-    controlPanelSections = [],
-  } = controlPanelConfig;
-
-  // default control panel sections
-  const sections = { ...SECTIONS };
-
-  // apply section overrides
-  Object.entries(sectionOverrides).forEach(([section, overrides]) => {
-    if (typeof overrides === 'object' && overrides.constructor === Object) {
-      sections[section] = {
-        ...sections[section],
-        ...overrides,
-      };
-    } else {
-      sections[section] = overrides;
-    }
-  });
-
-  const { datasourceAndVizType, sqlaTimeSeries, druidTimeSeries } = sections;
-  const timeSection =
-    datasourceType === 'table' ? sqlaTimeSeries : druidTimeSeries;
-
-  return []
-    .concat(datasourceAndVizType, timeSection, controlPanelSections)
-    .filter(section => !!section)
-    .map(section => {
-      const { controlSetRows } = section;
-      return {
-        ...section,
-        controlSetRows:
-          controlSetRows?.map(row =>
-            row.map(item => expandControlConfig(item, controlOverrides)),
-          ) || [],
-      };
-    });
-});
+  return getMemoizedSectionsToRender(datasourceType, controlPanelConfig);
+};
 
 export function getAllControlsState(vizType, datasourceType, state, formData) {
   const controlsState = {};


[incubator-superset] 05/06: hack at getting dynamic plugins working with dashboards

Posted by su...@apache.org.
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 0b3d809e0878f518f68b338491dd3d504683dff0
Author: David Aaron Suddjian <aa...@gmail.com>
AuthorDate: Fri Jul 10 01:31:14 2020 -0700

    hack at getting dynamic plugins working with dashboards
---
 superset-frontend/src/components/DynamicPlugins/PluginContext.ts | 2 +-
 superset-frontend/src/dashboard/App.jsx                          | 5 ++++-
 superset-frontend/src/dashboard/components/Dashboard.jsx         | 6 ++++++
 3 files changed, 11 insertions(+), 2 deletions(-)

diff --git a/superset-frontend/src/components/DynamicPlugins/PluginContext.ts b/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
index aa66010..9e7597a 100644
--- a/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
+++ b/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
@@ -15,7 +15,7 @@ export type PluginContextType = {
 };
 
 export const dummyPluginContext: PluginContextType = {
-  loading: false,
+  loading: true,
   plugins: {},
   fetchAll: () => {},
 };
diff --git a/superset-frontend/src/dashboard/App.jsx b/superset-frontend/src/dashboard/App.jsx
index 2deb9f6..7b84635 100644
--- a/superset-frontend/src/dashboard/App.jsx
+++ b/superset-frontend/src/dashboard/App.jsx
@@ -21,6 +21,7 @@ import React from 'react';
 import { Provider } from 'react-redux';
 import { supersetTheme, ThemeProvider } from '@superset-ui/style';
 
+import DynamicPluginProvider from 'src/components/DynamicPlugins/DynamicPluginProvider';
 import setupApp from '../setup/setupApp';
 import setupPlugins from '../setup/setupPlugins';
 import DashboardContainer from './containers/Dashboard';
@@ -31,7 +32,9 @@ setupPlugins();
 const App = ({ store }) => (
   <Provider store={store}>
     <ThemeProvider theme={supersetTheme}>
-      <DashboardContainer />
+      <DynamicPluginProvider>
+        <DashboardContainer />
+      </DynamicPluginProvider>
     </ThemeProvider>
   </Provider>
 );
diff --git a/superset-frontend/src/dashboard/components/Dashboard.jsx b/superset-frontend/src/dashboard/components/Dashboard.jsx
index 33eb420..d7e9048 100644
--- a/superset-frontend/src/dashboard/components/Dashboard.jsx
+++ b/superset-frontend/src/dashboard/components/Dashboard.jsx
@@ -40,6 +40,7 @@ import { areObjectsEqual } from '../../reduxUtils';
 import '../stylesheets/index.less';
 import getLocationHash from '../util/getLocationHash';
 import isDashboardEmpty from '../util/isDashboardEmpty';
+import { PluginContext } from 'src/components/DynamicPlugins/PluginContext';
 
 const propTypes = {
   actions: PropTypes.shape({
@@ -185,6 +186,8 @@ class Dashboard extends React.PureComponent {
     return Object.values(this.props.charts);
   }
 
+  static contextType = PluginContext;
+
   applyFilters() {
     const appliedFilters = this.appliedFilters;
     const { activeFilters } = this.props;
@@ -242,6 +245,9 @@ class Dashboard extends React.PureComponent {
   }
 
   render() {
+    if (this.context.loading) {
+      return 'loading...';
+    }
     return (
       <>
         <OmniContainer logEvent={this.props.actions.logEvent} />


[incubator-superset] 04/06: add a backend for dynamic plugins

Posted by su...@apache.org.
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)",
+    }


[incubator-superset] 06/06: more work on making it work, + feature flag

Posted by su...@apache.org.
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 42f97e728ffaeafebfc352288bb204b3fc58e40e
Author: David Aaron Suddjian <aa...@gmail.com>
AuthorDate: Fri Jul 10 13:56:47 2020 -0700

    more work on making it work, + feature flag
---
 .../src/components/AlteredSliceTag.jsx             | 25 ++++++----------
 .../DynamicPlugins/DynamicPluginProvider.tsx       |  8 +++--
 .../explore/components/ControlPanelsContainer.jsx  |  4 +--
 superset-frontend/src/featureFlags.ts              |  1 +
 .../src/utils/getControlsForVizType.js             | 35 ++++++++++++----------
 superset/app.py                                    | 17 ++++++-----
 superset/config.py                                 |  1 +
 7 files changed, 47 insertions(+), 44 deletions(-)

diff --git a/superset-frontend/src/components/AlteredSliceTag.jsx b/superset-frontend/src/components/AlteredSliceTag.jsx
index d158461..509d0f1 100644
--- a/superset-frontend/src/components/AlteredSliceTag.jsx
+++ b/superset-frontend/src/components/AlteredSliceTag.jsx
@@ -53,9 +53,7 @@ export default class AlteredSliceTag extends React.Component {
     super(props);
     const diffs = this.getDiffs(props);
 
-    const controlsMap = getControlsForVizType(this.props.origFormData.viz_type);
-
-    this.state = { diffs, hasDiffs: !isEmpty(diffs), controlsMap };
+    this.state = { diffs, hasDiffs: !isEmpty(diffs) };
   }
 
   UNSAFE_componentWillReceiveProps(newProps) {
@@ -96,6 +94,7 @@ export default class AlteredSliceTag extends React.Component {
   }
 
   formatValue(value, key) {
+    const controlsMap = getControlsForVizType(this.props.origFormData.viz_type);
     // Format display value based on the control type
     // or the value type
     if (value === undefined) {
@@ -103,8 +102,8 @@ export default class AlteredSliceTag extends React.Component {
     } else if (value === null) {
       return 'null';
     } else if (
-      this.state.controlsMap[key] &&
-      this.state.controlsMap[key].type === 'AdhocFilterControl'
+      controlsMap[key] &&
+      controlsMap[key].type === 'AdhocFilterControl'
     ) {
       if (!value.length) {
         return '[]';
@@ -118,14 +117,11 @@ export default class AlteredSliceTag extends React.Component {
           return `${v.subject} ${v.operator} ${filterVal}`;
         })
         .join(', ');
-    } else if (
-      this.state.controlsMap[key] &&
-      this.state.controlsMap[key].type === 'BoundsControl'
-    ) {
+    } else if (controlsMap[key] && controlsMap[key].type === 'BoundsControl') {
       return `Min: ${value[0]}, Max: ${value[1]}`;
     } else if (
-      this.state.controlsMap[key] &&
-      this.state.controlsMap[key].type === 'CollectionControl'
+      controlsMap[key] &&
+      controlsMap[key].type === 'CollectionControl'
     ) {
       return value.map(v => safeStringify(v)).join(', ');
     } else if (typeof value === 'boolean') {
@@ -139,6 +135,7 @@ export default class AlteredSliceTag extends React.Component {
   }
 
   renderRows() {
+    const controlsMap = getControlsForVizType(this.props.origFormData.viz_type);
     const diffs = this.state.diffs;
     const rows = [];
     for (const key in diffs) {
@@ -146,11 +143,7 @@ export default class AlteredSliceTag extends React.Component {
         <Tr key={key}>
           <Td
             column="control"
-            data={
-              (this.state.controlsMap[key] &&
-                this.state.controlsMap[key].label) ||
-              key
-            }
+            data={(controlsMap[key] && controlsMap[key].label) || key}
           />
           <Td column="before">{this.formatValue(diffs[key].before, key)}</Td>
           <Td column="after">{this.formatValue(diffs[key].after, key)}</Td>
diff --git a/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx b/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
index b14f4bd..476c1f9 100644
--- a/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
+++ b/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
@@ -1,5 +1,6 @@
 import React, { useEffect, useReducer } from 'react';
 import { SupersetClient, Json } from '@superset-ui/connection';
+import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
 import {
   PluginContext,
   PluginContextType,
@@ -96,6 +97,7 @@ export default function DynamicPluginProvider({ children }: Props) {
     ...dummyPluginContext,
     // eslint-disable-next-line @typescript-eslint/no-use-before-define
     fetchAll,
+    loading: isFeatureEnabled(FeatureFlag.DYNAMIC_PLUGINS),
     // TODO: Write fetchByKeys
   });
 
@@ -130,7 +132,7 @@ export default function DynamicPluginProvider({ children }: Props) {
             // eslint-disable-next-line no-console
             console.error(
               `Failed to load plugin ${plugin.key} with the following error:`,
-              err,
+              err.stack,
             );
             error = err;
           }
@@ -148,7 +150,9 @@ export default function DynamicPluginProvider({ children }: Props) {
   }
 
   useEffect(() => {
-    fetchAll();
+    if (isFeatureEnabled(FeatureFlag.DYNAMIC_PLUGINS)) {
+      fetchAll();
+    }
   }, []);
 
   return (
diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.jsx b/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
index 6d610e4..2cd3269 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
@@ -171,9 +171,9 @@ class ControlPanelsContainer extends React.Component {
   }
 
   render() {
-    const cpRegistry = getChartControlPanelRegistry();
+    const controlPanelRegistry = getChartControlPanelRegistry();
     if (
-      !cpRegistry.has(this.props.form_data.viz_type) &&
+      !controlPanelRegistry.has(this.props.form_data.viz_type) &&
       this.context.loading
     ) {
       // TODO replace with a snazzy loading spinner
diff --git a/superset-frontend/src/featureFlags.ts b/superset-frontend/src/featureFlags.ts
index 0853578..8cb177a 100644
--- a/superset-frontend/src/featureFlags.ts
+++ b/superset-frontend/src/featureFlags.ts
@@ -26,6 +26,7 @@ export enum FeatureFlag {
   ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST',
   SHARE_QUERIES_VIA_KV_STORE = 'SHARE_QUERIES_VIA_KV_STORE',
   SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE',
+  DYNAMIC_PLUGINS = 'DYNAMIC_PLUGINS',
 }
 
 export type FeatureFlagMap = {
diff --git a/superset-frontend/src/utils/getControlsForVizType.js b/superset-frontend/src/utils/getControlsForVizType.js
index 3be5b7e..fd0abc1 100644
--- a/superset-frontend/src/utils/getControlsForVizType.js
+++ b/superset-frontend/src/utils/getControlsForVizType.js
@@ -21,26 +21,29 @@ import memoize from 'lodash/memoize';
 import { getChartControlPanelRegistry } from '@superset-ui/chart';
 import controls from '../explore/controls';
 
-const getControlsForVizType = memoize(vizType => {
+const memoizedControls = memoize((vizType, controlPanel) => {
   const controlsMap = {};
-  getChartControlPanelRegistry()
-    .get(vizType)
-    .controlPanelSections.forEach(section => {
-      section.controlSetRows.forEach(row => {
-        row.forEach(control => {
-          if (!control) return;
-          if (typeof control === 'string') {
-            // For now, we have to look in controls.jsx to get the config for some controls.
-            // Once everything is migrated out, delete this if statement.
-            controlsMap[control] = controls[control];
-          } else if (control.name && control.config) {
-            // condition needed because there are elements, e.g. <hr /> in some control configs (I'm looking at you, FilterBox!)
-            controlsMap[control.name] = control.config;
-          }
-        });
+  controlPanel.controlPanelSections.forEach(section => {
+    section.controlSetRows.forEach(row => {
+      row.forEach(control => {
+        if (!control) return;
+        if (typeof control === 'string') {
+          // For now, we have to look in controls.jsx to get the config for some controls.
+          // Once everything is migrated out, delete this if statement.
+          controlsMap[control] = controls[control];
+        } else if (control.name && control.config) {
+          // condition needed because there are elements, e.g. <hr /> in some control configs (I'm looking at you, FilterBox!)
+          controlsMap[control.name] = control.config;
+        }
       });
     });
+  });
   return controlsMap;
 });
 
+const getControlsForVizType = vizType => {
+  const controlPanel = getChartControlPanelRegistry().get(vizType);
+  return memoizedControls(vizType, controlPanel);
+};
+
 export default getControlsForVizType;
diff --git a/superset/app.py b/superset/app.py
index 5800001..295cd1a 100644
--- a/superset/app.py
+++ b/superset/app.py
@@ -239,14 +239,15 @@ class SupersetAppInitializer:
             category="",
             category_icon="",
         )
-        appbuilder.add_view(
-            DynamicPluginsView,
-            "Custom Plugins",
-            label=__("Custom Plugins"),
-            category="Manage",
-            category_label=__("Manage"),
-            icon="fa-puzzle-piece",
-        )
+        if feature_flag_manager.is_feature_enabled("DYNAMIC_PLUGINS"):
+            appbuilder.add_view(
+                DynamicPluginsView,
+                "Custom Plugins",
+                label=__("Custom Plugins"),
+                category="Manage",
+                category_label=__("Manage"),
+                icon="fa-puzzle-piece",
+            )
         appbuilder.add_view(
             CssTemplateModelView,
             "CSS Templates",
diff --git a/superset/config.py b/superset/config.py
index cfef8c2..bcce955 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -308,6 +308,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
     "SIP_38_VIZ_REARCHITECTURE": False,
     "TAGGING_SYSTEM": False,
     "SQLLAB_BACKEND_PERSISTENCE": False,
+    "DYNAMIC_PLUGINS": False,
 }
 
 # This is merely a default.


[incubator-superset] 01/06: first attempts at dynamic plugin loading

Posted by su...@apache.org.
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 9b0dfc4f54159b23054a6d57a95c6fc9b3af2e65
Author: David Aaron Suddjian <aa...@gmail.com>
AuthorDate: Wed Jun 10 14:30:03 2020 -0700

    first attempts at dynamic plugin loading
---
 superset-frontend/src/addSlice/App.tsx             |  5 +-
 .../DynamicPlugins/DynamicPluginProvider.tsx       | 40 +++++++++++++
 .../src/components/DynamicPlugins/PluginContext.ts | 25 +++++++++
 superset-frontend/src/welcome/App.tsx              | 65 +++++++++++-----------
 4 files changed, 103 insertions(+), 32 deletions(-)

diff --git a/superset-frontend/src/addSlice/App.tsx b/superset-frontend/src/addSlice/App.tsx
index 4443fb5..f94b4da 100644
--- a/superset-frontend/src/addSlice/App.tsx
+++ b/superset-frontend/src/addSlice/App.tsx
@@ -21,6 +21,7 @@ import { hot } from 'react-hot-loader/root';
 import { supersetTheme, ThemeProvider } from '@superset-ui/style';
 import setupApp from '../setup/setupApp';
 import setupPlugins from '../setup/setupPlugins';
+import DynamicPluginProvider from '../components/DynamicPlugins/DynamicPluginProvider';
 import AddSliceContainer from './AddSliceContainer';
 
 setupApp();
@@ -33,7 +34,9 @@ const bootstrapData = JSON.parse(
 
 const App = () => (
   <ThemeProvider theme={supersetTheme}>
-    <AddSliceContainer datasources={bootstrapData.datasources} />
+    <DynamicPluginProvider>
+      <AddSliceContainer datasources={bootstrapData.datasources} />
+    </DynamicPluginProvider>
   </ThemeProvider>
 );
 
diff --git a/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx b/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
new file mode 100644
index 0000000..ca45e66
--- /dev/null
+++ b/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
@@ -0,0 +1,40 @@
+import React, { useEffect, useState } from 'react';
+// use scriptjs for browser-side dynamic importing
+// import $script from 'scriptjs';
+// import { Preset } from '@superset-ui/core';
+import PluginContext, { initialPluginContext } from './PluginContext';
+
+console.log('from superset:', React);
+
+// In future this should be provided by an api call
+const pluginUrls = ['http://localhost:8080/main.js'];
+
+export type Props = React.PropsWithChildren<{}>;
+
+export default function DynamicPluginProvider({ children }: Props) {
+  const [pluginState] = useState(initialPluginContext);
+  useEffect(() => {
+    console.log('importing test');
+    // $script(pluginUrls, () => {
+    //   console.log('done');
+    // });
+    Promise.all(
+      pluginUrls.map(async url => {
+        const { default: d } = await import(/* webpackIgnore: true */ url);
+        return d;
+      }),
+    ).then(pluginModules => {
+      console.log(pluginModules);
+      // return new Preset({
+      //   name: 'Dynamic Charts',
+      //   presets: [],
+      //   plugins: [pluginModules],
+      // });
+    });
+  }, [pluginUrls]);
+  return (
+    <PluginContext.Provider value={pluginState}>
+      {children}
+    </PluginContext.Provider>
+  );
+}
diff --git a/superset-frontend/src/components/DynamicPlugins/PluginContext.ts b/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
new file mode 100644
index 0000000..100e813
--- /dev/null
+++ b/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
@@ -0,0 +1,25 @@
+import React from 'react';
+
+export enum LoadingStatus {
+  LOADING = 'loading',
+  COMPLETE = 'complete',
+  ERROR = 'error',
+}
+
+export type PluginContextType = {
+  status: LoadingStatus;
+  error: null | {
+    message: string;
+  };
+  pluginKeys: string[];
+};
+
+export const initialPluginContext: PluginContextType = {
+  status: LoadingStatus.LOADING,
+  error: null,
+  pluginKeys: [],
+};
+
+const PluginContext = React.createContext(initialPluginContext);
+
+export default PluginContext;
diff --git a/superset-frontend/src/welcome/App.tsx b/superset-frontend/src/welcome/App.tsx
index 5bc624d..c72a131 100644
--- a/superset-frontend/src/welcome/App.tsx
+++ b/superset-frontend/src/welcome/App.tsx
@@ -20,7 +20,7 @@ import React from 'react';
 import { hot } from 'react-hot-loader/root';
 import thunk from 'redux-thunk';
 import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
-import { Provider } from 'react-redux';
+import { Provider as ReduxProvider } from 'react-redux';
 import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
 import { QueryParamProvider } from 'use-query-params';
 import { initFeatureFlags } from 'src/featureFlags';
@@ -38,6 +38,7 @@ import setupApp from '../setup/setupApp';
 import setupPlugins from '../setup/setupPlugins';
 import Welcome from './Welcome';
 import ToastPresenter from '../messageToasts/containers/ToastPresenter';
+import DynamicPluginProvider from 'src/components/DynamicPlugins/DynamicPluginProvider';
 
 setupApp();
 setupPlugins();
@@ -58,40 +59,42 @@ const store = createStore(
 );
 
 const App = () => (
-  <Provider store={store}>
+  <ReduxProvider store={store}>
     <ThemeProvider theme={supersetTheme}>
       <FlashProvider common={common}>
-        <Router>
-          <QueryParamProvider ReactRouterRoute={Route}>
-            <Menu data={menu} />
-            <Switch>
-              <Route path="/superset/welcome/">
-                <ErrorBoundary>
-                  <Welcome user={user} />
-                </ErrorBoundary>
-              </Route>
-              <Route path="/dashboard/list/">
-                <ErrorBoundary>
-                  <DashboardList user={user} />
-                </ErrorBoundary>
-              </Route>
-              <Route path="/chart/list/">
-                <ErrorBoundary>
-                  <ChartList user={user} />
-                </ErrorBoundary>
-              </Route>
-              <Route path="/tablemodelview/list/">
-                <ErrorBoundary>
-                  <DatasetList user={user} />
-                </ErrorBoundary>
-              </Route>
-            </Switch>
-            <ToastPresenter />
-          </QueryParamProvider>
-        </Router>
+        <DynamicPluginProvider>
+          <Router>
+            <QueryParamProvider ReactRouterRoute={Route}>
+              <Menu data={menu} />
+              <Switch>
+                <Route path="/superset/welcome/">
+                  <ErrorBoundary>
+                    <Welcome user={user} />
+                  </ErrorBoundary>
+                </Route>
+                <Route path="/dashboard/list/">
+                  <ErrorBoundary>
+                    <DashboardList user={user} />
+                  </ErrorBoundary>
+                </Route>
+                <Route path="/chart/list/">
+                  <ErrorBoundary>
+                    <ChartList user={user} />
+                  </ErrorBoundary>
+                </Route>
+                <Route path="/tablemodelview/list/">
+                  <ErrorBoundary>
+                    <DatasetList user={user} />
+                  </ErrorBoundary>
+                </Route>
+              </Switch>
+              <ToastPresenter />
+            </QueryParamProvider>
+          </Router>
+        </DynamicPluginProvider>
       </FlashProvider>
     </ThemeProvider>
-  </Provider>
+  </ReduxProvider>
 );
 
 export default hot(App);


[incubator-superset] 02/06: dynamic import working for explore

Posted by su...@apache.org.
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 fee32479c5f30d4b5f919ed062a13abbaa69b9c8
Author: David Aaron Suddjian <aa...@gmail.com>
AuthorDate: Wed Jul 1 14:48:33 2020 -0700

    dynamic import working for explore
---
 .../DynamicPlugins/DynamicPluginProvider.tsx       | 90 ++++++++++++++++------
 .../src/components/DynamicPlugins/PluginContext.ts |  8 +-
 superset-frontend/src/explore/App.jsx              | 11 ++-
 .../explore/components/ControlPanelsContainer.jsx  | 16 ++++
 .../explore/components/ExploreViewContainer.jsx    |  9 +++
 .../explore/components/controls/VizTypeControl.jsx | 26 +++++--
 .../src/utils/getClientErrorObject.ts              | 17 ++--
 7 files changed, 131 insertions(+), 46 deletions(-)

diff --git a/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx b/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
index ca45e66..4e5da20 100644
--- a/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
+++ b/superset-frontend/src/components/DynamicPlugins/DynamicPluginProvider.tsx
@@ -1,37 +1,79 @@
 import React, { useEffect, useState } from 'react';
-// use scriptjs for browser-side dynamic importing
-// import $script from 'scriptjs';
-// import { Preset } from '@superset-ui/core';
-import PluginContext, { initialPluginContext } from './PluginContext';
-
-console.log('from superset:', React);
+import {
+  PluginContext,
+  initialPluginContext,
+  LoadingStatus,
+} from './PluginContext';
 
 // In future this should be provided by an api call
 const pluginUrls = ['http://localhost:8080/main.js'];
 
+// TODO: Make this function an export of @superset-ui/chart or some such
+async function defineSharedModule(name: string, promise: Promise<any>) {
+  // dependency management using global variables, because for the life of me
+  // I can't figure out how to hook into UMD from a dynamically imported package.
+  // Maybe someone else can help figure that out.
+  const loadingKey = '__superset__loading__/' + name;
+  const pkgKey = '__superset__/' + name;
+  if (window[loadingKey]) {
+    await window[loadingKey];
+    return window[pkgKey];
+  }
+  window[loadingKey] = promise;
+  const pkg = await promise;
+  window[pkgKey] = pkg;
+  return pkg;
+}
+
+async function defineSharedModules(moduleMap: { [key: string]: Promise<any> }) {
+  return Promise.all(
+    Object.entries(moduleMap).map(([name, promise]) => {
+      defineSharedModule(name, promise);
+    }),
+  );
+}
+
 export type Props = React.PropsWithChildren<{}>;
 
 export default function DynamicPluginProvider({ children }: Props) {
-  const [pluginState] = useState(initialPluginContext);
+  const [pluginState, setPluginState] = useState(initialPluginContext);
   useEffect(() => {
-    console.log('importing test');
-    // $script(pluginUrls, () => {
-    //   console.log('done');
-    // });
-    Promise.all(
-      pluginUrls.map(async url => {
-        const { default: d } = await import(/* webpackIgnore: true */ url);
-        return d;
-      }),
-    ).then(pluginModules => {
-      console.log(pluginModules);
-      // return new Preset({
-      //   name: 'Dynamic Charts',
-      //   presets: [],
-      //   plugins: [pluginModules],
-      // });
-    });
+    (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'),
+        });
+
+        await Promise.all(
+          pluginUrls.map(url => import(/* webpackIgnore: true */ url)),
+        );
+
+        setPluginState({
+          status: LoadingStatus.COMPLETE,
+          error: null,
+        });
+      } catch (error) {
+        console.error(error.stack || error);
+        setPluginState({
+          status: LoadingStatus.ERROR,
+          error,
+        });
+      }
+    })();
   }, [pluginUrls]);
+
   return (
     <PluginContext.Provider value={pluginState}>
       {children}
diff --git a/superset-frontend/src/components/DynamicPlugins/PluginContext.ts b/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
index 100e813..d8e8080 100644
--- a/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
+++ b/superset-frontend/src/components/DynamicPlugins/PluginContext.ts
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useContext } from 'react';
 
 export enum LoadingStatus {
   LOADING = 'loading',
@@ -11,15 +11,13 @@ export type PluginContextType = {
   error: null | {
     message: string;
   };
-  pluginKeys: string[];
 };
 
 export const initialPluginContext: PluginContextType = {
   status: LoadingStatus.LOADING,
   error: null,
-  pluginKeys: [],
 };
 
-const PluginContext = React.createContext(initialPluginContext);
+export const PluginContext = React.createContext(initialPluginContext);
 
-export default PluginContext;
+export const useDynamicPluginContext = () => useContext(PluginContext);
diff --git a/superset-frontend/src/explore/App.jsx b/superset-frontend/src/explore/App.jsx
index e1f8b83..c84fad0 100644
--- a/superset-frontend/src/explore/App.jsx
+++ b/superset-frontend/src/explore/App.jsx
@@ -20,6 +20,7 @@ import React from 'react';
 import { hot } from 'react-hot-loader/root';
 import { Provider } from 'react-redux';
 import { supersetTheme, ThemeProvider } from '@superset-ui/style';
+import DynamicPluginProvider from 'src/components/DynamicPlugins/DynamicPluginProvider';
 import ToastPresenter from '../messageToasts/containers/ToastPresenter';
 import ExploreViewContainer from './components/ExploreViewContainer';
 import setupApp from '../setup/setupApp';
@@ -33,10 +34,12 @@ setupPlugins();
 const App = ({ store }) => (
   <Provider store={store}>
     <ThemeProvider theme={supersetTheme}>
-      <>
-        <ExploreViewContainer />
-        <ToastPresenter />
-      </>
+      <DynamicPluginProvider>
+        <>
+          <ExploreViewContainer />
+          <ToastPresenter />
+        </>
+      </DynamicPluginProvider>
     </ThemeProvider>
   </Provider>
 );
diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.jsx b/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
index c3c175b..420d683 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.jsx
@@ -25,6 +25,10 @@ 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 ControlPanelSection from './ControlPanelSection';
 import ControlRow from './ControlRow';
 import Control from './Control';
@@ -61,6 +65,9 @@ const Styles = styled.div`
 `;
 
 class ControlPanelsContainer extends React.Component {
+  // trigger updates to the component when async plugins load
+  static contextType = PluginContext;
+
   constructor(props) {
     super(props);
 
@@ -167,6 +174,15 @@ class ControlPanelsContainer extends React.Component {
   }
 
   render() {
+    const cpRegistry = getChartControlPanelRegistry();
+    if (
+      !cpRegistry.has(this.props.form_data.viz_type) &&
+      this.context.status === LoadingStatus.LOADING
+    ) {
+      // TODO replace with a snazzy loading spinner
+      return 'loading...';
+    }
+
     const querySectionsToRender = [];
     const displaySectionsToRender = [];
     this.sectionsToRender().forEach(section => {
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer.jsx b/superset-frontend/src/explore/components/ExploreViewContainer.jsx
index 6104216..93902a3 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer.jsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer.jsx
@@ -24,6 +24,10 @@ 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 ExploreChartPanel from './ExploreChartPanel';
 import ControlPanelsContainer from './ControlPanelsContainer';
 import SaveModal from './SaveModal';
@@ -77,6 +81,8 @@ const Styles = styled.div`
 `;
 
 class ExploreViewContainer extends React.Component {
+  static contextType = PluginContext; // eslint-disable-line react/sort-comp
+
   constructor(props) {
     super(props);
 
@@ -328,6 +334,9 @@ class ExploreViewContainer extends React.Component {
   }
 
   render() {
+    if (this.context.status === LoadingStatus.LOADING) {
+      return 'loading...';
+    }
     if (this.props.standalone) {
       return this.renderChartContainer();
     }
diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl.jsx b/superset-frontend/src/explore/components/controls/VizTypeControl.jsx
index 2c67f68..c626c9a 100644
--- a/superset-frontend/src/explore/components/controls/VizTypeControl.jsx
+++ b/superset-frontend/src/explore/components/controls/VizTypeControl.jsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import React from 'react';
+import React, { useContext } from 'react';
 import PropTypes from 'prop-types';
 import {
   Label,
@@ -32,6 +32,10 @@ import { getChartMetadataRegistry } from '@superset-ui/chart';
 
 import ControlHeader from '../ControlHeader';
 import './VizTypeControl.less';
+import {
+  useDynamicPluginContext,
+  LoadingStatus,
+} from 'src/components/DynamicPlugins/PluginContext';
 
 const propTypes = {
   description: PropTypes.string,
@@ -101,6 +105,19 @@ const DEFAULT_ORDER = [
 
 const typesWithDefaultOrder = new Set(DEFAULT_ORDER);
 
+function VizSupportWarning({ registry, vizType }) {
+  const state = useDynamicPluginContext();
+  if (state.status === LoadingStatus.LOADING || registry.has(vizType)) {
+    return null;
+  }
+  return (
+    <div className="text-danger">
+      <i className="fa fa-exclamation-circle text-danger" />{' '}
+      <small>{t('This visualization type is not supported.')}</small>
+    </div>
+  );
+}
+
 export default class VizTypeControl extends React.PureComponent {
   constructor(props) {
     super(props);
@@ -203,12 +220,7 @@ export default class VizTypeControl extends React.PureComponent {
             <Label onClick={this.toggleModal} style={LABEL_STYLE}>
               {registry.has(value) ? registry.get(value).name : `${value}`}
             </Label>
-            {!registry.has(value) && (
-              <div className="text-danger">
-                <i className="fa fa-exclamation-circle text-danger" />{' '}
-                <small>{t('This visualization type is not supported.')}</small>
-              </div>
-            )}
+            <VizSupportWarning registry={registry} vizType={value} />
           </>
         </OverlayTrigger>
         <Modal
diff --git a/superset-frontend/src/utils/getClientErrorObject.ts b/superset-frontend/src/utils/getClientErrorObject.ts
index 6349496..cbc3efe 100644
--- a/superset-frontend/src/utils/getClientErrorObject.ts
+++ b/superset-frontend/src/utils/getClientErrorObject.ts
@@ -35,8 +35,12 @@ export type ClientErrorObject = {
   stacktrace?: string;
 } & Partial<SupersetClientResponse>;
 
+interface ResponseWithTimeout extends Response {
+  timeout: number;
+}
+
 export default function getClientErrorObject(
-  response: SupersetClientResponse | (Response & { timeout: number }) | string,
+  response: SupersetClientResponse | ResponseWithTimeout | string,
 ): Promise<ClientErrorObject> {
   // takes a SupersetClientResponse as input, attempts to read response as Json if possible,
   // and returns a Promise that resolves to a plain object with error key and text value.
@@ -52,7 +56,7 @@ export default function getClientErrorObject(
         responseObject
           .clone()
           .json()
-          .then(errorJson => {
+          .then((errorJson: any) => {
             let error = { ...responseObject, ...errorJson };
 
             // Backwards compatibility for old error renderers with the new error object
@@ -83,7 +87,7 @@ export default function getClientErrorObject(
           })
           .catch(() => {
             // fall back to reading as text
-            responseObject.text().then(errorText => {
+            responseObject.text().then((errorText: any) => {
               resolve({ ...responseObject, error: errorText });
             });
           });
@@ -122,10 +126,11 @@ export default function getClientErrorObject(
         });
       } else {
         // fall back to Response.statusText or generic error of we cannot read the response
+        console.error('non-standard error:', response);
         const error =
-          'statusText' in response
-            ? response.statusText
-            : t('An error occurred');
+          (response as any).statusText ||
+          (response as any).message ||
+          t('An error occurred');
         resolve({
           ...responseObject,
           error,