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

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

npm run fe:start

In a different shell start up the backend

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).
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 { 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 ? (<Dialog
      id={id}
      style={{
            zIndex: 10000,
            position: "absolute",
            left: "17%",
            top: "50px",
            margin: 0,
            width
      }}
      modal={false}
      draggable
      bsStyle="primary"
   >
      <span role="header">
         <Glyphicon glyph="globe" />
         <Message msgId="geotour.name"/>
         <button onClick={() => onClose()} className="close">
            <Glyphicon glyph="1-close" />
         </button>
      </span>
      <div role="body">
            This will be an awesome plugin
      </div>
   </Dialog>) : 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

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

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

- 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: <Message msgId="geotour.name"/>,
+         icon: <Glyphicon glyph="globe"/>,
+         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.

// 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 ? (<Dialog
      id={id}
      style={{
            zIndex: 10000,
            position: "absolute",
            left: "17%",
            top: "50px",
            margin: 0,
            width: size.width
      }}
      modal={false}
      draggable
      bsStyle="primary"
   >
      <span role="header">
            <Glyphicon glyph="globe" />
            <Message msgId="geotour.name"/>
            <button onClick={() => onClose()} className="close">
               <Glyphicon glyph="1-close" />
            </button>
      </span>
      <Panel role="body" id={id} >
            <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> Region: {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>
   </Dialog>) : 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: <Message msgId="geotour.name"/>,
            icon: <Glyphicon glyph="globe"/>,
            action: toggleControl.bind(null, "geotour", null)
      }
   },
   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
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

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

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

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

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

# 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
         ]
         }
      }
   ]
}

Create a 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 ..
# 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

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

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

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

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

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.

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

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

- 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

- 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

- 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

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