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 <[email protected]> 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)", + }
