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

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 the page (e.g. plugins.desktop)

These operations will make your new plugin available for your page (desktop is associated to the main mapstore viewer).

In our case we will do more by including some advanced topics like plugin container and pages

  • Add custom theme which will include theme parts for the GeoTour plugin
  • How to use a plugin container like burger menu
  • Create a “tour” page that can be point at http://localhost:8081/tour/<id_of_the_map> where can load content from a map
  • Configure the GeoTour plugin in configs/localConfig.json in the dedicated section for the page (e.g. plugins.tour)
  • Extend plugin by adding actions, reducers, selectors, epics
  • 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

Common operations to create a simple plugin

We assume you have a working local project and for more details of it
check this part

Since we want to develop things, let’s developing let’s start our dev instance

npm start

In a different shell run the backend

npm run backend

Now navigate with your favorite browsers to http://localhost:8081/?debug=true#/

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).
mkdir js/plugins
cd js/plugins
touch GeoTour.jsx

Copy the following in js/plugins/GeoTour.jsx

// js/plugins/GeoTour.jsx
import React from 'react';
import { Panel } from 'react-bootstrap';
import { createSelector } from 'reselect';

import { connect, createPlugin } from '@mapstore/utils/PluginsUtils';
import DockablePanel from '@mapstore/components/misc/panels/DockablePanel';
import Message from '@mapstore/components/I18N/Message';
import BorderLayout from '@mapstore/components/layout/BorderLayout';

import { setControlProperty } from "@mapstore/actions/controls";

/**
* The GeoTour plugin allows you to
* @memberof plugins
* @prop {object} cfg properties that can be configured for this plugin
*/

/**
* It's a functional component, i.e. it's a function that returns a React component
* @prop {object} props passed to the component, in this case from the connect function
* @returns {React.Component} the component to be used inside the plugin
*/
const PanelContainer = ({
   id = "geo-tour-plugin",
   dockStyle = {},
   panelClassName = "",
   enabled = true,
   showCloseButton = false,
   onClose = () => {}
}) => {

   return enabled ? (<DockablePanel
      open={enabled}
      dock
      draggable={false}
      size={660}
      position="right"
      bsStyle="primary"
      title={<Message msgId="geotour.name"/>}
      onClose={showCloseButton ? onClose : null}
      glyph="globe"
      style={dockStyle}
   >
      <Panel id={id} className={panelClassName}>
            <BorderLayout>
               This will be an awesome plugin
            </BorderLayout>
      </Panel>
   </DockablePanel>) : 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 to your js/plugins.js

cd js
touch plugins.js
// 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,
      GeoTourPlugin: GeoTour
   }
};

Edit js/app.jsx in order to use a custom version of localConfig.json and use a custom list of plugins

+ 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

# from the root
mkdir translations
cd translations
touch data.en-US.json

add the following to the translations/data.en-US.json

{
   "locale": "en-US",
   "messages": {
      "geotour": {
      ""name": "GeoTour"
      }
   }
}

In this way we will extend/override official translations coming from mapstore

Configure it in configs/localConfig.json

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)

- "LayerDownload",
+ "LayerDownload",
+  {
+      "name": "GeoTour",
+      "cfg": {
+         "showCloseButton": true
+      }
+  },

Note

Whenever you edit & save this config file, you have to refresh the page to see updates

Extending your project with a custom theme

Add custom theme

Let’s include the needed style as part of the theme as explained here

# go to the root folder of your project (where package.json is located)
cd ..
mkdir themes/geo-theme
mkdir themes/geo-theme/less
cd themes/geo-theme
touch theme.less
touch variables.less
cd less
touch geo-tour.less
cd ../../../

Inside themes/geo-theme/theme.less put

// 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

/* change primary color to orange */
@ms-primary: #ff7b00;

Inside themes/geo-theme/less/geo-tour.less put

// **************
// 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)

-   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.

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

Advanced topics: plugin container, page

How to use a plugin container

A plugin container can be - a toolbar like navigation bar in the viewer - a dropdown menu like the burger menu

in general is a container that manipulates items and it render them accordingly

In order to make a button appear in the burger menu we can add the following to the js/plugins/GeoTour.jsx file

- import { Panel } from 'react-bootstrap';
+ import { Panel, Glyphicon } from 'react-bootstrap';

- import { setControlProperty } from '@mapstore/actions/controls';
+ import { toggleControl, setControlProperty } from "@mapstore/actions/controls";

+ import { isLoggedIn } from '@mapstore/selectors/security';

- component: ConnectedPlugin
+ component: ConnectedPlugin,
+ containers: {
+      BurgerMenu: {
+         name: 'geotour',
+         position: 30,
+         text: <Message msgId="geotour.name"/>,
+         icon: <Glyphicon glyph="globe"/>,
+         action: toggleControl.bind(null, 'geotour', null),
+         selector: createSelector(
+             isLoggedIn,
+             (loggedIn) => ({
+                 style: loggedIn ? {} : { display: "none" }
+             })
+         )
+     }

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
  • text 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
  • selector is a function used to hide the button if the user is not logged in, i.e. to anonymous users
  • action is an action to dispatch when the item in the burger menu is clicked

Create a “tour” page

let’s add a pages folder inside js

# go to the root folder of your project (where package.json is located)
mkdir js/pages
cd js/pages
touch Tour.jsx
cd ..

Copy the following in order to create a tour page

// js/pages/Tour.jsx
import PropTypes  from 'prop-types';
import React, {useEffect}  from 'react';
import {connect}  from 'react-redux';

import Page  from '@mapstore/containers/Page';
import {loadMapConfig as loadMapConfigAction}  from '@mapstore/actions/config';


const GeoTourPage = ({
   name,
   loadMapConfig,
   match,
   plugins,
   pluginsConfig
}) => {

   useEffect(() => {
      loadMapConfig("configs/new.json", null);
      // var id = this.props.match.params.id || 0;
   }, []);

   let pluginsToBeUsed = pluginsConfig;
   let pluginsConfigToBeUsed = {
      "desktop": pluginsToBeUsed[name] || [],
      "mobile": pluginsToBeUsed[name] || []
   };
   return (<Page
      id={name}
      includeCommon={false}
      pluginsConfig={pluginsConfigToBeUsed}
      plugins={plugins}
      params={match.params}
   />);
};

export default connect((state) => {
   return {
      mode: 'desktop',
      pluginsConfig: (state.localConfig && state.localConfig.plugins) || null
   };
}, {
   loadMapConfig: loadMapConfigAction
})(GeoTourPage);


GeoTourPage.propTypes = {
   name: PropTypes.string,
   mode: PropTypes.string,
   loadMapConfig: PropTypes.func,
   match: PropTypes.object,
   plugins: PropTypes.object,
   pluginsConfig: PropTypes.object
};
GeoTourPage.contextTypes = {
   router: PropTypes.object
};
GeoTourPage.defaultProps = {
   name: "tour",
   mode: 'desktop',
   match: {},
   pluginsConfig: {}
};

The important part of this component is the name, which will be the one used inside configs/localConfig.json at plugins[name]

Here we reuse a base component called Page which is a container where plugins are rendered

includeCommon defines if common plugins should be included in this page, you can find their list in configs/localConfig.json plugins.common

Configure it in ``configs/localConfig.json`` in the tour page section

(e.g. plugins.tour)

In order to add plugins to our new page we have to create a new entry in th localConfig.json page

+ "tour": [
+    {
+      "name": "Map",
+      "cfg": {
+        "mapOptions": {
+          "openlayers": {
+            "interactions": {
+              "pinchRotate": false,
+              "altShiftDragRotate": false
+            },
+            "attribution": {
+              "container": "#footer-attribution-container"
+            }
+          },
+          "leaflet": {
+            "attribution": {
+              "container": "#footer-attribution-container"
+            }
+          }
+        },
+        "toolsOptions": {
+          "scalebar": {
+            "container": "#footer-scalebar-container"
+          }
+        }
+      }
+    },
+    {
+      "name": "GeoTour",
+      "cfg": {
+        "showCloseButton": false
+      }
+    }
+  ],
+  "desktop": ["Details",
-  "desktop": ["Details",

Now go to http://localhost:8081/?debug=true/#/tour/new to see your new page

example-geotour-page

A page in mapstore composed of two plugins, the Map and the GeoTour

Note

There is no MapFooter plugin here, just a map viewer and the plugin

Advanced topics: actions, reducers, selectors, epics

Now let’s extend this example with some interesting parts.

GOAL

We want to create a plugin that interacts with the map and performs a fly to operation given some points coming from a geojson

For doing this we can use dropzone which is a component that allows to drop files and parse it and use to populate a table to show parsed object. For this we will need to change the main component of the plugin, i.e the plugin which the connect function is applied to (PanelContainer) and we will need some extra files

Extending component

Since we are talking about a lot of changes we add here the final results and we will explain everything

// 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 DockablePanel from "@mapstore/components/misc/panels/DockablePanel";
import Message from "@mapstore/components/I18N/Message";
import BorderLayout from "@mapstore/components/layout/BorderLayout";
import { isLoggedIn } from "@mapstore/selectors/security";
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
*/

/**
* It"s a functional component, i.e. it"s a function that returns a React component
* @prop {object} props passed to the component, in this case from the connect function
* @returns {React.Component} the component to be used inside the plugin
*/

const PanelContainer = ({
   // local properties
   id = "geo-tour-plugin",
   dockStyle = {},
   panelClassName = "",
   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 = true,
   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 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 ? (<DockablePanel
      open={enabled}
      dock
      draggable={false}
      size={660}
      position="right"
      bsStyle="primary"
      title={<Message msgId="geotour.name"/>}
      onClose={showCloseButton ? onClose : null}
      glyph="globe"
      style={dockStyle}
   >
      <Panel id={id} className={panelClassName}>
            <BorderLayout>
               <Dropzone
                  key="DragZone"
                  rejectClassName="alert-danger"
                  className="alert alert-info"
                  onDrop={uploadFiles}
                  style={{}}
                  activeStyle={{}}>
                  <div style={{
                        display: "flex",
                        alignItems: "center",
                        width: "100%",
                        height: "100%",
                        justifyContent: "center"
                  }}>
                        <span style={{
                           textAlign: "center",
                           cursor: "pointer"
                        }}>
                           <Glyphicon glyph="upload"/>
                           {`  ${dropMessage}`}
                        </span>
                  </div>
               </Dropzone>
               {file ? <>
                  <div>
                        <table className="geotour-table-results">
                           <tr>
                              <td> action </td>
                              <td> # </td>
                              <td className="table-geom-type"> geometry type </td>
                              <td> coordinates </td>
                           </tr>
                           { file.features.map((ft, index) => {
                              return (<tr>
                                    <td>
                                       <Button disabled={!flyToEnabled} onClick={() => {
                                          flyTo(bbox(ft), "EPSG:4326", 5, {duration: 1000, point: ft.geometry.coordinates});
                                       }}>fly to</Button>
                                    </td>
                                    <td>{index + 1}</td>
                                    <td>{ft.geometry.type}</td>
                                    <td>{ft.geometry.coordinates.toString()}</td>
                              </tr>);
                           }) }
                        </table>
                  </div>
                  <p>Click next button in order to {flyToEnabled ? "disable" : "enable"} fly to</p>
                  <Button onClick={() => {
                        setFlyToEnabled(!flyToEnabled);
                  }}>fly to</Button>
                  {
                        reverseGeocodeData ? (
                           <>
                              <p>ReverseGeocodeData address</p>
                              <li> state {reverseGeocodeData?.address?.state || "N.A." }</li>
                              <li> country {reverseGeocodeData?.address?.country || "N.A." }</li>
                              <li> city {reverseGeocodeData?.address?.city || "N.A." }</li>
                           </>
                        ) : null }
               </>
                  : null }
            </BorderLayout>
      </Panel>
   </DockablePanel>) : 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: {
      BurgerMenu: {
            name: "geotour",
            position: 30,
            text: <Message msgId="geotour.name"/>,
            icon: <Glyphicon glyph="globe"/>,
            action: toggleControl.bind(null, "geotour", null),
            // display the BurgerMenu button only if the user is logged in
            selector: createSelector(
               isLoggedIn,
               (loggedIn) => ({
                  style: loggedIn ? {} : { display: "none" }
               })
            )
      }
   },
   reducers: {
      geotour
   },
   epics: geotourEpics
});

The <Dropzone> 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

})), {
   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

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

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

# from the root
mkdir js/actions
mkdir js/reducers
mkdir js/selectors
mkdir js/epics
cd js/actions
touch geotour.js
cd ../reducers
touch geotour.js
cd ../selectors
touch geotour.js
cd ../epics
touch geotour.js

Extending reducers

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

// 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

Extending 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

export const GEOTOUR_UPLOAD_FILE = "GEOTOUR:GEOTOUR_UPLOAD_FILE";

export const uploadFile = (file) => ({
   type: GEOTOUR_UPLOAD_FILE,
   file
});

Extending selectors

Add the following to js/selectors/geotour.js

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 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

You can compose together different selectors like in the following example

/**
 * this selector allows you to fetch all the layers from the dedicated part of the store i.e. "layers",
 * note the initial destructuring
 * export const layersSelector = (state) =>
 * vs
 * export const layersSelector = ({layers, config} = {}) =>
*/
export const layersSelector = ({layers, config} = {}) => layers && isArray(layers) ? layers : layers && layers.flat || config && config.layers || [];

/**
 * this selector leverage the previous selector in order to obtain a layer given an intial id
*/
export const getLayerFromId = (state, id) => head(layersSelector(state).filter(l => l.id === id));

/**
 * USAGE:
 *
 * in epics
 * const layer = getLayerFromId(store.getState(), myId);

 * in connect
 * state => getLayerFromId(state, myId);
 * or
 * state => getLayerFromId(state, getSpecificId(state));
*/

Extending 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

import Rx from 'rxjs';
import { ZOOM_TO_EXTENT } from '@mapstore/actions/map';
import { showMapinfoRevGeocode } from '@mapstore/actions/mapInfo';


export const addFeatureinfoEpic = (action$) => action$.ofType(ZOOM_TO_EXTENT)
   .switchMap(
      (action) => {
         return Rx.Observable.from(
            [showMapinfoRevGeocode({lng: action.options.point[0], lat: action.options.point[1] })]
         );
      }
   );

// alternatively
export const addFeatureinfoEpicWithOf = (action$) => action$.ofType(ZOOM_TO_EXTENT)
   .switchMap(
      (action) => {
         return Rx.Observable.of(
            showMapinfoRevGeocode({lng: action.options.point[0], lat: action.options.point[1] })
         );
      }
   );

// the difference here is the operator used in the first example we have
// Rx.Observable.from which needs to use an Iterable or an Array etc.
// while Rx.Observable.of it just take the action creator to dispatch, note that you could include also other actions in the of operator

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

Extending assets

for testing our application we need some assets so let’s prepare it for later

# from the root
touch assets/markers.json

Add the following to assets/markers.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
         ]
         }
      }
   ]
}

Extra topics: docs, test, conditional render, lint

Add codebase 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

Add 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 continuoustest

First let’s create a test for our selectors, actions, reducers and plugin

# 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

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

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

import expect from 'expect';
import { uploadFile } from '@js/actions/geotour';
import geotour from '@js/reducers/geotour';


describe("geotour actions 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

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 = '<div id="container"></div>';
      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(<Plugin/>, 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

npm test

You will see something like this

npm-test

result of execution of command npm test

Whenever you run tests wth this command “npm test” a report is generated inside coverage/report-html/index.html

open it in your browser

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 continuoustest” in combination with edits to tests.webpack.js like this

- 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 continuoustest” 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

- 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

How to render a plugin conditionally using monitored state or disablePluginIf

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

- export default createPlugin("GeoTour", {
+ export default createPlugin("GeoTour", {
+   options: {
+      disablePluginIf: "{state('mapType') === 'leaflet' || state('mapType') === 'cesium'}"
+   },

This will automatically disable this plugin when you open the map viewer with leaflet or cesium

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

Extending or customizing lint configuration

Lint 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

"eslintConfig": {
   "extends": [
      "@mapstore/eslint-config-mapstore"
   ],
   "parserOptions": {
      "babelOptions": {
            "configFile": "./MapStore2/build/babel.config.js"
      }
   },
+  "rules": {
+     "quotes": [ "error","double" ]
+  }
},