J3.x

Developing an MVC Component/Adding AJAX

From Joomla! Documentation

< J3.x:Developing an MVC Component
Other languages:
English • ‎español • ‎français
Joomla! 
3.x
Tutorial
Developing an MVC Component


This is a multiple-article series of tutorials on how to develop a Model-View-Controller Component for Joomla! VersionJoomla 3.x.

Begin with the Introduction, and navigate the articles in this series by using the navigation button at the bottom or the box to the right (the Articles in this series).



This tutorial is part of the Developing a MVC Component for Joomla! 3.x tutorial. You are encouraged to read the previous parts of the tutorial before reading this. This step introduces how to use AJAX within a Joomla component.

A video accompanying this step can be found at step 19 adding ajax (although the video was recorded before some of the language strings were properly coded).

Functionality

In the previous step we added a map to our site web page. In this step we extend this to provide a "Search Here" facility for the map.

On our site page we'll display a "Search Here" button, and when the user clicks this the javascript will make an Ajax call to the server to retrieve those Helloworld records which have latitude and longitude values which fall within the bounds of the map, as it's currently displayed on the page.

We'll then display the results of that search in a series of rows under the map.

Approach

Initiating the Ajax Request

On our Search Here button we'll put an onclick listener, and in that javascript function we'll initiate the Ajax call. We'll use the jQuery library call for the Ajax request, as that makes it all fairly straightforward.

We'll need to get the bounds of the map in lat/long format, and include that in our Ajax request to the server. We'll also set a task= parameter to call a specific controller function, to keep our Ajax-handling code separate. And we'll also indicate that we expect the result returned to be in JSON format.

As security is as important here as always, we'll include a form token on the site web page which we'll also send in the Ajax request.

Handling the Ajax Request

On the server we'll extend our Joomla PHP code and structure it according to the MVC pattern:

  • In our existing controller we'll add a new function, whose main purpose will be to check the security token
  • We will have a new view file which will contain the main logic for pulling together the JSON response
  • We will extend our existing model to handle the SQL query for finding the records whose lat/long values fit within the lat/long bounds of the map.

Handling the Ajax Response

Back in our Javascript code we'll interpret the resulting JSON returned, and output the records in some HTML below the map.

Error Handling

A significant part of our code will be associated with handling the various errors which may occur. We'll use the Joomla JResponseJson class to help both with forming the JSON response and in handling the various error scenarios. It's worth reading the good description of this functionality at JSON Responses with JResponseJson.

Site Helloworld Page

We add 3 fields within a new div section below the map:

  • A Search Here button, styled using Bootstrap, and with an onclick listener
  • A Joomla form token
  • A subsidiary div which is the area where we will output the search results

site/views/helloworld/tmpl/default.php

<?php
/**
 * @package     Joomla.Administrator
 * @subpackage  com_helloworld
 *
 * @copyright   Copyright (C) 2005 - 2018 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

// No direct access to this file
defined('_JEXEC') or die('Restricted access');
?>
<h1><?php echo $this->item->greeting.(($this->item->category and $this->item->params->get('show_category'))
                                      ? (' ('.$this->item->category.')') : ''); ?>
</h1>
<?php
    $src = $this->item->imageDetails['image'];
    if ($src)
    {
        $html = '<figure>
                    <img src="%s" alt="%s" >
                    <figcaption>%s</figcaption>
                </figure>';
        $alt = $this->item->imageDetails['alt'];
        $caption = $this->item->imageDetails['caption'];
        echo sprintf($html, $src, $alt, $caption);
    } ?>
<div id="map" class="map"></div>
<div class="map-callout map-callout-bottom" id="greeting-container"></div>
<div id="searchmap">
    <?php echo '<input id="token" type="hidden" name="' . JSession::getFormToken() . '" value="1" />'; ?>
    <button type="button" class="btn btn-primary" onclick="searchHere();">
        <?php echo JText::_('COM_HELLOWORLD_SEARCH_HERE_BUTTON') ?>
    </button>
    <div id="searchresults">
    </div>
</div>

Javascript Code

Into our javascript code we'll add 3 functions:

  • the onclick listener which will initiate the Ajax call
  • a function to get the bounds of the map in lat/long format
  • a function to display the results

media/js/openstreetmap.js

var map;

jQuery(document).ready(function() {
    
    // get the data passed from Joomla PHP
    // params is a Javascript object with properties for the map display: 
    // centre latitude, centre longitude and zoom, and the helloworld greeting
    const params = Joomla.getOptions('params');
    
    // We'll use OpenLayers to draw the map (http://openlayers.org/)
    
    // Openlayers uses an x,y coordinate system for positions
    // We need to convert our lat/long into an x,y pair which is relative
    // to the map projection we're using, viz Spherical Mercator WGS 84
    const x = parseFloat(params.longitude);
    const y = parseFloat(params.latitude);
    const mapCentre = ol.proj.fromLonLat([x, y]); // Spherical Mercator is assumed by default
    
    // To draw a map, Openlayers needs:
    // 1. a target HTML element into which the map is put
    // 2. a map layer, which can be eg a Vector layer with details of polygons for
    //    country boundaries, lines for roads, etc, or a Tile layer, with individual
    //    .png files for each map tile (256 by 256 pixel square).
    // 3. a view, specifying the 2D projection of the map (default Spherical Mercator),
    //    map centre coordinates and zoom level
    map = new ol.Map({
        target: 'map',
        layers: [
            new ol.layer.Tile({  // we'll get the tiles from the OSM server
                source: new ol.source.OSM()
            })
        ],
        view: new ol.View({  // default is Spherical Mercator projection
            center: mapCentre,
            zoom: params.zoom
        })
    });
    
    // Now we add a marker for our Helloworld position
    // To do that, we specify it as a Point Feature, and we add styling 
    // to define how that Feature is presented on the map
    var helloworldPoint = new ol.Feature({geometry: new ol.geom.Point(mapCentre)});
    // we'll define the style as a red 5 point star with blue edging
    const redFill = new ol.style.Fill({
        color: 'red'
    });
    const blueStroke = new ol.style.Stroke({
        color: 'blue',
        width: 3
    });
    const star = new ol.style.RegularShape({
        fill: redFill,
        stroke: blueStroke,
        points: 5,
        radius1: 20,   // outer radius of star
        radius2: 10,   // inner radius of star
    })
    helloworldPoint.setStyle(new ol.style.Style({
        image: star
    }));
    // now we add the feature to the map via a Vector source and Vector layer
    const vectorSource = new ol.source.Vector({});
    vectorSource.addFeature(helloworldPoint);
    const vector = new ol.layer.Vector({
        source: vectorSource
    });
    map.addLayer(vector);
    
    // If a user clicks on the star, then we'll show the helloworld greeting
    // The greeting will go into another HTML element, with id="greeting-container"
    // and this will be shown as an Overlay on the map
    var overlay = new ol.Overlay({
        element: document.getElementById('greeting-container'),
    });
    map.addOverlay(overlay);
        
    // Finally we add the onclick listener to display the greeting when the star is clicked
    // The way this works is that the onclick listener is attached to the map,
    // and then it works out which Feature or Features have been hit
    map.on('click', function(e) {
        let markup = '';
        let position;
        map.forEachFeatureAtPixel(e.pixel, function(feature) {  // for each Feature hit
            markup = params.greeting;
            position = feature.getGeometry().getCoordinates();
        }, {hitTolerance: 5});  // tolerance of 5 pixels
        if (markup) {
            document.getElementById('greeting-container').innerHTML = markup;
            overlay.setPosition(position);
        } else {
            overlay.setPosition();  // this hides it, if we click elsewhere
        }
    });    
});

function getMapBounds(){
    var mercatorMapbounds = map.getView().calculateExtent(map.getSize());
    var latlngMapbounds = ol.proj.transformExtent(mercatorMapbounds,'EPSG:3857','EPSG:4326');
    return { minlat: latlngMapbounds[1],
             maxlat: latlngMapbounds[3],
             minlng: latlngMapbounds[0],
             maxlng: latlngMapbounds[2] }
}
    
function searchHere() {
    var mapBounds = getMapBounds();
    var token = jQuery("#token").attr("name");
    jQuery.ajax({
        data: { [token]: "1", task: "mapsearch", format: "json", mapBounds: mapBounds },
        success: function(result, status, xhr) { displaySearchResults(result); },
        error: function() { console.log('ajax call failed'); },
    });
}

function displaySearchResults(result) {
    if (result.success) {
        var html = "";
        for (var i=0; i<result.data.length; i++) {
            html += "<p>" + result.data[i].greeting + 
                " @ " + result.data[i].latitude + 
                ", " + result.data[i].longitude + "</p>";
        }
        jQuery("#searchresults").html(html);
    } else {
        var msg = result.message;
        if ((result.messages) && (result.messages.error)) {
            for (var j=0; j<result.messages.error.length; j++) {
                msg += "<br/>" + result.messages.error[j];
            }
        }
        jQuery("#searchresults").html(msg);
    }
}

If you're using Google Maps instead of Openlayers to display the map, then the way to find the map bounds is described in this stackoverflow question.

Server Code

Into our main site controller we include the checking of the token. If it passes we call the standard controller display() function.

site/controller.php

<?php
/**
 * @package     Joomla.Administrator
 * @subpackage  com_helloworld
 *
 * @copyright   Copyright (C) 2005 - 2018 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */
// No direct access to this file
defined('_JEXEC') or die('Restricted access');
/**
 * Hello World Component Controller
 *
 * @since  0.0.1
 */
class HelloWorldController extends JControllerLegacy
{
    public function mapsearch()
    {
        if (!JSession::checkToken('get')) 
        {
            echo new JResponseJson(null, JText::_('JINVALID_TOKEN'), true);
        }
        else 
        {
            parent::display();
        }
    }
}

The standard display() function sets up the model and the view, and then calls the display() function of the view. However, because we've included format=json as a parameter in our Ajax HTTP request, Joomla will look for this function in a view.json.php file instead of the usual view.html.php file. So we put our view code into this new file.

site/views/helloworld/view.json.php

<?php
/**
 * View file for responding to Ajax request for performing Search Here on the map
 * 
 */
 
// No direct access to this file
defined('_JEXEC') or die('Restricted access');
 
class HelloWorldViewHelloWorld extends JViewLegacy
{
	/**
	 * This display function returns in json format the Helloworld greetings
	 *   found within the latitude and longitude boundaries of the map.
	 * These bounds are provided in the parameters
	 *   minlat, minlng, maxlat, maxlng
	 */

	function display($tpl = null)
	{
		$input = JFactory::getApplication()->input;
		$mapbounds = $input->get('mapBounds', array(), 'ARRAY');
		$model = $this->getModel();
		if ($mapbounds)
		{
			$records = $model->getMapSearchResults($mapbounds);
			if ($records) 
			{
				echo new JResponseJson($records);
			}
			else
			{
				echo new JResponseJson(null, JText::_('COM_HELLOWORLD_ERROR_NO_RECORDS'), true);
			}
		}
		else 
		{
			$records = array();
			echo new JResponseJson(null, JText::_('COM_HELLOWORLD_ERROR_NO_MAP_BOUNDS'), true);
		}
	}
}

Finally we include our getMapSearchResults function in our existing model.

site/models/helloworld.php

<?php
/**
 * @package     Joomla.Administrator
 * @subpackage  com_helloworld
 *
 * @copyright   Copyright (C) 2005 - 2018 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

// No direct access to this file
defined('_JEXEC') or die('Restricted access');

/**
 * HelloWorld Model
 *
 * @since  0.0.1
 */
class HelloWorldModelHelloWorld extends JModelItem
{
	/**
	 * @var object item
	 */
	protected $item;

	/**
	 * Method to auto-populate the model state.
	 *
	 * This method should only be called once per instantiation and is designed
	 * to be called on the first call to the getState() method unless the model
	 * configuration flag to ignore the request is set.
	 *
	 * Note. Calling getState in this method will result in recursion.
	 *
	 * @return	void
	 * @since	2.5
	 */
	protected function populateState()
	{
		// Get the message id
		$jinput = JFactory::getApplication()->input;
		$id     = $jinput->get('id', 1, 'INT');
		$this->setState('message.id', $id);

		// Load the parameters.
		$this->setState('params', JFactory::getApplication()->getParams());
		parent::populateState();
	}

	/**
	 * Method to get a table object, load it if necessary.
	 *
	 * @param   string  $type    The table name. Optional.
	 * @param   string  $prefix  The class prefix. Optional.
	 * @param   array   $config  Configuration array for model. Optional.
	 *
	 * @return  JTable  A JTable object
	 *
	 * @since   1.6
	 */
	public function getTable($type = 'HelloWorld', $prefix = 'HelloWorldTable', $config = array())
	{
		return JTable::getInstance($type, $prefix, $config);
	}

	/**
	 * Get the message
	 * @return object The message to be displayed to the user
	 */
	public function getItem()
	{
		if (!isset($this->item)) 
		{
			$id    = $this->getState('message.id');
			$db    = JFactory::getDbo();
			$query = $db->getQuery(true);
			$query->select('h.greeting, h.params, h.image as image, c.title as category, h.latitude as latitude, h.longitude as longitude')
				  ->from('#__helloworld as h')
				  ->leftJoin('#__categories as c ON h.catid=c.id')
				  ->where('h.id=' . (int)$id);
			$db->setQuery((string)$query);
		
			if ($this->item = $db->loadObject()) 
			{
				// Load the JSON string
				$params = new JRegistry;
				$params->loadString($this->item->params, 'JSON');
				$this->item->params = $params;

				// Merge global params with item params
				$params = clone $this->getState('params');
				$params->merge($this->item->params);
				$this->item->params = $params;

				// Convert the JSON-encoded image info into an array
				$image = new JRegistry;
				$image->loadString($this->item->image, 'JSON');
				$this->item->imageDetails = $image;
			}
		}
		return $this->item;
	}

	public function getMapParams()
	{
		if ($this->item) 
		{
			$this->mapParams = array(
				'latitude' => $this->item->latitude,
				'longitude' => $this->item->longitude,
				'zoom' => 10,
				'greeting' => $this->item->greeting
			);
			return $this->mapParams; 
		}
		else
		{
			throw new Exception('No helloworld details available for map', 500);
		}
	}

	public function getMapSearchResults($mapbounds)
	{
		try 
		{
			$db    = JFactory::getDbo();
			$query = $db->getQuery(true);
			$query->select('h.greeting, h.latitude, h.longitude')
			   ->from('#__helloworld as h')
			   ->where('h.latitude > ' . $mapbounds['minlat'] . 
				' AND h.latitude < ' . $mapbounds['maxlat'] .
				' AND h.longitude > ' . $mapbounds['minlng'] .
				' AND h.longitude < ' . $mapbounds['maxlng']);
			$db->setQuery($query);
			$results = $db->loadObjectList(); 
		}
		catch (Exception $e)
		{
			$msg = $e->getMessage();
			JFactory::getApplication()->enqueueMessage($msg, 'error'); 
			$results = null;
		}

		return $results; 
	}
}

Updated Language Strings

site/language/en-GB/en-GB.com_helloworld.ini

; add new message form
COM_HELLOWORLD_LEGEND_DETAILS="New Helloworld Message Details"
COM_HELLOWORLD_HELLOWORLD_CREATING="Add message"
COM_HELLOWORLD_HELLOWORLD_ERROR_UNACCEPTABLE="Sorry, you have an error"
COM_HELLOWORLD_HELLOWORLD_DETAILS="Message details"
COM_HELLOWORLD_HELLOWORLD_GREETING_LABEL="Greeting"
COM_HELLOWORLD_HELLOWORLD_GREETING_DESC="Please specify the greeting to add"
COM_HELLOWORLD_HELLOWORLD_GREETING_HINT="Letters only!"
COM_HELLOWORLD_HELLOWORLD_FIELD_CATID_LABEL="Category"
COM_HELLOWORLD_HELLOWORLD_FIELD_CATID_DESC="Please select the associated category"
COM_HELLOWORLD_HELLOWORLD_MESSAGE_LABEL="Reason"
COM_HELLOWORLD_HELLOWORLD_MESSAGE_DESC="Please say why you're adding this greeting"
COM_HELLOWORLD_HELLOWORLD_MESSAGE_HINT="No HTML tags!"
COM_HELLOWORLD_HELLOWORLD_FIELD_CAPTCHA_LABEL="Spam protection"
COM_HELLOWORLD_HELLOWORLD_FIELD_CAPTCHA_DESC="Prove you're a real person!"
COM_HELLOWORLD_HELLOWORLD_FIELD_SHOW_CATEGORY_LABEL="Display category or not?"
COM_HELLOWORLD_HELLOWORLD_FIELD_SHOW_CATEGORY_DESC="Select if you want the category displayed too"
COM_HELLOWORLD_HELLOWORLD_IMAGE_LABEL="Image information"
COM_HELLOWORLD_HELLOWORLD_PICTURE_LABEL="Image file to upload"
COM_HELLOWORLD_HELLOWORLD_PICTURE_DESC="Select the file with the image to upload"
COM_HELLOWORLD_HELLOWORLD_CAPTION_LABEL="Caption"
COM_HELLOWORLD_HELLOWORLD_CAPTION_DESC="Text to use as a caption for the image"
COM_HELLOWORLD_HELLOWORLD_ALTTEXT_LABEL="Alt text"
COM_HELLOWORLD_HELLOWORLD_ALTTEXT_DESC="Text to display if image cannot be shown"
; save and cancel confirmation messages
COM_HELLOWORLD_ADD_SUCCESSFUL="New greeting successfully saved"
COM_HELLOWORLD_ADD_CANCELLED="New greeting cancelled ok"
; file upload error conditions
COM_HELLOWORLD_ERROR_FILEUPLOAD="PHP Error %s encountered when uploading file"
COM_HELLOWORLD_ERROR_FILETOOLARGE="Upload file exceeds max size configured in Joomla"
COM_HELLOWORLD_ERROR_BADFILENAME="Upload file has an invalid filename"
COM_HELLOWORLD_ERROR_FILE_EXISTS="Upload file already exists"
COM_HELLOWORLD_ERROR_UNABLE_TO_UPLOAD_FILE="Error creating uploaded file"
; helloworld greeting page
COM_HELLOWORLD_SEARCH_HERE_BUTTON="Search here"
; Ajax handling errors
COM_HELLOWORLD_ERROR_NO_RECORDS="Didn't get any records"
COM_HELLOWORLD_ERROR_NO_MAP_BOUNDS="Error: no map bounds"

Packaging the Component

Contents of your code directory. Each file link below takes you to the step in the tutorial which has the latest version of that source code file.

helloworld.xml

<?xml version="1.0" encoding="utf-8"?>
<extension type="component" version="3.0" method="upgrade">

	<name>COM_HELLOWORLD</name>
	<!-- The following elements are optional and free of formatting constraints -->
	<creationDate>January 2018</creationDate>
	<author>John Doe</author>
	<authorEmail>john.doe@example.org</authorEmail>
	<authorUrl>http://www.example.org</authorUrl>
	<copyright>Copyright Info</copyright>
	<license>License Info</license>
	<!--  The version string is recorded in the components table -->
	<version>0.0.19</version>
	<!-- The description is optional and defaults to the name -->
	<description>COM_HELLOWORLD_DESCRIPTION</description>

	<!-- Runs on install/uninstall/update; New in 2.5 -->
	<scriptfile>script.php</scriptfile>

	<install> <!-- Runs on install -->
		<sql>
			<file driver="mysql" charset="utf8">sql/install.mysql.utf8.sql</file>
		</sql>
	</install>
	<uninstall> <!-- Runs on uninstall -->
		<sql>
			<file driver="mysql" charset="utf8">sql/uninstall.mysql.utf8.sql</file>
		</sql>
	</uninstall>
	<update> <!-- Runs on update; New since J2.5 -->
		<schemas>
			<schemapath type="mysql">sql/updates/mysql</schemapath>
		</schemas>
	</update>

	<!-- Site Main File Copy Section -->
	<!-- Note the folder attribute: This attribute describes the folder
		to copy FROM in the package to install therefore files copied
		in this section are copied from /site/ in the package -->
	<files folder="site">
		<filename>index.html</filename>
		<filename>helloworld.php</filename>
		<filename>controller.php</filename>
		<folder>controllers</folder>
		<folder>views</folder>
		<folder>models</folder>
	</files>

        <languages folder="site/language">
		<language tag="en-GB">en-GB/en-GB.com_helloworld.ini</language>
        </languages>

	<media destination="com_helloworld" folder="media">
		<filename>index.html</filename>
		<folder>images</folder>
		<folder>js</folder>
		<folder>css</folder>
	</media>

	<administration>
		<!-- Administration Menu Section -->
		<menu link='index.php?option=com_helloworld' img="../media/com_helloworld/images/tux-16x16.png">COM_HELLOWORLD_MENU</menu>
		<!-- Administration Main File Copy Section -->
		<!-- Note the folder attribute: This attribute describes the folder
			to copy FROM in the package to install therefore files copied
			in this section are copied from /admin/ in the package -->
		<files folder="admin">
			<!-- Admin Main File Copy Section -->
			<filename>index.html</filename>
			<filename>config.xml</filename>
			<filename>helloworld.php</filename>
			<filename>controller.php</filename>
			<filename>access.xml</filename>
			<!-- SQL files section -->
			<folder>sql</folder>
			<!-- tables files section -->
			<folder>tables</folder>
			<!-- models files section -->
			<folder>models</folder>
			<!-- views files section -->
			<folder>views</folder>
			<!-- controllers files section -->
			<folder>controllers</folder>
			<!-- helpers files section -->
			<folder>helpers</folder>
		</files>
		<languages folder="admin/language">
        		<language tag="en-GB">en-GB/en-GB.com_helloworld.ini</language>
                        <language tag="en-GB">en-GB/en-GB.com_helloworld.sys.ini</language>
		</languages>
	</administration>

</extension>

Contributors