Develop a Geo Tour Plugin ************************* In this section we will dive into the creation of a plugin and the possibilities are many so we can take this opportunity to learn the important stuff In our case we will do more by including some advanced topics like **plugin container** - **How to use a plugin container** like sidebar menu - **Extend plugin by adding** actions, reducers, selectors, epics - **Add custom theme** which will include theme parts for the GeoTour plugin - **Add unit tests** of the codebase added - **Add documentation** of the codebase added - **How to render a plugin conditionally** using monitored state - **Extending or customizing** lint configuration Setup the development environment ================================= | We assume you have a working local project and for more details of it | check :ref:`this ` part Since we want to develop things, let's developing let's start our dev instance .. code-block:: shell npm run fe:start In a different shell start up the backend .. code-block:: shell npm run be:start Now navigate with your favorite browsers to http://localhost:8081/?debug=true#/ Create a new plugin =================== A **plugin** is the main development unit used to add functionalities to MapStore Normally to develop a plugin you have to do 3 things: - **Create plugin file** with the code of the plugin, the exports plugin in the defined format (*e.g.* ``js/plugins/GeoTour.jsx``) - **Add the definition** to your ``js/plugins.js`` file. - **Configure** it in ``configs/localConfig.json`` in the dedicated section for a page (e.g. ``plugins.desktop``) These operations will make your new plugin available for your page (**desktop** is associated to the main mapstore viewer). Create plugin file ------------------ Let's create a new plugin in our project | Create this file, called **GeoTour.jsx** in ``js/plugins`` folder | (or ``web/client/plugins`` if you are working on the core). .. code-block:: shell mkdir js/plugins cd js/plugins touch GeoTour.jsx Copy the following in ``js/plugins/GeoTour.jsx`` .. code-block:: javascript // js/plugins/GeoTour.jsx import React from "react"; import { Glyphicon } from "react-bootstrap"; import { createSelector } from "reselect"; import { connect, createPlugin } from "@mapstore/utils/PluginsUtils"; import Dialog from "@mapstore/components/misc/Dialog"; import Message from "@mapstore/components/I18N/Message"; import { setControlProperty } from "@mapstore/actions/controls"; import { geotourEnabledSelector } from '@js/selectors/geotour'; /** * The GeoTour plugin allows you to * @memberof plugins * @prop {object} cfg properties that can be configured for this plugin * @prop {object} [cfg.width=500] width of the panel */ const PanelContainer = ({ // local properties id = "geo-tour-plugin", width = 500, // coming from the connect (connected to the store) enabled = true, // coming from the connect function (mapDispatchToProps) onClose = () => {} }) => { return enabled ? (
This will be an awesome plugin
) : null; }; const ConnectedPlugin = connect(createSelector([ state => state?.controls?.geotour?.enabled ], (enabled) => ({ enabled })), { onClose: setControlProperty.bind(null, "geotour", "enabled", false, false) })(PanelContainer); // export the plugin using the createPlugin utils which is the recommended way export default createPlugin("GeoTour", { component: ConnectedPlugin }); This is the simplest plugin you can imagine (we are going to add more soon :) .. note:: You will find several ways how plugins are exported inside the framework and inside the code and documentation. The suggested way is to use the ``createPlugin`` function from ``web/client/utils/pluginUtils`` Add the definition ------------------ .. code-block:: shell cd js touch plugins.js .. code-block:: javascript // js/plugins.js import GeoTour from "@js/plugins/GeoTour"; import productPlugins from "@mapstore/product/plugins.js"; /** * This will compose the list of plugins that can be accessed from your application, and that wil lbe included in the bundle */ export default { requires: { ...productPlugins.requires }, plugins: { ...productPlugins.plugins, GeoTour } }; Edit ``js/app.jsx`` in order to use a custom version of ``localConfig.json`` and use a custom list of plugins .. code-block:: diff + import plugins from '@js/plugins.js'; - const plugins = require('@mapstore/product/plugins').default; + ConfigUtils.setLocalConfigurationFile('configs/localConfig.json'); - ConfigUtils.setLocalConfigurationFile('MapStore2/web/client/configs/localConfig.json'); - ConfigUtils.setConfigProp('translationsPath', ['./MapStore2/web/client/translations']); + ConfigUtils.setConfigProp('translationsPath', ['./MapStore2/web/client/translations', './translations']); since we are preparing the ground for custom translations let's add them already .. code-block:: shell # from the root mkdir translations cd translations touch data.en-US.json add the following to the ``translations/data.en-US.json`` .. code-block:: json { "locale": "en-US", "messages": { "geotour": { "name": "GeoTour" } } } In this way we will extend/override official translations coming from mapstore Configure plugin ---------------- ``configs/localConfig.json`` is the main config file used by mapstore that allows you to chose which plugins can be rendered and where. It allows you to edit other properties used globally like, projection definitions, custom cors rules, default catalog services etc., you can refer to `this `__ guide to know more about it This file has an object called *plugins* and it will contain all the mode/page used by mapstore to render adding to ``plugins --> desktop`` (for now) .. code-block:: diff - "LayerDownload", + "LayerDownload", + { + "name": "GeoTour" + }, .. note:: Whenever you edit & save this config file, you have to refresh the page to see updates Add functionalities =================== Plugin container ---------------- A plugin container is a component (usually in a plugin or a page) that can contain other plugin's parts and render them accordingly. For instance: - a toolbar like navigation bar or the sidebar - a dropdown menu like the burger menu - a panel like the drawer menu In order to make a button appear in the sidebar menu we have apply the following changes to the ``js/plugins/GeoTour.jsx`` file .. code-block:: diff - import { Panel } from 'react-bootstrap'; + import { Panel, Glyphicon } from 'react-bootstrap'; - import { setControlProperty } from '@mapstore/actions/controls'; + import { toggleControl, setControlProperty } from "@mapstore/actions/controls"; - component: ConnectedPlugin + component: ConnectedPlugin, + containers: { + SidebarMenu: { + name: 'geotour', + position: 10000, // this will put the button at the bottom of the list + tooltip: , + icon: , + action: toggleControl.bind(null, 'geotour', null) + } + } In this way we will render a button that will toggle visibility of the geotour plugin and it will interact with the state.controls.geotour.enabled flag - *icon* icon component to use, icons available can be checked in this `glyphicon set `__ - *tooltip* optional text to use that will be rendered in the list, after the icon - *position* is number that will define the order of items in the list - *action* is an action to dispatch when the item in the burger menu is clicked Component --------- Since we are talking about a lot of changes, let's apply them first, then we will explain them. .. code-block:: javascript // js/plugins/GeoTour.jsx import React, {useState} from "react"; import { Button, Panel, Glyphicon } from "react-bootstrap"; import { createSelector } from "reselect"; import Dropzone from 'react-dropzone'; import bbox from '@turf/bbox'; import { connect, createPlugin } from "@mapstore/utils/PluginsUtils"; import Dialog from "@mapstore/components/misc/Dialog"; import Message from "@mapstore/components/I18N/Message"; import BorderLayout from "@mapstore/components/layout/BorderLayout"; import { toggleControl, setControlProperty } from "@mapstore/actions/controls"; import { readJson } from '@mapstore/utils/FileUtils'; import { updateAdditionalLayer } from '@mapstore/actions/additionallayers'; import { zoomToExtent } from '@mapstore/actions/map'; import { uploadFile } from '@js/actions/geotour'; import { geotourFileSelector, geotourEnabledSelector, geotourReverseGeocodeDataSelector } from '@js/selectors/geotour'; import geotour from '@js/reducers/geotour'; import * as geotourEpics from '@js/epics/geotour'; /** * The GeoTour plugin allows you to * @memberof plugins * @prop {object} cfg properties that can be configured for this plugin * @prop {object} [cfg.showCloseButton=false] if true, it shows the close button, default is false */ const PanelContainer = ({ // local properties id = "geo-tour-plugin", dropMessage = "Drop here a geojson that can be used to fly to its features", // coming from the configuration cfg object of the plugin, defined in localConfig.plugins.[desktop|tour|embedded]... showCloseButton = false, // coming from the connect (connected to the store) enabled = false, file = null, reverseGeocodeData = "", // coming from the connect (connected to the store) // coming from the connect function (mapDispatchToProps) onClose = () => {}, onUpload = () => {}, addMarkers = () => {}, flyTo = () => {} }) => { const [flyToEnabled, setFlyToEnabled] = useState(false); const [size, setSize] = useState({width: 500, height: 500}); const uploadFiles = (files) => { if (!files) return; const fileToParse = files[0]; readJson(fileToParse).then(f => { onUpload({...f}); addMarkers( "geotour-layer", "goutour", 'overlay', { id: "geotour-layer", name: "geotour-layer", type: "vector", features: f.features.map(ft => ({ ...ft, style: { "iconGlyph": "comment", "iconShape": "square", "iconColor": "blue", "iconAnchor": [ 0.5, 0.5], "filtering": true } })) }); }); }; return enabled ? (
{` ${dropMessage}`}
{file ? <>
{ file.features.map((ft, index) => { return (); }) }
action # geometry type coordinates
{index + 1} {ft.geometry.type} {ft.geometry.coordinates.toString()}

Click next button in order to {flyToEnabled ? "disable" : "enable"} fly to

{ reverseGeocodeData ? ( <>

ReverseGeocodeData address

  • Region: {reverseGeocodeData?.address?.state || "N.A." }
  • Country: {reverseGeocodeData?.address?.country || "N.A." }
  • City: {reverseGeocodeData?.address?.city || "N.A." }
  • ) : null } : null }
    ) : null; }; const ConnectedPlugin = connect(createSelector([ geotourEnabledSelector, geotourFileSelector, geotourReverseGeocodeDataSelector ], (enabled, file, reverseGeocodeData) => ({ enabled, file, reverseGeocodeData })), { onClose: setControlProperty.bind(null, "geotour", "enabled", false, false), onUpload: uploadFile, addMarkers: updateAdditionalLayer, flyTo: zoomToExtent })(PanelContainer); // export the plugin using the createPlugin utils which is the recommended way export default createPlugin("GeoTour", { component: ConnectedPlugin, containers: { SidebarMenu: { name: "geotour", position: 10000, tooltip: , icon: , action: toggleControl.bind(null, "geotour", null) } }, reducers: { geotour }, epics: geotourEpics }); The ```` component has an **onDrop** function that will bring the files dropped this files are passed to an uploadFiles function which assumes it is a json and then uses a readJson utility function present in mapstore framework Once we have these information we use an action (updateAdditionalLayer) coming from mapstore framework to create a layer that will only be rendered and not added in the toc. They are called additionalLayers We also push the content of the geojson dropped with **uploadFile** action that we are going to create If we wanted to add them to the TOC too we had to used a different action called **addLayer** from ``@mapstore/actions/layers`` In order to interact with the state we need to connect These functions in the final part of the connect function .. code:: javascript })), { onClose: setControlProperty.bind(null, "geotour", "enabled", false, false), onUpload: uploadFile, addMarkers: updateAdditionalLayer, flyTo: zoomToExtent })(PanelContainer); If you have noticed we use also internal or local state for managing the status of some action buttons in the table since this information is not relevant across the application we decided to use the dedicated react hooks and use a local state .. code:: javascript const [flyToEnabled, setFlyToEnabled] = useState(false); Also take a note on the addition to the createPlugin function because this is the way you can add reducers and epics to a plugin .. code:: javascript reducers: { geotour }, epics: geotourEpics .. note:: | Note that you can add multiple reducers to a plugin | At the end all reducers of all plugins plus some standard one are merged together to form the store Now let's prepare the ground for the other changes we need .. code-block:: shell # from the root mkdir js/actions mkdir js/reducers mkdir js/selectors mkdir js/epics touch js/actions/geotour.js touch js/reducers/geotour.js touch js/selectors/geotour.js touch js/epics/geotour.js Reducer ------- Since our application has a global store, which is a js object, we use reducers that are functions that manipulate a particular piece of the store. A reducer accept as parameters a state and an action object and returns a new state The reason we store things in the store through reducers and actions is because this information can be globally accessed across the entire application put this in the ``js/reducers/geotour.js`` .. code-block:: javascript // js/reducers/geotour.js import { SHOW_REVERSE_GEOCODE } from '@mapstore/actions/mapInfo'; import { GEOTOUR_UPLOAD_FILE } from '@js/actions/geotour'; export const geotour = (state = {}, action) => { switch (action.type) { case GEOTOUR_UPLOAD_FILE: { return { ...state, file: action.file }; } case SHOW_REVERSE_GEOCODE: { return { ...state, reverseGeocodeData: action.reverseGeocodeData }; } default: return state; } }; export default geotour; This file is pretty simple. It's a normal switch that based on the action type manipulates the state accordingly. The GEOTOUR_UPLOAD_FILE constant is usually stored near the action creator and it is used here to store the file dropped in the dropzone **Note**: Check the console or redux dev tool later for this The SHOW_REVERSE_GEOCODE justs store information coming from a request sent to Nominatim as we will see in the epics file .. note:: Never mutate objects in the state, always return new objects Actions ------- The actions are simply functions that allows you to manipulate the store by describing how the manipulation should happen Add the following to ``js/actions/geotour.js`` .. code-block:: javascript export const GEOTOUR_UPLOAD_FILE = "GEOTOUR:GEOTOUR_UPLOAD_FILE"; export const uploadFile = (file) => ({ type: GEOTOUR_UPLOAD_FILE, file }); Selectors --------- Add the following to ``js/selectors/geotour.js`` .. code-block:: javascript export const geotourReverseGeocodeDataSelector = state => state?.geotour?.reverseGeocodeData; export const geotourFileSelector = state => state?.geotour?.file; export const geotourEnabledSelector = state => state?.controls?.geotour?.enabled; A selector is a function that takes the state as argument in and returns something, it can be reused in different places like in epics or other plugins so it is suggested to place them in a dedicated folder Epics ----- Epics are basically manipulation of action events, for handling side effects check here the `Detailed explanation `__ or in the `developer doc here `__ Add the following to ``js/epics/geotour.js`` .. code-block:: javascript import Rx from 'rxjs'; import { ZOOM_TO_EXTENT } from '@mapstore/actions/map'; import { showMapinfoRevGeocode } from '@mapstore/actions/mapInfo'; /** * Epics for geotour plugin. Intercept `ZOOM_TO_EXTENT` action and dispatch a `showMapinfoRevGeocode` action. * The `showMapinfoRevGeocode` is a thunk action that invokes the GeoCodingApi, reverse GeoCodes the given lat-lon and emits * an action of type `SHOW_REVERSE_GEOCODE` with the result. export const addFeatureInfoEpic = (action$) => action$.ofType(ZOOM_TO_EXTENT) .switchMap( (action) => { return Rx.Observable.of( [showMapinfoRevGeocode({lng: action.options.point[0], lat: action.options.point[1] })] ); } ); Here we react to the ZOOM_TO_EXTENT action in order to call Nominatim and to obtain reverse geocode data from a point **Caveat**: this will be triggered every time a ZOOM_TO_EXTENT action is dispatched so is better to dispatch another action when clicking on the fly to that can be listened here and: - either add the dispatch of the zoomToExtent from here - or create a thunk action and dispatch two actions there one for updating the reverse geocode data and one for the zoomToExtent Here we are returning the showMapinfoRevGeocode which will be dispatched by the middleware we have in mapstore core. Notice that this is a thunk and thunk can be used to fetch asynchronous data Another way is to uses the `RxJs.defer `_ like has been done here for `contextcreator epics `__ Test file --------- for testing our plugin we need a sample file to upload. So let's create it in ``assets`` folder .. code-block:: shell # from the root touch assets/markers.json Add the following to ``assets/markers.json`` .. code-block:: json { "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": {}, "geometry": { "type": "Point", "coordinates": [ 0.1318359375, 43.77109381775651 ] } }, { "type": "Feature", "properties": {}, "geometry": { "type": "Point", "coordinates": [ 9.217529296875, 45.47554027158593 ] } }, { "type": "Feature", "properties": {}, "geometry": { "type": "Point", "coordinates": [ 21.0498046875, 44.62175409623324 ] } } ] } Create a Custom theme ===================== Let's include the needed style as part of the theme as explained `here `__ .. code-block:: shell # go to the root folder of your project (where package.json is located) cd .. # create needed folders mkdir themes/geo-theme/less -p # create needed files touch themes/geo-theme/theme.less touch themes/geo-theme/variables.less touch themes/geo-theme/less/geo-tour.less Inside ``themes/geo-theme/theme.less`` put .. code-block:: less // themes/geo-theme/theme.less @import "../../MapStore2/web/client/themes/default/theme.less"; @import "./variables.less"; @import "./less/geo-tour.less"; Inside ``themes/geo-theme/variables.less`` put .. code-block:: less /* change primary color to orange */ @ms-primary: #ff7b00; Inside ``themes/geo-theme/less/geo-tour.less`` put .. code-block:: less // ************** // Theme // ************** #ms-components-theme(@theme-vars) { // here all the selectors related to the theme // use the mixins declared in the web/client/theme/default/less/mixins/css-properties.less // to use one of the @theme-vars // eg: use the main background and text colors to paint my plugin /* .my-plugin-class { .color-var(@theme-vars[main-color]); .background-color-var(@theme-vars[main-bg]); }*/ } // ************** // Layout // ************** // eg: fill all the available space in the parent container with my plugin #page-tour { #map { bottom: 0; } } #geo-tour-plugin { .geotour-table-results { td { padding: 10px; } .table-geom-type { display: flex; width: 130px; } } } Update webpack configuration to use the custom style (``webpack.config.js``, ``prod-webpack.config.js``) .. code-block:: diff - themeEntries, + themeEntries: { + "themes/default": path.join(__dirname, "themes", "geo-theme", "theme.less") + }, Once reached this point, you can load the viewer of mapstore and see the plugin being rendered. .. figure:: img/example-geotour-page.jpg :alt: example-geotour-plugin First rendering of the Plugin .. note:: Mapstore uses bootstrap and react-bootstrap components for creating the UI, most of the times is enough to pick them or to reuse components from ``@mapstore/components/misc`` Extra topics ============ Documentation ------------- Plugins (but also actions, reducers, selectors and epics) documentation is defined with `JsDoc `_ and generated using generated using `docma `_ Mapstore documentation is then `hosted here `_ Files included in the docma documentation are included by the ``docma-config.json`` and this is not available in mapstore projects but only in the framework See `here `_ for more details about building the framework documentation with docma If you check the ``js/plugins/GeoTour.jsx`` you will get an idea on how to add documentation Unit tests ---------- Tests in mapstore can be checked in two ways: - by running them all with **npm test** - by running a subset of tests in watch mode with **npm run test:watch** First let's create a test for our selectors, actions, reducers and plugin .. code-block:: shell # from the root mkdir js/selectors/__tests__ touch js/selectors/__tests__/geotour-test.js mkdir js/actions/__tests__ touch js/actions/__tests__/geotour-test.js mkdir js/reducers/__tests__ touch js/reducers/__tests__/geotour-test.js mkdir js/plugins/__tests__ touch js/plugins/__tests__/GeoTour-test.js Inside ``js/selectors/__tests__/geotour-test.js`` put .. code-block:: javascript import expect from 'expect'; import { geotourFileSelector, geotourEnabledSelector } from '@js/selectors/geotour'; describe("geotour selectors tests", () => { it("geotourFileSelector test", () => { expect(geotourFileSelector({})).toEqual(undefined); }); it("geotourEnabledSelector test", () => { expect(geotourEnabledSelector({})).toEqual(undefined); }); }); Inside ``js/actions/__tests__/geotour-test.js`` put .. code-block:: javascript import expect from 'expect'; import { uploadFile, GEOTOUR_UPLOAD_FILE } from '@js/actions/geotour'; describe("geotour actions tests", () => { it("uploadFile test", () => { const file = {}; expect(uploadFile( file )).toEqual({ type: GEOTOUR_UPLOAD_FILE, file }); }); }); Inside ``js/reducers/__tests__/geotour-test.js`` put .. code-block:: javascript import expect from 'expect'; import { uploadFile } from '@js/actions/geotour'; import geotour from '@js/reducers/geotour'; describe("geotour reducers tests", () => { it("uploadFile test", () => { const file = { type: "FeatureCollection" }; const initialState = {}; let state = geotour(initialState, uploadFile(file)); expect(state).toBeTruthy(); expect(state).toEqual({ file }); }); it.skip("TODO SHOW_REVERSE_GEOCODE test", () => { // complete this test autonomously const file = { type: "FeatureCollection" }; const initialState = {}; let state = geotour(initialState, uploadFile(file)); expect(state).toBeTruthy(); expect(state).toEqual({ file }); }); }); Inside ``js/plugins/__tests__/GeoTour-test.js`` put .. code-block:: javascript import expect from 'expect'; import React from 'react'; import ReactDOM from 'react-dom'; import GeoTourPlugin from '@js/plugins/GeoTour'; import { getPluginForTest } from '@mapstore/plugins/__tests__/pluginsTestUtils'; describe('Geostories Plugin', () => { beforeEach((done) => { document.body.innerHTML = '
    '; setTimeout(done); }); afterEach((done) => { ReactDOM.unmountComponentAtNode(document.getElementById("container")); document.body.innerHTML = ''; setTimeout(done); }); it('creates GeoTour plugin with default configuration', () => { const { Plugin } = getPluginForTest(GeoTourPlugin, { controls: { geotour: { enabled: true } } }); ReactDOM.render(, document.getElementById("container")); const buttons = document.querySelectorAll('.glyphicon-upload'); expect(buttons.length).toBe(1); }); }); We have added all of our tests, now try to run .. code-block:: shell npm test You will see something like this .. note:: In version 2023.01.00 the test will fail because of a bug. You can workaround the bug by removing the istanbul instrumenter rule from ``karma.conf.single-run.js`` This will not run the coverage report but will allow you to run the tests. .. code-block:: diff - testConfig.webpack.module.rules = [{ - test: /\.jsx?$/, - exclude: /(__tests__|node_modules|legacy|libs\\Cesium|libs\\html2canvas)\\|(__tests__|node_modules|legacy|libs\/Cesium|libs\/html2canvas)\/|webpack\.js|utils\/(openlayers|leaflet)/, - enforce: "post", - use: [ - { - loader: 'istanbul-instrumenter-loader', - options: { esModules: true } - } - ] - }, ...testConfig.webpack.module.rules]; .. figure:: img/npm-test.png :alt: npm-test result of execution of command *npm test* open it in your browser Whenever you run tests wth this command "npm test" a report is generated inside ``coverage/report-html/index.html`` .. figure:: img/report-html.png :alt: report-html report of the coverage this is helpful to know which are the parts less covered by unit tests Q: What if you have a big list if tests and you want to check only the one you are adding? A: Use ``npm run test:watch`` in combination with edits to ``tests.webpack.js`` like this .. code-block:: diff - var context = require.context('./js', true, /-test\.jsx?$/); + var context = require.context('./js/reducers', true, /-test\.jsx?$/); in this way when you will run ``npm run test:watch`` you will tests only files inside this folder with the following you can also specify which is the filename of the test file you are gonna test, this will speed up things .. code-block:: diff - var context = require.context('./js/reducers', true, /-test\.jsx?$/); + var context = require.context('./js/reducers', true, /geotour-test\.jsx?$/); You can also add .skip or .only to the it or describe function to respectively skip tests or chose only which one will be run by the test runner Conditional rendering --------------------- Sometimes happens that you want a plugin to be rendered based on a particular situation, like the present of an authenticated user or the url path you are in Some other times you want the opposite to hide a plugin based on a particular situation. For this you can add to your plugin definition the following .. code-block:: diff - export default createPlugin("GeoTour", { + export default createPlugin("GeoTour", { + options: { + disablePluginIf: "{state('userrole') !== 'ADMIN'}" + }, This will automatically disable this plugin when you user is not an administrator. This is useful when you want to create a plugin that only administrators can use. .. note:: you cannot uses everything inside this state function, only the state parts of the state defined in the ``configs/localconfig.json`` at monitoredState object Customize linter rules ---------------------- Linter configuration of MapStore is stored in a specific folder and is `published in npm `__ You can check the eslintConfig in ``package.json`` to see how it's included for example if you hate double quotes you can add the following to disable the related lint rules .. code-block:: diff "eslintConfig": { "extends": [ "@mapstore/eslint-config-mapstore" ], "parserOptions": { "babelOptions": { "configFile": "./MapStore2/build/babel.config.js" } }, + "rules": { + "quotes": [ "error","double" ] + } }, .. toctree:: :maxdepth: 1 :caption: Contents: