Skip to content

Commit

Permalink
#8423 Modular plugins (#8457)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-fedorenko authored Aug 11, 2022
1 parent bc98bd2 commit 4005b4e
Show file tree
Hide file tree
Showing 39 changed files with 1,080 additions and 370 deletions.
34 changes: 31 additions & 3 deletions docs/developer-guide/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ As you can see from the code, the most important difference is that you need to
The extension definition will import or define all the needed dependencies (components, reducers, epics) as well as the plugin configuration elements
(e.g. containers).

### Dynamic import of extension

MapStore supports dynamic import of plugins and extensions.
In this case, plugin or extension is loaded when app demands to render it for the first time.

There are few changes required to make extension loaded dynamically:

1. Create `Module.jsx` file in `js/extension/plugins/` and populate it with `js/extension/plugins/Extension.jsx` content.
2. Update content of `js/extension/plugins/Extension.jsx` to be like:
```jsx
import {toModulePlugin} from "@mapstore/utils/ModulePluginsUtils";
import { name } from '../../../config';

export default toModulePlugin(name, () => import(/* webpackChunkName: 'extensionName' */ './Module'));
```
3. Update `js/extensions.js` and remove `createPlugin` wrapper from `Extension` export. File content should look like:
```js
import Extension from './extension/plugins/Extension';
import { name } from '../config';


export default {
[name]: Extension
};
```

### Distributing your extension as an uploadable module

The sample project allow you to create the final zip file for you.
Expand Down Expand Up @@ -131,20 +157,20 @@ The `index.json file should contain all the information about the extension:
### Installing Extensions

Extensions can be uploaded using the context creator UI of MapStore. The storage and configuration of the uploaded zip bundle is managed by a dedicated MapStore backend service, the ***Upload Service***.
The Upload Service is responsible of unzipping the bundle, storing javascript and the other extension assets in the extensions folder and updating the configuration files needed by MapStore to use the extension:
The Upload Service is responsible for unzipping the bundle, storing javascript and the other extension assets in the extensions folder and updating the configuration files needed by MapStore to use the extension:

* `extensions.json` (the extensions registry)
* `pluginsConfig.json.patch` (the context creator plugins catalog patch file)

### Extensions and datadir

Extensions work better if you use a [datadir](externalized-configuration.md), because when a datadir is configured,
extensions are uploaded inside it so they can ***live*** outside of the application main folder (and you don't risk to overwrite them when
extensions are uploaded inside it, so they can ***live*** outside the application main folder (and you don't risk to overwrite them when
you upgrade MapStore to a newer version).

### Extensions for dependent projects

Extensions build in MapStore actually can run only in MapStore product. They can not be installed in dependent projects. If you have a custom project and you want to add support for extensions, you will have to create your build system for extensions dedicated to your application, to build the Javascript with the correct paths.
Extensions build in MapStore actually can run only in MapStore product. They can not be installed in dependent projects. If you have a custom project, and you want to add support for extensions, you will have to create your build system for extensions dedicated to your application, to build the Javascript with the correct paths.
Moreover, to enable extensions to work with the datadir in a dependent project (MapStore product is already configured to use it) you need to configure (or customize) the following configuration properties in your `app.jsx`:

#### Externalize the extensions configuration
Expand Down Expand Up @@ -179,7 +205,9 @@ Assets are loaded using a different service, `/rest/config/loadasset`.
Extension could implement drawing interactions, and it's necessary to prevent a situation when multiple tools from different plugins or extensions have active drawing, otherwise it could end up in an unpredicted or buggy behavior.

There are two ways how drawing interaction can be implemented in plugin or extension:

- Using DrawSupport (e.g. Annotations plugin)

- By intercepting click on the map interactions (e.g. Measure plugin)

### Making another plugins aware of your extension starts drawing
Expand Down
33 changes: 33 additions & 0 deletions docs/developer-guide/writing-epics.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,36 @@ const fetchDataEpic = (action$, store) => action$
map(response => dataFetched(response.data))
);
```

### Muted epics: how to mute internal streams

MapStore will mute all the epics whenever corresponding plugin or extension is not rendered on the page.
Though, it might be the case that one of your epics will return internal stream, like in example below:

```js
export const dummyEpic = (action$, store) => action$.ofType(ACTION)
.switchMap(() => {
return Rx.Observable.interval(1000)
.switchMap(() => {
console.log('TEST');
return Rx.Observable.empty();
});
});
```

In this case, internal stream should be muted explicitly.

Each epic receives third argument called `isActive$`.
Combined with `semaphore` it allows to mute internal stream whenever epic is muted:

```js
export const dummyEpic = (action$, store, isActive$) => action$.ofType(ACTION)
.switchMap(() => {
return Rx.Observable.interval(1000)
.let(semaphore(isActive$.startWith(true)))
.switchMap(() => {
console.log('TEST');
return Rx.Observable.empty();
});
});
```
4 changes: 3 additions & 1 deletion web/client/components/app/StandardRouter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ class StandardRouter extends React.Component {
version: PropTypes.string,
loadAfterTheme: PropTypes.bool,
themeLoaded: PropTypes.bool,
onThemeLoaded: PropTypes.func
onThemeLoaded: PropTypes.func,
loaderComponent: PropTypes.func
};

static defaultProps = {
Expand All @@ -55,6 +56,7 @@ class StandardRouter extends React.Component {
const pageConfig = page.pageConfig || {};
const Component = connect(() => ({
plugins: this.props.plugins,
loaderComponent: this.props.loaderComponent,
...pageConfig
}))(page.component);
return <Route key={(page.name || page.path) + i} exact path={page.path} component={Component}/>;
Expand Down
82 changes: 77 additions & 5 deletions web/client/components/app/__tests__/withExtensions-test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ describe('StandardApp withExtensions', () => {
},
subscribe() {
},
replaceReducer: () => { }
replaceReducer: () => { },
storeManager: {
reduce: () => {},
addReducer: () => {},
removeReducer: () => {},
addEpics: () => {},
muteEpics: () => {},
unmuteEpics: () => {},
rootEpic: () => {}
}
});
setStore(store());
const MyApp = ({ plugins }) => {
Expand All @@ -79,6 +88,15 @@ describe('StandardApp withExtensions', () => {
return {};
},
subscribe() {
},
storeManager: {
reduce: () => {},
addReducer: () => {},
removeReducer: () => {},
addEpics: () => {},
muteEpics: () => {},
unmuteEpics: () => {},
rootEpic: () => {}
}
});

Expand All @@ -99,6 +117,15 @@ describe('StandardApp withExtensions', () => {
return {};
},
subscribe() {
},
storeManager: {
reduce: () => {},
addReducer: () => {},
removeReducer: () => {},
addEpics: () => {},
muteEpics: () => {},
unmuteEpics: () => {},
rootEpic: () => {}
}
});

Expand All @@ -125,6 +152,15 @@ describe('StandardApp withExtensions', () => {
listener({
type: LOAD_EXTENSIONS
});
},
storeManager: {
reduce: () => {},
addReducer: () => {},
removeReducer: () => {},
addEpics: () => {},
muteEpics: () => {},
unmuteEpics: () => {},
rootEpic: () => {}
}
});

Expand Down Expand Up @@ -165,7 +201,16 @@ describe('StandardApp withExtensions', () => {
}
});
},
replaceReducer: () => { }
replaceReducer: () => { },
storeManager: {
reduce: () => {},
addReducer: () => {},
removeReducer: () => {},
addEpics: () => {},
muteEpics: () => {},
unmuteEpics: () => {},
rootEpic: () => {}
}

});
setStore(store());
Expand Down Expand Up @@ -199,7 +244,16 @@ describe('StandardApp withExtensions', () => {
plugin: "My"
});
},
replaceReducer: () => { }
replaceReducer: () => { },
storeManager: {
reduce: () => {},
addReducer: () => {},
removeReducer: () => {},
addEpics: () => {},
muteEpics: () => {},
unmuteEpics: () => {},
rootEpic: () => {}
}

});
setStore(store());
Expand Down Expand Up @@ -228,7 +282,16 @@ describe('StandardApp withExtensions', () => {
},
subscribe() {
},
replaceReducer() { }
replaceReducer() { },
storeManager: {
reduce: () => {},
addReducer: () => {},
removeReducer: () => {},
addEpics: () => {},
muteEpics: () => {},
unmuteEpics: () => {},
rootEpic: () => {}
}
});
ConfigUtils.setConfigProp("persisted.reduxStore", store());

Expand Down Expand Up @@ -260,7 +323,16 @@ describe('StandardApp withExtensions', () => {
},
subscribe() {
},
replaceReducer() { }
replaceReducer() { },
storeManager: {
reduce: () => {},
addReducer: () => {},
removeReducer: () => {},
addEpics: () => {},
muteEpics: () => {},
unmuteEpics: () => {},
rootEpic: () => {}
}
});
ConfigUtils.setConfigProp("persisted.reduxStore", store());
const MyApp = ({ plugins }) => {
Expand Down
18 changes: 14 additions & 4 deletions web/client/components/app/withExtensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import castArray from 'lodash/castArray';
import axios from '../../libs/ajax';
import ConfigUtils from '../../utils/ConfigUtils';
import PluginsUtils from '../../utils/PluginsUtils';
import { augmentStore } from '../../utils/StateUtils';
import { LOAD_EXTENSIONS, PLUGIN_UNINSTALLED } from '../../actions/contextcreator';

/**
Expand Down Expand Up @@ -93,7 +92,7 @@ function withExtensions(AppComponent) {
} else {
afterInit(config);
}
});
}, store);
};

getAssetPath = (asset) => {
Expand Down Expand Up @@ -145,15 +144,23 @@ function withExtensions(AppComponent) {
}, {});
};

loadExtensions = (path, callback) => {
loadExtensions = (path, callback, store) => {
if (this.props.enableExtensions) {
let reducersLoaded = false;
return axios.get(path).then((response) => {
const plugins = response.data;
Promise.all(Object.keys(plugins).map((pluginName) => {
const bundlePath = this.getAssetPath(plugins[pluginName].bundle);
return PluginsUtils.loadPlugin(bundlePath, pluginName).then((loaded) => {
const impl = loaded.plugin?.default ?? loaded.plugin;
augmentStore({ reducers: impl?.reducers ?? {}, epics: impl?.epics ?? {} });

if (impl?.isModulePlugin) {
return { plugin: {[pluginName + 'Plugin']: impl}, translations: plugins[pluginName].translations || ""};
}

Object.keys(impl?.reducers ?? {}).forEach((name) => store.storeManager.addReducer(name, impl.reducers[name]));
store.storeManager.addEpics(impl.name, impl?.epics ?? {});
reducersLoaded = true;
const pluginDef = {
[pluginName + "Plugin"]: {
[pluginName + "Plugin"]: {
Expand All @@ -170,6 +177,9 @@ function withExtensions(AppComponent) {
return null;
});
})).then((loaded) => {
if (reducersLoaded) {
store.dispatch({type: 'REDUCERS_LOADED'});
}
callback(
loaded
.filter(l => l !== null) // exclude extensions that triggered errors
Expand Down
45 changes: 30 additions & 15 deletions web/client/components/plugins/__tests__/PluginsContainer-test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ const pluginsCfgRef = {
]
};

const store = {
dispatch: () => {},
subscribe: () => {},
getState: () => ({})
};

describe('PluginsContainer', () => {
beforeEach((done) => {
document.body.innerHTML = '<div id="container"></div>';
Expand All @@ -110,8 +116,11 @@ describe('PluginsContainer', () => {
});

it('checks filterDisabledPlugins one disabled', () => {
const cmp = ReactDOM.render(<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfg}/>, document.getElementById("container"));
const cmp = ReactDOM.render(
<Provider store={store}>
<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfg}/>
</Provider>, document.getElementById("container"));
expect(cmp).toExist();

const cmpDom = ReactDOM.findDOMNode(cmp);
Expand All @@ -122,8 +131,11 @@ describe('PluginsContainer', () => {
});

it('checks filterDisabledPlugins no disabled', () => {
const cmp = ReactDOM.render(<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfg2} />, document.getElementById("container"));
const cmp = ReactDOM.render(
<Provider store={store}>
<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfg2} />
</Provider>, document.getElementById("container"));
expect(cmp).toExist();

const cmpDom = ReactDOM.findDOMNode(cmp);
Expand All @@ -134,8 +146,11 @@ describe('PluginsContainer', () => {
});
it('test noRoot option disable root rendering of plugins', () => {
// Not rendered without container
let cmp = ReactDOM.render(<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfg3} />, document.getElementById("container"));
let cmp = ReactDOM.render(
<Provider store={store}>
<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfg3} />
</Provider>, document.getElementById("container"));
expect(cmp).toExist();

let cmpDom = ReactDOM.findDOMNode(cmp);
Expand All @@ -145,8 +160,11 @@ describe('PluginsContainer', () => {

// rendered in container
expect(rendered.length).toBe(1);
cmp = ReactDOM.render(<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfg4} />, document.getElementById("container"));
cmp = ReactDOM.render(
<Provider store={store}>
<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfg4} />
</Provider>, document.getElementById("container"));
expect(cmp).toExist();

cmpDom = ReactDOM.findDOMNode(cmp);
Expand All @@ -157,13 +175,10 @@ describe('PluginsContainer', () => {
expect(document.getElementById('no-root')).toExist();
});
it('checks plugin with forwardRef = true connect option', () => {
const store = {
dispatch: () => {},
subscribe: () => {},
getState: () => ({})
};
const app = ReactDOM.render(<Provider store={store}><PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfgRef}/></Provider>, document.getElementById("container"));
const app = ReactDOM.render(<Provider store={store}>
<PluginsContainer mode="desktop" defaultMode="desktop" params={{}}
plugins={plugins} pluginsConfig={pluginsCfgRef}/>
</Provider>, document.getElementById("container"));

expect(app).toExist();
expect(window.WithGlobalRefPlugin.myFunc).toExist();
Expand Down
Loading

0 comments on commit 4005b4e

Please sign in to comment.