Build an advanced plugin

To give you an idea what else could be implemented as an RepeaterFlex item let's have a look how to implement something like a map plugin which needs some global configuration (for example an API key), needs additional scripts and styles and may require a user opt-in with respect to data privacy. This example is based on MapBox, but other services should be rather similar. This is what we actually want to show, a map of rome with some markers at points of interest: If you see this page the first time, there should be a button asking to enable the map functionality, otherwise you'll see the map:

Enable Map

The map functions use services from external servers, please notice additional information in our still-to-be-written Policy Statement.
Enable Map functions now?

Yes, one week Yes, once

The PHP side

The magic behind this condition is right at the beginning of the render method:

plugins/ContentRenderer/FlexMapBoxSimple.inc

plugins/ContentRenderer/FlexMapBoxSimple.inc

<?php namespace ProcessWire;
/*
 * Module displaying a simple MapBox map to verify functionality of JS and CSS includes
 */
class FlexMapBoxSimple extends RepeaterFlexItem {
	public static function getModuleInfo() {
		return array(
			'title' => __('Simple MapBox Viewer', __FILE__), // Module Title
			'version' => 81,
			'renderer' => 'ContentRenderer',
			'head' => '{flex_label} [• Style:{Options.title}, {TextArea}]',
			'icon' => 'fa fa-map-o',
			);
	}
	
	public static function getOptions() { return [
						1 => [ 'style' => 'mapbox/outdoors-v10', 'title' => __('Outdoor') ],
						2 => [ 'style' => 'mapbox/streets-v10', 'title' => __('Streets') ],
						3 => [ 'style' => 'mapbox/navigation-guidance-day-v4', 'title' => __('Navigation (day)') ],
						4 => [ 'style' => 'mapbox/navigation-guidance-night-v4', 'title' => __('Navigation (night)') ],
						5 => [ 'style' => 'mapbox/emerald-v8', 'title' => __('Emerald') ],
						6 => [ 'style' => 'mapbox/light-v9', 'title' => __('light') ],
						7 => [ 'style' => 'mapbox/dark-v9', 'title' => __('dark') ],
						8 => [ 'style' => 'mapbox/satellite-streets-v10', 'title' => __('Streets and Satellite') ],
						9 => [ 'style' => 'mapbox/satellite-v9', 'title' => __('Satellite') ],
						];
		}
	public static function getConfigFields() { return [
		'accesskey' => [
				'type' => 'InputfieldText',
				'label' => __('MapBox Access Key'),
				'description' => __('register your key at [www.mapbox.com](https://www.mapbox.com)'),
				'columnWidth' => 50,
			],
		];
	}

	public static function getFields() {
		return([
			'@Options' => RepeaterFlexItem::Options([
					'columnWidth' => 25,
					'label' => __('Card Styles'),
					'comment' => __('Select all styles available in style selector. First style will be the default.'),
					'inputfieldClass' => 'InputfieldAsmSelect',
					'initValue' => 5,
					'required' => 1,
					'options' => self::getOptions(),
				]),
			'@Percent' => RepeaterFlexItem::Percent([
					'label' => __('Height of Map, percent of viewport height'),
					'columnWidth' => 25,
					]),
			'@TextArea' => RepeaterFlexItem::TextArea([
					'label' => __('Map Configuration'),
					'notes' => __('List of assignments in JSON format ( "name" : value, )'),
					'columnWidth' => 50,
					]),
		]);
	}

	public function textindex(Page $pg, $textIndex)
		{
		$ar = json_decode('{' . $pg->TextArea . '}', true);	// Try decoding
		if(is_array($ar) && isset($ar['marker']))	// Markers defined?
			{
			foreach($ar['marker'] as &$marker)	// Enumerate markers and...
				$textIndex->index($marker['title']);	// ...add titles to the index
			}
		}

	public static function verifyFields(InputfieldWrapperFlex $wrapper)	{
		$fld = $wrapper->TextArea;	// Get JSON field
		$ar = json_decode('{' . $fld->value . '}', true);	// Try decoding
		if(!is_array($ar))
			$fld->error('Parse Error: '.json_last_error_msg());
	}

	private function getStyleSelect($mapID, Page $pg) {
		$tSelect = __('Style');
		$StyleSelect = "
<button class='uk-button uk-button-default uk-button-small' type='button'>{$tSelect}</button>
<div uk-dropdown>
    <ul class='uk-nav uk-dropdown-nav'>
";
//	foreach(self::getOptions() as $k => $style)	// Enumerate all options
	$DefaultStyle = '';
	foreach($pg->Options as $k => $style)	// Enumerate selected options only
		{
		if($DefaultStyle == "")
			{
			$liStyle = " uk-active";
			$DefaultStyle = $k;
			}
		else
			$liStyle = "";
		$StyleSelect .= "<li class='map-style{$liStyle}' data-mapid='$mapID' data-style='{$style['style']}' id='$k'><a href='#' onclick='setStyle(this)'>{$style['title']}</a></li>\n";
		}
//        <li><a href='#' onclick='setStyle(this)'>Active</a></li>
	$StyleSelect .= "
    </ul>
</div>
";
		return($StyleSelect);
	}
		
	public function render(Page $pg) {
		$apiEnabled = $this->ctx->isApiEnabled('MapBox');	// We need opt-in from the user
		if($apiEnabled !== true) return($apiEnabled);		// isApiEnabled returns markup in case the API is not enabled
		
		$vars = array_merge( [		// Combine attributes with default
			'zoom' => 12,
			'origin' => [51.4934, 0.0098],	// Default Greenwich
			'style' => $pg->Options->style,
			'pageid' => $pg->id,
			], json_decode('{' . $pg->TextArea . '}', true));

		$mapID = "mapbox{$pg->id}";	// The ID is based on the page id, allowing to have multiple maps per page
		$this->ctx->addStyles('mapbox/mapbox.css');			// Register MapBox styles
		$this->ctx->addScript('mapbox/mapbox.js');			// Register MapBox script
		$this->ctx->addScript('js/FlexMapBoxSimple.js');	// Register our MapBox script
		$this->ctx->js('MapBox', [							// Register config variables for client-side JavaScript
			'accesskey' => $this->accesskey,
			$mapID => $vars,
			]);

	$StyleSelect = '';
	if(count($pg->Options) > 1)
		$StyleSelect = $this->getStyleSelect($mapID, $pg);
		
	// Create MapBox object, initialization is done in JavaScript
	if(($height = $pg->Percent) <= 0)
		$height = 50;
	return("{$StyleSelect}<div class='FlexMapBox' id='{$mapID}' style='height:{$height}vh;'></div>");
	}
}
    public function render(Page $pg) {
        $apiEnabled = $this->ctx->isApiEnabled('MapBox');    // We need opt-in from the user
        if($apiEnabled !== true) return($apiEnabled);        // isApiEnabled returns markup in case the API is not enabled

The call to isApiEnabled essentially invokes a hook, so the application may decide how to proceed. For this example we simply need to know, that a boolean return value of true indicates an allowed operation, otherwise the returned string may contain HTML markup, which is simply returned. In our case that markup contains the "Enable Map" button along with the modal window. We'll do a deeper look on this in another example.

Then the render method array_merges a set of configuration parameters from some defaults and parameters specified in a TextArea. To simplify this example, the TextArea content must be in proper JSON format, otherwise we simply fail. In a real application you may decide to output nothing or display an error. But for now the parsing code looks like this:

        $vars = array_merge( [        // Combine attributes with default
            'zoom' => 12,
            'origin' => [51.4934, 0.0098],    // Default Greenwich
            'style' => $pg->Options->style,
            'pageid' => $pg->id,
            ], json_decode('{' . $pg->TextArea . '}', true));

Please note that we add the enclosing curly braces explicitly, so valid input would look something like this (which renders the map of Rome along with the markers):

"origin" : [ 41.9009, 12.4833 ],
"zoom" : 14,
"mapmarker" : {
   "url" : "/site/static/pwflex-marker.png",
   "iconsize" : [37, 62],
   "iconanchor" : [17, 60],
   "popupanchor" : [0, -60]
   },
"marker" : [
   { "origin" : [ 41.890251, 12.492373 ], "title" : "Colosseum", "id" : 1 },
   { "origin" : [ 41.8925, 12.4853 ], "title" : "Roman Forum", "id" : 2 },
   { "origin" : [ 40.75, 14.486111 ], "title" : "Pompeii", "id" : 3 },
   { "origin" : [ 41.9029, 12.4534 ], "title" : "Vatican City", "id" : 4 },
   { "origin" : [ 41.9029, 12.4545 ], "title" : "Sistine Chapel", "id" : 5 },
   { "origin" : [ 41.9009, 12.4833 ], "title" : "Trevi Fountain", "id" : 6 },
   { "origin" : [ 41.9428, 12.7744 ], "title" : "Hadrian's Villa", "id" : 7 }
 ]

Before we continue to analyze the render method, I'd like to introduce a method for item verification when saving a repeater item. The standard procedure for input verification is handled within each input field, you may even define pattern for text fields or similar. But it can't verify correctness of JSON data that way (which isn't trivial either).

To realize a per-item verification, the RepeaterFlex invokes a static method verifyFields, if defined. For our map example it looks like this:

    public static function verifyFields(InputfieldWrapperFlex $wrapper)    {
        $fld = $wrapper->TextArea;    // Get JSON field
        $ar = json_decode('{' . $fld->value . '}', true);    // Try decoding
        if(!is_array($ar))
            $fld->error('Parse Error: '.json_last_error_msg());
    }

verifyFields receives a single parameter, a special wrapper to the inputfield so you may access valid fields just from the, potentially virtual, name. Please note that this method handles with an InputfieldWrapper not a page (page not stored yet!), so you'll have to use the Inputfield API and access the current value from the element value.

The method actually ensures that the input can be parsed as JSON. You may add further tests, like test for correct coordinates, verify completeness of icon definition etc.

Ok, back to render and now, that everything is prepared, let's have a look at the interesting part, which registers the required script and style files and the JavaScript parameter set. The API slightly follows the regular ProcessWire $config variable with its scripts and styles members and the js method:

        $mapID = "mapbox{$pg->id}";    // The ID is based on the page id, allowing to have multiple maps per page
        $this->ctx->addStyles('mapbox/mapbox.css');            // Register MapBox styles
        $this->ctx->addScript('mapbox/mapbox.js');            // Register MapBox script
        $this->ctx->addScript('js/FlexMapBoxSimple.js');    // Register our MapBox script
        $this->ctx->js('MapBox', [                            // Register config variables for client-side JavaScript
            'accesskey' => $this->accesskey,
            $mapID => $vars,
            ]);

There only is little magic behind addStyles and addScript methods, the given paths simply end up in the $config->styles and $config->scripts arrays but will be prefixed with the url of the instance of FieldtypeRepeaterFlex. So the script mapbox/mapbox.js will effectively be loaded from /site/modules/FieldtypeRepeaterFlex/mapbox/mapbox.js. Maybe a later version of RepeaterFlex allow a custom root path, but for now everything is served from its subdirectory.

The js method does slightly more magic, before it hands over the variable to $config->js. During enumeration of a RepeaterFlex field, any call to $this->ctx->js will merge into a temporary associative array to allow multiple instances (otherwise the 'MapBox' element would be overwritten with each call). For exactly the same reason the prepared $vars are stored with the key $mapID, which is generated from the item's $page->id and therefore unique.

The same $mapID is used within the generated markup code, so our script will correctly associate both, as we'll see soon. The rest of the render method simply creates a <div> with correct $id and class along with a simple map-style selector, which we do not discuss in detail here (you may check the complete code above). Please note that we specify class='FlexMapBox' for the div, so the script will find our placeholder.

    $StyleSelect = '';
    if(count($pg->Options) > 1)
        $StyleSelect = $this->getStyleSelect($mapID, $pg);
        
    // Create MapBox object, initialization is done in JavaScript
    if(($height = $pg->Percent) <= 0)
        $height = 50;
    return("{$StyleSelect}<div class='FlexMapBox' id='{$mapID}' style='height:{$height}vh;'></div>");
    }

Generating links for scripts and styles

Before we can dive into the JavaScript, we first need to embed the generated scripts and styles into the markup sent to the client when opening the page. ProcessWire does this automatically while in admin, but for frontend-use you'll have to do this on your own. Of course this slightly depends on your output method, I'll go with echo for this example, and the few lines, right before the closing </body> tag, look like this:

foreach($config->styles->unique() as $style) echo "<link rel='stylesheet' type='text/css' href='{$style}' />";
if(count($jsConfig = $config->js()) > 0) echo '<script type="text/javascript">var config = '.json_encode($jsConfig).';</script>';
foreach($config->scripts->unique() as $script) echo "<script src='{$script}'></script>\n";

Please check out the magic of the second line, since it carries a pretty essential convention so our JavaScript code can see our variables: We statically inject a variable named config and initialize it as an object with the collected configuration. Of course you may pick another name, but then you'll need to adjust the loaded script as well.

js/FlexMapBoxSimple.js

js/FlexMapBoxSimple.js

/*
 * Simple MapBox viewer to demonstrate communication between
 * a RepeaterFlex item plugin and associated script.
 */

var Maps = { };	// Array to hold information about map instances (may be multiple per page)

function setMapLayer(mapID, styleId)	// Utility function to update visible layer (may switch style during runtime)
	{
	if(Maps[mapID].styleLayer)
		Maps[mapID].map.removeLayer(Maps[mapID].styleLayer);

	Maps[mapID].styleLayer = L.mapbox.styleLayer('mapbox://styles/'+styleId);
	Maps[mapID].map.addLayer(Maps[mapID].styleLayer);
	}

function setStyle(s)	// onClick function to set a map style (see getStyleSelect in PHP code)
	{
//	console.log(s.parentNode.dataset.style);
	setMapLayer(s.parentNode.dataset.mapid, s.parentNode.dataset.style);
	}

$(document).ready(function() {
	$('.FlexMapBox').each(function(){		// Initialize any FlexMapBox instance
		var $box = jQuery(this);			// reference to the div
		var $mapID = $box.attr('id');		// We need this id for proper association with the container
		var mapCfg = config.MapBox[$mapID];	// Shortkey to our instance variables
//		console.log(mapCfg);
	
		L.mapbox.accessToken = config.MapBox.accesskey;			// Initialize access token with Leaflet MapBox
		Maps[$mapID] = { };										// Create container object
		Maps[$mapID].map = L.mapbox.map($mapID, undefined, {	// Initialize map
			maxZoom: 18
			});
		Maps[$mapID].map.setView(mapCfg.origin, mapCfg.zoom);	// Initialize view
		setMapLayer($mapID, mapCfg.style);		// Configure layer
	
		if('mapmarker' in mapCfg)	// Do we have a mapmarker image defined?
			{
//			console.log(mapCfg.mapmarker);
			// Generate a Leaflet Icon to be placed in our marker layer
			var mapMarker = L.icon({
				iconUrl: mapCfg.mapmarker.url,	// We may want to sanitize these parameters
				iconSize: mapCfg.mapmarker.iconsize,
				iconAnchor: mapCfg.mapmarker.iconanchor,
				popupAnchor: mapCfg.mapmarker.popupanchor,
//    shadowUrl: 'my-icon-shadow.png',		// Well, not implemented here...
//    shadowSize: [68, 95],
//    shadowAnchor: [22, 94]
				});
				
			// Create a new layer for our marker and add it to the map
			layerMarker = new L.featureGroup();
			Maps[$mapID].map.addLayer(layerMarker);

			// Iterate through all provided marker entries and place a marker
			for(var m of mapCfg.marker)
				{
				var marker = L.marker(m.origin, {icon: mapMarker}).addTo(layerMarker);
				marker.markerid = m.id;
				marker.bindPopup(m.title);
				}
			}
	
		});
	});

Script execution starts after loading the document, which enumerates all elements with class .FlexMapBox:

$(document).ready(function() {
    $('.FlexMapBox').each(function(){       // Initialize any FlexMapBox instance
        var $box = jQuery(this);            // reference to the div
        var $mapID = $box.attr('id');       // We need this id for proper association with the container
        var mapCfg = config.MapBox[$mapID]; // Shortkey to our instance variables

Since we injected the config variable we may easily access the parameters for our particular MapBox instance, which we obtain from the id attribute. We just define mapCfg for easier access.

Setup of the MapBox viewer is rather simple and complete after these very few lines:

        L.mapbox.accessToken = config.MapBox.accesskey;         // Initialize access token with Leaflet MapBox
        Maps[$mapID] = { };                                     // Create container object
        Maps[$mapID].map = L.mapbox.map($mapID, undefined, {    // Initialize map
            maxZoom: 18
            });
        Maps[$mapID].map.setView(mapCfg.origin, mapCfg.zoom);   // Initialize view
        setMapLayer($mapID, mapCfg.style);        // Configure layer

The slightly larger part of the script deals with creation of the marker icon and adding the provided map marker:

        if('mapmarker' in mapCfg)    // Do we have a mapmarker image defined?
            {
            // Generate a Leaflet Icon to be placed in our marker layer
            var mapMarker = L.icon({
                iconUrl: mapCfg.mapmarker.url,    // We may want to sanitize these parameters
                iconSize: mapCfg.mapmarker.iconsize,
                iconAnchor: mapCfg.mapmarker.iconanchor,
                popupAnchor: mapCfg.mapmarker.popupanchor,
                });
                
            // Create a new layer for our marker and add it to the map
            layerMarker = new L.featureGroup();
            Maps[$mapID].map.addLayer(layerMarker);

            // Iterate through all provided marker entries and place a marker
            for(var m of mapCfg.marker)
                {
                var marker = L.marker(m.origin, 
).addTo(layerMarker); marker.markerid = m.id; marker.bindPopup(m.title); } } }); });

Done!

prepared in 61ms (content 28ms, header 0ms, Menu 21ms, Footer 11ms)