Constructing a Reusable JET Composite Country Selection Component Powered by the OpenLayers GIS Library
 
By Lucas Jellema
Summary

Composite Components are a crucial mechanism in Oracle JET. Aligned with the W3C Web Components standard, composite components are standardized, encapsulated, easily reusable building blocks that will help developers be more productive. Composite components can be plugged into the Oracle Visual Builder Cloud Service development framework to add tailor-made functionality to the low code component palette for use in declaratively developed applications that can be stand-alone or extensions of Oracle SaaS applications.

This article describes how to construct a reusable JET Composite Component: the input-country component.

Figure 1

This component allows users to select a country from a map, displayed in a popup. The map can be panned and zoomed in and out across many levels.

Figure 2

The component supports two-way data binding, callback functions and publication of events. It can be packaged, published, shipped and reused in any JET 4 application as well as in Visual Builder Cloud applications. The component leverages OpenLayers, one of the most prominent open-source JavaScript libraries for working with maps in web applications. This library provides an API for building rich web-based geographic applications similar to Google Maps and Bing Maps. One of the geographic data providers that OpenLayers works well with is Open Street Map (OSM). also fully open-source. This article helps you get started with both the creation of a JET Composite Component as well as the use of OpenLayers in Oracle JET applications.

Resources

Execute the first step in http://www.oracle.com/webfolder/technetwork/jet/globalGetStarted.html to get started with Oracle JET 4 on your machine.

Download this image from https://github.com/lucasjellema/input-country-composite-component/blob/master/src/js/jet-composites/input-country/images/icon-world.png. Download the zip-file for the OpenLayers 4.x distribution from: https://openlayers.org/download/. Download the GeoJSON file countries.geo,json with vector coordinates for the countries of the world https://github.com/johan/world.geo.json.

You will find all software artefacts discussed in this article – the intermediate steps and the final situation – on GitHub: https://github.com/lucasjellema/input-country-composite-component.

For more background on the JET Composite Component Architecture (CCA), read Duncan Mills’ excellent learning path – a series of 20+ articles: https://blogs.oracle.com/groundside/cca. Also check out the Oracle JET documentation on CCA: http://www.oracle.com/webfolder/technetwork/jet/jetCookbook.html?component=composite&demo=basic.

For dozens of showcases of what can be done with the OpenLayers library, look at the examples here: http://openlayers.org/en/latest/examples/. The API Documentation for OpenLayers is here: http://openlayers.org/en/latest/apidoc/.

 

Hint: Running the [seven stages of the] Application

 

You can run the JET application with the input-country component using the JET CLI from the command line on your computer after cloning the GitHub repository, by using first ojet restore (otherwise you will get an error about oraclejet-tooling not being installed) and then the command to actually run the application: ojet serve both from the root directory of the download project. To follow the seven stages of the input-country component as discussed in this article, you will need to edit two files: src\js\views\workarea.html and src\js\viewModels\workarea.js. In workarea.html add the stage number to the tags – for example:

   .
  In workarea.js add the stage number to the path mapping for the input-country component,  such as: 
  requirejs.config(
  {
  // create path mapping for input-country module
  paths:
  {
  'input-country':'jet-composites/input-country4'
  }
  }); 
  

These changes are picked up immediately in the browser UI, demonstrating the specific stage of the component.

Stage 1 – Baby Steps With A Composite Component

Let’s first generate a new JET application in which we will create and test our composite component. Run the following command with the JET CLI on the command line:

ojet create inputcountrycomposite --template=basic
 
Figure 3

At this point, the project scaffold has been created.

Figure 4
Change to the generated directory inputcountrycomposite. Instantiate a new composite component through the CLI: ojet create component input-country

 

Figure 5

This will generate the artefacts that make up [the absolute minimum implementation of] the composite component, in a directory called after the composite component that is located under the src/js/jet-composites directory:

Figure 6

Open file component.json that describes the component – its functionality and all of its properties and events. Replace the contents of this file with:

{
  "name": "input-country",
  "displayName": "input-country",
  "description": "This component allows users to input the name of a country - 
  either by typing it or by selecting it on a map presented in a popup.",
  "version": "1.0.0",
  "jetVersion": "^4.1.0",
  "properties": { 
    "countryName": {
      "description": "Property to hold the name of the selected country; a string can be passed in, or a data bound expression",
      "type": "string",
      "writeback": true
    }
  },
  "methods": {},
  "events" : {},
  "slots": {}
}

Here we declare a single property called countryName of type string. In later stages we will extend the component’s definition with more properties, a callback function and an event. Review the loader.js file. This file takes care of registering the composite component, thus making it available in the consuming JET application. Open file view.html, the file that defines the markup for the component. This can be a combination of standard HTML5 components, out-of-the-box JET components and custom composite components. Update the file to:

<h3 data-bind="text: $props.countryName"/>

This specifies the component will render an h3 element with its content derived from the countryName property that has been passed in. In order to consume this bare component, we will add a module called workarea to the JET application. Create directories views and viewModels under src/js. Create file workarea.html in the views directory. Open the file and copy and paste this markup code:

<h2>Workarea with Composite Component input-country</h2>  
<div>      <input-country country-name="{{country}}" country-selection-handler={{handleCountrySelection}}
        on-country-selected={{countrySelectedHandler}}  label="Enter your country"/> 
         </div>  <h4 data-bind="text: upperCountry"></h4>  
<div>      <input-country country-name="{{country2}}" country-selection-handler={{handleCountry2Selection}} 
	label="Destination of Next Holiday Trip" 
       on-country-name-changed={{handleCountryNameChangedHandler}}  />  </div>  <h4 data-bind="text: country2"></h4>  <br/>

 

Here we see two instances of the input-country component. The attribute country-name corresponds to the property countryName and is data bound to the viewModel. The other attributes – label, country-selection-handler and on-country-selected – are to be discussed at a later stage and can be ignored for now. Next, create file workarea.js in the viewModels directory and set its content to:

requirejs.config(
    {
      // create path mapping for input-country module
      paths:
      {
        'input-country':'jet-composites/input-country'
      }
    });
define(
    ['ojs/ojcore', 'knockout', 'jquery', 'ojs/ojknockout', 'ojs/ojinputtext'
    , 'input-country/loader'
],
    function (oj, ko, $) {
        'use strict';
        function WorkareaViewModel() {
            var self = this;
            // initialize two country observables
            self.country = ko.observable("Italy");
            self.country2 = ko.observable("Indonesia");
            // a computed observable, based on the first country observable
            // whenever self.country is updated, this observable is also modified
            self.upperCountry = ko.computed(function() {
                return this.country().toUpperCase();                
            }, self);

            // function to be called back from input-country component
            self.handleCountrySelection= function (selectedCountryName, selectedCountryCode) {
                console.log('Callback Function to Handle Country Selection name '+ selectedCountryName+ ' 
			and code '+ selectedCountryCode);
            }

            // function to be called back from input-country component
            self.handleCountry2Selection= function (selectedCountryName, selectedCountryCode) {
                console.log('Callback Function to Handle Country Selection name '+ selectedCountryName+ 
			' and code '+ selectedCountryCode);
            }

            // function to handle the countrySelected event that can be published by the input-country component
            self.countrySelectedHandler = function(countrySelectedEvent) {
                console.log("countrySelectedHandler - to handle countrySelected event "+JSON.stringify(countrySelectedEvent.detail))
            }

            self.handleCountryNameChangedHandler = function(countryNameChangedEvent) {
                console.log("handleCountryNameChangedHandler - to handle out-of-the-box countryNameChanged event 
				"+JSON.stringify(countryNameChangedEvent.detail))
            }


            }

        return new WorkareaViewModel();
    }
);

A path mapping is created for the input-country module, relative to the directory that holds he workarea.js file. We will see URLs later on, referring to resources like an image and a JSON document inside the input-country module. The relative references used in these URLs extend from this path mapping for the module. The new composite needs to be activated in order to be used in the workarea module. To that end, the reference 'jet-composites/input-country/loader' is added in the define call. Two observables are created in the ViewModel and initialized with values (Italy and Indonesia). These observables are passed as property to the input-country component. We will make use of the functions you see in this code snippet in later stages in this article. Open file index.html. Between the <header> and <footer> elements, add this reference to the workarea module:

     <div role="main" class="oj-web-applayout-max-width oj-web-applayout-content">          
		<h3>index.html: Module workarea</h3>          
		<div data-bind="ojModule:'workarea'"/>          
	</div>

Finally, in order to enable the JET application to work with modules, you need to update the file main.js and add the reference to ojs/ojModule in the require call:

require(['ojs/ojcore', 'knockout', 'appController', 'ojs/ojknockout', 'ojs/ojbutton', 'ojs/ojtoolbar', 
'ojs/ojmenu','ojs/ojmodule'],

You are now ready to run the application and see the input-country component in action. Navigate to the root directory of the project on the command line and run: ojet serve This command should result in the application being compiled, built and run. Your web browser is launched at url localhost:8000 and should display the application as follows:

Figure 7

Note: the red rectangles were added to indicate the output from the two input-country instances.

Stage 2

Enriching the Input Country Composite Component In this stage, we will enrich the component a little. You need to copy the image file icon-world.png to a new directory: images under jet-composites/input-country. Open the file styles.css that contains the style definitions for the composite component, and add:

input-country .iconbtnintext {	
  position:absolute;
  cursor:pointer;
  width:22px;
  height:23px;
  margin-left:-24px;
  padding-top:4px;
}

This style is applied to the image, to shrink it to icon-size and position it appropriately. Subsequently, change the content of view.html to:

<div :id='[['country-input-container'+ $uniqueId]]'>      
	<oj-label data-bind="attr: {for: 'country-input'+ $uniqueId }" >
		<span data-bind="text: $props.label"></span></span></oj-label>      
	<oj-input-text :id="[['country-input'+ $uniqueId]]"  value="{{$props.countryName}}"></oj-input-text>      
	<img  :id="[['countrySelectorOpen'+ $uniqueId]]" :src='[[require.toUrl("input-country/images/icon-world.png")]]' 
		class='iconbtnintext' title="Click to open World Map in Popup"         
	/>  
</div>

Quite a bit is going on here. The component now renders an input-text component with a label and an img element based on the image file that you just added, using a local reference that is turned to the appropriate URL through the call to require.toUrl. It also references a new property called label for which the definition should be added to component.json:

    "label": {
      "description": "This property is used to specify the text to be shown as the label (aka prompt) for the input field",
      "type": "string",
      "value": "Country"
    }


Note: we use $uniqueId in view.html to ensure that the HTML elements rendered by our component have unique identifiers, even if multiple instances of the component are included in the same page. See this article by Duncan Mills for some background: https://blogs.oracle.com/groundside/jet-composite-components-xvii-beware-the-ids. Some of these changes are not picked up automatically, so you will have to abort the current process and run ojet serve again to load the somewhat enriched component:

Figure 8

The component now renders a standard JET input-text component with an associated label that takes its value from the label property passed in from workarea.html. The image is styled as a small icon, shown right-aligned inside the input-text field. It invites the user to click – but if you do, nothing happens (yet). That is for the next stage.

Stage 3 – Add the Popup to the Input Country Component

To make a popup appear when the icon is clicked on – as is our aim – we have to add the popup to view.html, like so:

  <div :id="[['country-input-container'+ $uniqueId]]">
    <oj-label data-bind="attr: {for: 'country-input'+ $uniqueId }">
      <span data-bind="text: $props.label"></span>
      </span>
    </oj-label>
    <oj-input-text :id="[['country-input'+ $uniqueId]]" value="{{$props.countryName}}"></oj-input-text>
    <img :id="[['countrySelectorOpen'+ $uniqueId]]" :src='[[require.toUrl("input-country/images/icon-world.png")]]' 
	   class='iconbtnintext'
      title="Click to open World Map in Popup" data-bind="click: openPopup" />
  </div>
  <div id="popupWrapper">
    <oj-popup class="input-country-country-selection-popup" :id="[['countrySelectionPopup'+ $uniqueId ]]" tail="none" 
		position.my.horizontal="center"
      position.my.vertical="bottom" position.at.horizontal="center" position.at.vertical="bottom" position.of="window" 
	   position.offset.y="-10"
      modality="modal" data-bind="event:{'ojAnimateStart': startAnimationListener}">
      <div class="input-country-country-selection-popup-body">
        <div class="input-country-country-selection-popup-header">
          <h5>Select a country by clicking on it</h5>
        </div>
        <div class="input-country-country-selection-popup-content">
          <div :id="[['countryInfo'+ $uniqueId ]]"></div>
          <div :id="[['mapContainer'+ $uniqueId ]]"></div>
        </div>
        <div class="input-country-country-selection-popup-footer">
          <oj-button :id="[['btnClose'+ $uniqueId ]]" data-bind="click: function()
    {
      var popup = document.querySelector('#countrySelectionPopup'+$uniqueId);
      popup.close();
    }">
            Cancel
          </oj-button>
        </div>
      </div>
    </oj-popup>
  </div>

This adds the JET Popup component to be opened as a modal window – with a title, a cancel button and no other content (yet). You will probably notice the data bound property click on the image, that refers to a function called openPopup. This function is to be added to viewModel.js along with function startAnimationListener:

define(
    ['ojs/ojcore', 'knockout', 'jquery', 'ojs/ojbutton', 'ojs/ojpopup'], function (oj, ko, $) {
        'use strict';

        function InputCountryComponentModel(context) {
            var self = this;
            // save a reference to the unique identity of the composite component instance - also used in generating 
			the element id values in view.html
            // see https://blogs.oracle.com/groundside/jet-composite-components-xvii-beware-the-ids for reference 
            self.unique = context.unique;
            self.composite = context.element;

            self.openPopup = function () {
                $('#countrySelectionPopup' + self.unique).ojPopup("open");
            }//openPopup
            
            self.startAnimationListener = function (data, event) {
                var ui = event.detail;
                if (!$(event.target).is("#countrySelectionPopup" + self.unique))
                    return;

                if ("open" === ui.action) {
                    event.preventDefault();
                    var options = { "direction": "top" };
                    oj.AnimationUtils.slideIn(ui.element, options).then(ui.endCallback);
                    // if the map has not yet been initialized, then do the initialization now 
					(this is the case the first time the popup opens)
                    if (!self.map) initMap();

                }
                else if ("close" === ui.action) {
                    event.preventDefault();
                    ui.endCallback();
                }
            }

        };

        //Lifecycle methods - uncomment and implement if necessary 
        //ExampleComponentModel.prototype.activated = function(context){
        //};

        //ExampleComponentModel.prototype.attached = function(context){
        //};

        //ExampleComponentModel.prototype.bindingsApplied = function(context){
        //};

        //ExampleComponentModel.prototype.detached = function(context){
        //};

        return InputCountryComponentModel;
    });

Note how the ojButton and ojPopup modules are included in the define() for this viewModel. Styles associated with the class attributes in view.html are added to styles.css. These take care of assigning proper width and height to the popup:

.input-country-country-selection-popup {
  width: 80vw;  
  height: 80vh; 
  display: none;
} 
 .input-country-country-selection-popup-body {
  width: 75vw;  
  height: 75vh; 
  display: flex; 
  flex-direction: column;
  align-items: stretch;
} 
 .input-country-country-selection-popup-header {
  align-self: flex-start;
  margin-bottom: 10px;
} 
 .input-country-country-selection-popup-content {
  align-self: stretch;
  overflow: auto;
  flex-basis: 60vh;
} 
 .input-country-country-selection-popup-footer {
  align-self: flex-end;
  margin-top: 10px; 
} 

Note how the style names for the popup have been associated with an imaginary input-country namespace by prefixing the style names with the name of our component. This is done to reduce the chances of collisions with styles defined in other components or in the consuming applications. You now need to restart ojet serve in order to pick up the changes in styles.css. Clicking the icon at this point results in a popup with little content:

Figure 9
Stage 4 – Add OpenLayers to the Component and a World Map to the Popup

In this stage we will start the integration of OpenLayer in our component. Extract the contents from the OpenLayers distribution zip file (ol-debug.js, ol.js and ol.css) to a new directory: libs\openlayers under the composite component’s root: src\js\jet-composites\input-country . Modify the contents of loader.js to load the OpenLayers JavaScript library (as module inputCountry/ol) and CSS source, like this:

requirejs.config(
  { // Path mappings for the logical module names
    paths:
    { 'input-country/ol': '../'+require.toUrl('input-country/libs/openlayers/ol-debug')
    }
  }
  );
/**
  Copyright (c) 2015, 2017, Oracle and/or its affiliates.
  The Universal Permissive License (UPL), Version 1.0
*/
define(['ojs/ojcore', 'text!./view.html', './viewModel', 'text!./component.json','input-country/ol', 'css!./styles', 
	'css!./libs/openlayers/ol', 'ojs/ojcomposite'],
  function(oj, view, viewModel, metadata) {
    oj.Composite.register('input-country', {
      view: {inline: view}, 
      viewModel: {inline: viewModel}, 
      metadata: {inline: JSON.parse(metadata)}
    });
  }
);

The other changes are all in viewModel.js. First of all, the inputCountry/ol module needs to be added to the define call:

define(
    ['ojs/ojcore', 'knockout', 'jquery', 'input-country/ol', 'ojs/ojbutton', 'ojs/ojpopup'], function (oj, ko, $, ol) {
        'use strict';


Then, the OpenLayers world map has to be initialized when the popup opens. Therefore, this line should be added to the openPopup function:

// if the map has not yet been initialized, then do the initialization now (this is the case the first time the popup opens)
if (!self.map) initMap();

Finally, the function that does the actual map initialization needs to be added to the InputCountryComponentModel:

function initMap() {
    self.map = new ol.Map({
        layers: [
            new ol.layer.Tile({
                id: "world",
                source: new ol.source.OSM()
            })
        ],
        target: 'mapContainer'+self.unique,
        view: new ol.View({
            center: [0, 0],
            zoom: 2
        })
    });
}//initMap

Here you see the first little bit of OpenLayers: a map is instantiated with a layer based on the OpenStreetMap data source and with a view centered at coordinates [0,0] with zoom level two (almost completely zoomed out). Feel free to experiment with these coordinates and the zoom level. Note the use of self.unique for setting the element id of the map container element to the target property on the map object. The value of this property was set during the initialization of InputCountryComponentModel, based on the unique property in the context parameter. Its value corresponds to what the expression $uniqueId resolves to in the view view.html.

Restart ojet serve. When the application is running again, a popup with a world map in it should open when you click the image in the input field:

Figure 10

Out of the box, maps in OpenLayers can be panned – click plus drag – and be zoomed in (with double click) and zoomed out (using shift+double click), or using corresponding gestures on touch devices or the controls in the upper left-hand corner of the map.

Figure 11

 

Stage 5 – Leverage OpenLayers for a Richer Map Experience

If we want to select countries on the map, we should demarcate them and provide a visual hint as to which country the user is hovering over. Additionally when a country is clicked, it should be highlighted as to make clear that it was selected. This can easily be achieved using OpenLayers. First we add a second layer to the map, one that draws the country outlines based on a GeoJSON file with vector coordinates.

Figure 12

Copy file countries.geo.json to the root directory of the input-country component, as shown below.

Replace function initMap() in viewModel.js with the following code:

initMap()

            function initMap() {
                var style = new ol.style.Style({
                    fill: new ol.style.Fill({
                        color: 'rgba(255, 255, 255, 0.6)'
                    }),
                    stroke: new ol.style.Stroke({
                        color: '#319FD3',
                        width: 1
                    }),
                    text: new ol.style.Text()
                }); //style

                self.countriesVector = new ol.source.Vector({
                    url: require.toUrl('input-country/countries.geo.json'),
                    format: new ol.format.GeoJSON()
                });

                self.map = new ol.Map({
                    layers: [new ol.layer.Tile({
                        id: "world",
                        source: new ol.source.OSM()
                    }),
                    new ol.layer.Vector({
                        id: "countries",
                        renderMode: 'image',
                        source: self.countriesVector,
                        style: function (feature) {
                            style.getText().setText(feature.get('name'));
                            return style;
                        }
                    })

                    ],
                    target: 'mapContainer'+self.unique,
                    view: new ol.View({
                        center: [0, 0],
                        zoom: 2
                    })
                });
            }//initMap

Vector countriesVector is created based on the country features in the countries.geo.json file. Based on the vector, a second layer is added to the map, to render the outlines of the countries using the style that defines the color and width of the borders as well as the country-specific text. Restart ojet serve and inspect the map. Play with the style to see the effects on the map.

Figure 13

We will next add a visual effect to the map to indicate the country currently hovered over. Add this code to function initMap():

initMap()
                // layer to hold (and highlight) currently hovered over highlighted (not yet selected) feature(s) 
                var featureOverlay = new ol.layer.Vector({
                    source: new ol.source.Vector(),
                    map: self.map,
                    style: new ol.style.Style({
                        stroke: new ol.style.Stroke({
                            color: '#f00',
                            width: 1
                        }),
                        fill: new ol.style.Fill({
                            color: 'rgba(255,0,0,0.1)'
                        })
                    })
                });

                var highlight;

                // function to get hold of the feature under the current mouse position;
                // the country associated with that feature is displayed in the info box
                // the feature itself is highlighted (added to the featureOverlay defined just ovehead)
                var displayFeatureInfo = function (pixel) {
                    var feature = self.map.forEachFeatureAtPixel(pixel, function (feature) {
                        return feature;
                    });

                    var info = document.getElementById('countryInfo'+self.unique);
                    if (feature) {
                        info.innerHTML = feature.getId() + ': ' + feature.get('name');
                    } else {
                        info.innerHTML = ' ';
                    }

                    if (feature !== highlight) {
                        if (highlight) {
                            featureOverlay.getSource().removeFeature(highlight);
                        }
                        if (feature) {
                            featureOverlay.getSource().addFeature(feature);
                        }
                        highlight = feature;
                    }

                };

                self.map.on('pointermove', function (evt) {
                    if (evt.dragging) {
                        return;
                    }
                    var pixel = self.map.getEventPixel(evt.originalEvent);
                    displayFeatureInfo(pixel);
                });

The featureOverlay vector is associated with the map. It defines a bold style – red fill and bold red outline – for the features it contains. The pointermove event listener on the map is triggered when the user moves the mouse around. The function displayFeatureInfo is invoked to find the feature (i.e., country) at the current mouse position, display its name in the info DIV element in the upper left-hand corner and add that country to the featureOverlay vector. The screenshot demonstrates the visual effect in the map:

Figure 14

Now we also want to select a country, not just hover over it. For this we will add an OpenLayers Interaction element to the map. This code too is added to (the end of) function initMap():

                // define the style to apply to selected countries
                var selectCountryStyle = new ol.style.Style({
                    stroke: new ol.style.Stroke({
                        color: '#ff0000',
                        width: 2
                    })
                    , fill: new ol.style.Fill({
                        color: 'red'
                    })
                });
                self.selectInteraction = new ol.interaction.Select({
                    condition: ol.events.condition.singleClick,
                    toggleCondition: ol.events.condition.shiftKeyOnly,
                    layers: function (layer) {
                        return layer.get('id') == 'countries';
                    },
                    style: selectCountryStyle

                });

                self.map.getInteractions().extend([self.selectInteraction]);

                // add an event handler to the interaction
                self.selectInteraction.on('select', function (e) {
                    //to ensure only a single country can be selected at any given time
                    // find the most recently selected feature, clear the set of selected features and 
						add the selected the feature (as the only one)
                    var f = self.selectInteraction.getFeatures()
                    var selectedFeature = f.getArray()[f.getLength() - 1]
                    self.selectInteraction.getFeatures().clear();
                    self.selectInteraction.getFeatures().push(selectedFeature);
                    self.countrySelection = { "code": selectedFeature.id_, "name": selectedFeature.values_.name };
                });

The interaction is triggered by the select event – a single click on a feature in the countries layer – and undone with shift+click. The selectCountryStyle is applied to the selected (country) feature. Feel free to play with this style, which currently fills and outlines the feature with bright red. The select event handler on this interaction element ensures that only a single country can be selected at any one time (as that is the intended behavior of the component, although multi-selection can be interesting too). Details for the selected country are stored in the variable self.countrySelection. We will use this variable when the selection is passed back to the consuming page. This change is immediately reloaded to the browser, and now we can select a country by clicking on it:

Figure 15

 

Stage 6 – Data Binding the Input-Country Component

The purpose of our component is to allow users to select a country and also to return that selection to the underlying viewModel. To make that happen, we should take the selected country on the map and set its name on the countryName property. First, add a Save button in view.html, to allow users to save their selection.

                <oj-button :id="[['btnSave'+ $uniqueId ]]" data-bind="click: save">
                      Save
                  </oj-button>

The button invokes a save function that should be defined on the viewModel:

          context.props.then(function (propertyMap) {
                //Store a reference to the properties for any later use
                self.properties = propertyMap;
                //Parse your component properties here 

                // property countrySelectionHandler may contain a function to be called when a country has been selected by the user
                self.callbackHandler = self.properties['countrySelectionHandler'];
            });
  


            // this function writes the selected country name to the two way bound countryName property, calls the callback 
				function and publishes the countrySelected event
            // (based on the currently selected country in self.countrySelection)
            self.save = function () {
                if (self.countrySelection && self.countrySelection.name) {
                  // set selected country name on the observable
                  self.properties['countryName'] = self.countrySelection.name;
                  // notify the world about this change
                  if (self.callbackHandler) { self.callbackHandler(self.countrySelection.name, self.countrySelection.code) }
                }
                // close popup
                $('#countrySelectionPopup' + self.unique).ojPopup("close");
            }//sav

This snippet also has us read the propertyMap from the context parameter that is passed in to the composite component with all the properties defined on the component – in our case, in workarea.html. When the context.props promise is resolved, the propertyMap is stored in self.properties where it can be accessed in the save function. The countryName property is defined as writeback in component.json. This means that changes in the property value are communicated back to the observable that is bound to the property – in our case, the two observables in workarea.js.

Figure 16

When the user selects a country and presses the Save button, the contents of the input-text field now reflects the selection. What’s more, the h3 element underneath the field is also updated. This happens because it is bound to the same observable as the input-country component and is therefore updated at the same time.

Figure 17

You’ve probably noticed property self.callbackHandler, which was set based on a property countrySelectionHandler in the component’s propertyMap. You may recall that in workarea.html, values were defined for this property, bound to functions handleCountrySelection and handleCountry2Selection in workarea.js. The definition of this property has to be added to component.json:

    "countrySelectionHandler": {
      "description": "Provide a function to be called back whenever a country has been selected",
      "type": "function(string,string):boolean"
    }

When the user presses the Save button on the popup after a selection is made, the function provided in property countrySelectionHandler will be invoked. It should accept two input parameters that will receive the name and code of the selected country. In the next stage, we will discuss events as an alternative mechanism for publishing the country selection. Check the console window in your browser to see the effect of the callback function – a single line of logging is written.

Figure 18

Data binding happens in two directions. Not only does our component pass back the selected country, it also receives the initial country name and it should show that country as pre-selected on the map. Open viewModel.js and add this code snippet to replace the current openPopup{} function.

            self.popupFirstTime = true;            
            self.openPopup = function () {
                $('#countrySelectionPopup' + self.unique).ojPopup("open");
                // if the map has not yet been initialized, then do the initialization now (this is the case the first time the popup opens)
                if (!self.map) initMap();
                // set the currently selected country - but only if this is not the first time the popup opens 
					(and we can be sure that the country vector has been loaded)
                // note: as soon as the vector has finished loading, a listener fires () and sets the currently 
					selected country ; see var listenerKey in function initMap();
                if (!self.popupFirstTime) {
                    self.selectInteraction.getFeatures().clear();
                    if (self.properties['countryName'])
                      self.setSelectedCountry(self.properties['countryName'])
                } else 
                    self.popupFirstTime=false;
            }//openPopup

            self.setSelectedCountry = function (country) {
                //programmatic selection of a feature; based on the name, a feature is searched for in countriesVector and 
					when found is highlighted
                var countryFeatures = self.countriesVector.getFeatures();
                var c = self.countriesVector.getFeatures().filter(function (feature) 
					{ return feature.values_.name == country });
                self.selectInteraction.getFeatures().push(c[0]);
            }

A new function is created to add the feature for a country from the countriesVector to the selected features vector on the selectInteraction. This function is called from openPopup, but only if the popup has been opened before. Because the countriesVector is loaded asynchronously and typically is available only after the popup has loaded, we use an event listener on the countriesVector to set the selected country upon the initialization of the popup, the map and the vector. Insert this snippet right after the definition of the countriesVector:

                // register a listener on the vector; as soon as it has loaded, we can select the feature for the 
					currently selected country
                var listenerKey = self.countriesVector.on('change', function (e) {
                    if (self.countriesVector.getState() == 'ready') {
                        // and unregister the "change" listener 
                        ol.Observable.unByKey(listenerKey);
                        if (self.properties['countryName'])
                            self.setSelectedCountry(self.properties['countryName'])
                    }
                });

When you now open the popup, the country whose name is the current value in the input field is preselected on the map. If you type the (correct, init-capped) name of a country into the field and then open the popup, this country will be highlighted.

Figure 19

It would be convenient for our users if they can confirm their selection with a single click on an already selected country, without having to click the Save button. This click event listener on the map object does exactly that (add it near the end of the initMap function):

                // handle the singleclick event- in case a country is clicked that is already selected
                self.map.on('singleclick', function (evt) {
                    var feature = self.map.forEachFeatureAtPixel(evt.pixel,
                        function (feature, layer) {
                            var clickCountrySelection = { "code": feature.id_, "name": feature.values_.name };
                            if (self.countrySelection && self.countrySelection.name && 
								(self.countrySelection.name == clickCountrySelection.name)) {
                                // the current selection is confirmed (clicked on a second time). 
									We interpret this as: Save the selected country and close the popup  
                                self.save();
                                return;
                            }
                            return [feature, layer];
                        });
                });
Stage 7 – Publishing and Handling the CountrySelected Event

Composite components can return data through writeback-enabled data-bound properties and by calling [back] functions, as we have seen in the previous section. Additionally, composite components can publish events that the consuming viewModel can process as it sees fit. In workarea.html you may have seen the attribute on-country-selected that is bound to the function self.countrySelectedHandler defined in workarea.js. This function takes an event as its input – and expects this event to contain a property called detail. The contents of this property can be defined by us, in any way we see fit. At present, the input-country component does not publish this custom event. It does, however, publish an out-of-the-box event called countryNameChanged – courtesy of the JET framework for all writeback-enabled properties. The countryNameChanged event also carries a detail property that provides both the new and the previous values of the countryName. This event is handled by function handleCountryNameChangedHandler in workarea.js. We will now also add support for publishing the custom countrySelected event in a few quick steps: First, add the definition of the countrySelected event in component.json:

"events" : {
    "countrySelected" : {
      "description" : "The event that consuming views can use to recognize when a country has been selected",
      "bubbles" : true,
      "cancelable" : false,
      "detail" : {
        "countryName" : {"type" : "string"}
        ,"countryCode" : {"type" : "string"}
      }
    }
  }


Next, create function raiseCountrySelectedEvent in viewModel.js that will raise the event when called:

            self.raiseCountrySelectedEvent = function (countryName, countryCode) {
                var eventParams = {
                    'bubbles': true,
                    'cancelable': false,
                    'detail': {
                        'countryName': countryName
                        , 'countryCode': countryCode
                    }
                };
                //Raise the custom event on the composite component
                self.composite.dispatchEvent(new CustomEvent('countrySelected',
                    eventParams));
            }

Finally, add this call to function raiseCountrySelectedEvent in function save:

                // report the country selection event
                self.raiseCountrySelectedEvent(self.countrySelection.name, self.countrySelection.code);

When the user now selects a country, the countrySelected event is raised, just as is promised in the specification in component.json. In the console window in the browser, you can inspect the somewhat unexciting event handling by the countrySelectedHandler function in the workarea viewModel.

Figure 20

Package, Distribute and Reuse the Composite Component The InputCountry component is now fully defined by the sources in the directory jet-composites/input-country. You can create a zip-file from this directory by way of packaging up the component. This zip-file can be shared with developers working either on their own JET applications or on Visual Builder Cloud applications.

To use the component in a JET application, these steps are required:

  • Unzip the zip-file to the src/js/jet-composites directory in the target JET project
  • Add a reference to the component in the define() call in the viewModel for the view that consumes the component: 'jet-composites/input-country/loader' (just as in workarea.js in this article)
  • Add the markup for the component to the view’s HTML document: (similar to workarea.html in this article)

Read the documentation on importing and using Composite Components in Visual Builder Cloud: https://docs.oracle.com/en/cloud/paas/integration-cloud/visual-user/working-application-extensions.html#GUID-5C9C21C1-EA65-4044-B849-4C6E7B3187A4. Hints The two layers defined on the map could be reordered – OpenStreetMap tile on top of GeoJSON Countries vector – for a different and in my view nicer look & feel:

Figure 21

The component can easily be further tuned to support other behavior, such as displaying (and selecting) multiple countries, or regions or cities. As a first step, it takes hardly any effort, for example, to show major cities on the map:

viewModel.js (extra layer in self.map definition)

                        , new ol.layer.Vector({
                            id: "cities",
                            renderMode: 'image',
                            source: new ol.source.Vector({
                                url: require.toUrl('input-country/cities.geojson'),
                                format: new ol.format.GeoJSON()
                            }),
                        })

The result is as follows, with a few hundred world cities being displayed on the map.

Figure 22

Good to know: GeoJSON files are available with details for tens of thousands of cities as well as for many other location-based data sets. Take a look, for example, at 1500+ GeoJSON data sets published by the US Government at: https://catalog.data.gov/dataset?res_format=GeoJSON. Additionally, on http://geojson.io you will find an editor that allows you to easily create your own GeoJSON file. Fun fact: Any geojson file in a GitHub repository is automatically rendered as an interactive, browsable map, annotated with geodata. Note: I would like to thank Duncan Mills, Architect at Oracle, for his generous and valuable help on this article.

About the Author
Lucas Jellema is solution architect and CTO at AMIS, based in the Netherlands. An Oracle, Java, and SOA specialist, he works as a consultant, architect, and instructor in diverse areas such as Database & SQL, Java/Java EE, SOA/BPM and PaaS and SaaS Cloud Solutions. The running theme through most of his activities is the transfer of knowledge and enthusiasm (and live demos). Lucas is a well-known speaker at Oracle OpenWorld, JavaOne and various Oracle User Group conferences around the world. His articles have appeared in OTN, OTech and the AMIS Technology Weblog, and he is the author of Oracle SOA Suite 11g Handbook (2014, McGraw Hill) and the Oracle SOA Suite 12c Handbook (2015, McGraw Hill)
Join the Database Community Conversation
DEVO_ATTACH_BOTTOM
Experience Oracle Cloud —Get up to 3,500 hours free.