J4 Component Tutorial: Mywalks Part 1 - The Site Code

From Tutorials
Jump to navigation Jump to search

J4 Component Tutorial: Mywalks Part 1 - The Site Code

Details
Written by Super User
Published: 06 August 2019
Hits: 41

Background

As an experienced Joomla! 3.x developer I needed to learn about Joomla! 4.x and found it really difficult to get started. Although experienced, my actual knowledge of Joomla internals is limited. I started at the 1.6 stage and many new features passed me by. So this tutorial may be a case of the blind leading the blind. It took me about 10 days to get something to work by reading the code, stepping through with the debugger and reading the limited amount of J4 documentation available. The tutorial started with Joomla 4.x at the Alpha 10 stage so it may be out of date all too soon. There is a 'Hello World' tutorial in preparation by another author so this is something different.

The tutorial text was prepared as an article written with Joomla 4.0 Alpha 10, converted to MediaWiki format with Pandoc and finally finished in a local MediaWiki installation.

Component Purpose and Data Schema

For the last few years I have been going out for walks with my family, sometimes once a week, sometimes twice, but only in good weather. I kept a list - there are 50 or so walks and we have done each of them several times. This was all part of trying to keep fit in old age. So for self-teaching purposes I decided to develop a Joomla! component that has two Site views: the list of walks and the details on individual walks. To keep it simple I don't want any frills: no site side data entry, no scores, hit counters, categories or other Joomla goodies. [At this stage I have no administrator data entry code - sample data has been entered directly into the database with phpMyAdmin.] The component needs two database tables: the list of walks and the list of individual visits. I decided to name the component com_mywalks and the tables #__mywalks and #__mywalks_dates.

All of the code for this tutorial component can be obtained from here: com_mywalks.zip. You might find it helpful to install the component or unpack it without installation and look at the working files.

The mywalks table

At the time of writing the install script in the admin/sql folder is not being called. So, if you are installing a working version of the code for this tutorial, run the following scripts manually first. Is this a bug in Joomla or the Tutorial code? The uninstall script is working - the tables successfully disappear.

CREATE TABLE `#__mywalks` (
`id` int(11) NOT NULL,
`title` varchar(64) NOT NULL,
`description` text NOT NULL,
`distance` decimal(10,0) NOT NULL,
`toilets` tinyint(1) NOT NULL DEFAULT '0',
`cafe` tinyint(1) NOT NULL DEFAULT '0',
`hills` int(11) NOT NULL DEFAULT '0',
`bogs` int(11) NOT NULL DEFAULT '0',
`picture` varchar(128) DEFAULT NULL,
`width` int(11) DEFAULT NULL,
`height` int(11) DEFAULT NULL,
`alt` varchar(64) DEFAULT NULL,
`state` TINYINT NOT NULL DEFAULT '1'; 
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

The mywalks_dates table

CREATE TABLE `#__mywalk_dates` (
`id` int(11) NOT NULL,
`walk_id` int(11) NOT NULL,
`date` date NOT NULL,
`weather` varchar(256) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

If this were a real component it would be obvious that the schema design has a long way to go! Look at the WalkHighlands site to see how far. However, it is enough for tutorial purposes.

The Manifest file and component folder structure

The component zip file used for installation should contain the manifest file named mywalks.xml (notice no com_) along with admin and site folders, like so:

com_mywalks.zip
     admin
     site
     mywalks.xml

On installation the manifest file is copied to the site_root/administrator/components/com_mywalks folder where it is needed for uninstall purposes. It should not be there in the source code! Entries are also made in site_root/libraries/autoload_psr4.php [New in J4].

The manifest file

<?xml version="1.0" encoding="UTF-8"?>
<extension type="component" version="4.0" method="upgrade" client="site">
    <name>Mywalks</name>
    <!-- The following elements are optional and free of formatting conttraints -->
    <creationDate>August 2019</creationDate>
    <author>Clifford E Ford</author>
    <authorEmail></authorEmail>
    <authorUrl>http://www.fford.me.uk/</authorUrl>
    <copyright>Copyright (C) 2019 Clifford E Ford, All rights reserved.</copyright>
    <license>GNU/GPL Version 2 or later - http://www.gnu.org/licenses/gpl-2.0.html</license>
    <!--  The version string is recorded in the components table -->
    <version>0.1.1</version>
    <!-- The description is optional and defaults to the name -->
    <description>Mywalks Component</description>
    <namespace>Joomla\Component\Mywalks</namespace>

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

    <!-- 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">
        <folder>Controller</folder>
        <folder>Dispatcher</folder>
        <folder>forms</folder>
        <folder>Helper</folder>
        <folder>helpers</folder>
        <folder>Model</folder>
        <folder>Service</folder>
        <folder>tmpl</folder>
        <folder>View</folder>
    </files>
    
    <languages folder="site">
        <language tag="en-GB">language/en-GB/en-GB.com_mywalks.ini</language>
    </languages>
    
    <administration>
        <files folder="admin">
            <file>access.xml</file>
            <file>config.xml</file>
            <folder>Extension</folder>
            <folder>services</folder>
            <folder>sql</folder>
        </files>
        <languages folder="admin">
            <language tag="en-GB">language/en-GB/en-GB.com_mywalks.ini</language>
            <language tag="en-GB">language/en-GB/en-GB.com_mywalks.sys.ini</language>
        </languages>
        <menu img="class:default" link="option=com_mywalks">Mywalks</menu>
    </administration>
</extension>

The Namespace

Notice the namespace tag in the manifest file. The first item should be the Company name. I don't have one so I might have used my own name or some non-identifying name such as Mydemos. However as this is a Joomla tutorial I have left it at Joomla. The namespace is used in the extension to distinguish its code from code in other extensions that may have identical class names. The second item is the type of extension: Component, Module, Plugin or Template. The third item is the extension name without preceding com_, mod_, etc., Mywalks in this case.

Language files

In case you are not familiar with Joomla extensions, the source site language folder contains one file: en-GB.com_mywalks.ini, which contains the translated values of fixed strings, used to allow translation from English into other languages. The folder structure is simple:

site                                     - the folder containing the site files       
     language                            - the folder containing the site language translation file
          en-GB                          - the folder containing English translations
               en-GB.com_mywalks.ini     - the file of translated keys

And the en-GB.com_mywalks.ini contains this:

COM_MYWALKS_ERROR_WALK_NOT_FOUND="Walk not found!"
COM_MYWALKS_LIST_PAGE_HEADING="List of Walks"
COM_MYWALKS_LIST_TITLE="Title"
COM_MYWALKS_LIST_DESCRIPTION="Description"
COM_MYWALKS_LIST_DISTANCE="Distance in Km"
COM_MYWALKS_LIST_LAST_VISIT="Last Visit"
COM_MYWALKS_LIST_NVISITS="nVisits"
COM_MYWALKS_LIST_TABLE_CAPTION="List of Walks"

COM_MYWALKS_WALK_REPORTS="Walk Reports"
COM_MYWALKS_WALK_DATE="Visit date"
COM_MYWALKS_WALK_WEATHER="Weather Report"

For each line, the first part is a key and the second part is its value, the English translation Any fixed text in the component site interface should be in this file. For example, the walks list column headings should be keys in the source code and translated here. Note also that the primary language of Joomla is British English. Other languages need separate translation files.

The admin language files: Todo!

The Site Files

You may notice that some of the Joomla! 4.x folder and file names begin with Upper Case letters and others begin with Lower case letters. J4 also has a different structure from J3. In short, files with Upper Case leading character names are name-spaced and those that are not are not. The different organisation in J4 is .... [ToD].

Also, be aware that the code required to display site views also includes some functions from the admin code, covered below.

The tmpl files

The tmpl files contain the code that displays the page views. They ought to be the easiest to explain and to understand. The tmpl file structure in the source code looks like this:

site
     tmpl
          mywalk
              default.php
          mywalks
              default-items.php
              default.php
              default.xml

The single walk view - tmpl/mywalk/default.php:

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

defined('_JEXEC') or die;

//use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;

?>
<div class="page-header">
    <h1><?php echo $this->item->title; ?></h1>
</div>

<p><?php echo $this->item->description; ?>!</p>

<h2><?php echo Text::_('COM_MYWALKS_WALK_REPORTS'); ?></h2>

<div class="table-responsive">
  <table class="table table-striped">
  <caption><?php echo Text::_('COM_MYWALKS_WALK_REPORTS'); ?></caption>
  <thead>
    <tr>
        <th scope="col"><?php echo Text::_('COM_MYWALKS_WALK_DATE'); ?></th>
        <th scope="col"><?php echo Text::_('COM_MYWALKS_WALK_WEATHER'); ?></th>
    </tr>
    </thead>
    <tbody>
    <?php foreach ($this->reports as $id => $report) : ?>
    <tr>
        <td><?php echo $report->date; ?></td>
        <td><?php echo $report->weather; ?></td>
    </tr>
    <?php endforeach; ?><?php //endif; ?>
    </tbody>
  </table>
</div>

For newcomers to Joomla: each php file starts with a DocBlock used in automated documentation; in namespaced files the next statement is the namspace, not used in tmpl files; the first executable statement is always defined('_JEXEC') or die; which ensures the file has been loaded by Joomla and not called directly through a web url.

The remaining lines output the walk title, description and list of visits extracted from the database. The use Joomla\CMS\Language\Text statement loads the class that converts string keys to string values. The //use Joomla\CMS\HTML\HTMLHelper; statement is commented out because this file does not use any one of a number of HTML embelishments. Look at the file to see what: site_root/libraries/src/HTML/HTMLHelper.php.

The walks list view - tmpl/mywalks/default.php

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

defined('_JEXEC') or die;

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
//use Joomla\CMS\Layout\LayoutHelper;

HTMLHelper::_('behavior.core');

?>
<h1><?php echo Text::_('COM_MYWALKS_LIST_PAGE_HEADING'); ?></h1>
<div class="com-contact-categories categories-list">
    <?php
        echo $this->loadTemplate('items');
    ?>
</div>

Note that the use statements are loading additional php files using their namespaces. Joomla\CMS\HTML\HTMLHelper adds files used in page display, for example the Javascript files needed for table sorting. Joomla\CMS\Language\Text adds the file used to convert fixed string keys to their English values. Joomla\CMS\Layout\LayoutHelper was copied here when copy and paste from elsewhere was being used during development. It is left in but commented out to illustrate that there my be lots of instances of accidental code that does nothing but use server resources.

This file outputs a page heading and then loads another file, default-items.php, which displays the list of walks. $this->loadTemplate('items') uses library code to locate the default_items.php file in the same directory in which it was called.

The list items - tmpl/mywalks/default_items.php

Notice the creation of a slug by converting the title to lower case alpha-numeric only with spaces replaced by minus signs.

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

defined('_JEXEC') or die;

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Component\Mywalks\Site\Helper\RouteHelper as MywalksHelperRoute;

?>
<div class="table-responsive">
  <table class="table table-striped">
  <caption><?php echo Text::_('COM_MYWALKS_LIST_TABLE_CAPTION'); ?></caption>
  <thead>
    <tr>
        <th scope="col"><?php echo Text::_('COM_MYWALKS_LIST_TITLE'); ?></th>
        <th scope="col"><?php echo Text::_('COM_MYWALKS_LIST_DESCRIPTION'); ?></th>
        <th scope="col"><?php echo Text::_('COM_MYWALKS_LIST_DISTANCE'); ?></th>
        <th scope="col"><?php echo Text::_('COM_MYWALKS_LIST_LAST_VISIT'); ?></th>
        <th scope="col"><?php echo Text::_('COM_MYWALKS_LIST_NVISITS'); ?></th>
    </tr>
    </thead>
    <tbody>
    <?php foreach ($this->items as $id => $item) : 
        $slug = preg_replace('/[^a-z\d]/i', '-', $item->title);
        $slug = strtolower(str_replace(' ', '-', $slug));
    ?>
    <tr>
        <td><a href="/j4x/<?php echo Route::_(MywalksHelperRoute::getWalkRoute($item->id, $slug)); ?>">
        <?php echo $item->title; ?></a></td>
        <td><?php echo $item->description; ?></td>
        <td><?php echo $item->distance; ?></td>
        <td><?php echo $item->last_visit //$item->lastvisit; ?></td>
        <td><?php echo $item->nvisits; ?></td>
    </tr>
    <?php endforeach; ?><?php //endif; ?>
    </tbody>
  </table>
</div>

Also notice the static call to Route which is used to create a url for a link to an individual walk description. And notice its associated use call that tells the loader where to find the required class and function. More on routing later.

This is an extract from the getWalkRoute function:

 public static function getWalkRoute($id, $slug, $language = 0, $layout = null)
    {
        // Create the link
        $link = 'index.php?option=com_mywalks&view=mywalk&id=' . $id . '&slug=' . $slug;

        if ($language && $language !== '*' && Multilanguage::isEnabled())
        {
            $link .= '&lang=' . $language;
        }

        if ($layout)
        {
            $link .= '&layout=' . $layout;
        }

        return $link;
    }

Getting the data - The HtmlView files

The tmpl files are supposed to deal solely with the composition of html. Any data needed to create the html, such as the list of walks, should be stored in variables in the HtmlView files where they are made available in the $this object.

The HtmlView.php file for the single walk view

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

namespace Joomla\Component\Mywalks\Site\View\Mywalk;

defined('_JEXEC') or die;

//use Joomla\CMS\HTML\HTMLHelper;
//use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\GenericDataException;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;

/**
 * HTML Mywalk View class for the Mywalks component
 *
 * @since  1.5
 */
class HtmlView extends BaseHtmlView
{
    /**
     * The item model state
     *
     * @var    \Joomla\Registry\Registry
     * @since  1.6
     */
    protected $state;

    /**
     * The item object details
     *
     * @var    \JObject
     * @since  1.6
     */
    protected $item;
    protected $reports;

    /**
     * Execute and display a template script.
     *
     * @param   string  $tpl  The name of the template file to parse; automatically searches through the template paths.
     *
     * @return  mixed  A string if successful, otherwise an Error object.
     */
    public function display($tpl = null)
    {
        $state      = $this->get('State');
        $item       = $this->get('Item');
        $reports    = $this->get('Reports');

        $this->state       = &$state;
        $this->item        = &$item;
        $this->reports     = &$reports;

        // Check for errors.
        if (count($errors = $this->get('Errors')))
        {
            throw new GenericDataException(implode("\n", $errors), 500);
        }

        return parent::display($tpl);
    }
}

The display function is very simple. It fetches state, single walk and walk report data for that walk from the model. If any of the data retrieval steps return an error it throws an exception, usually resulting in some sort of error message page. Otherwise control is passed via Joomla to the tmpl file to create the html output. HtmlView files can become quite complex - have a look at the content component article HtmlView for for an example.

The HtmlView file for the list of walks

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

namespace Joomla\Component\Mywalks\Site\View\Mywalks;

defined('_JEXEC') or die;

use Joomla\CMS\Factory;
//use Joomla\CMS\HTML\HTMLHelper;
//use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\GenericDataException;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
//use Joomla\CMS\Router\Route;

/**
 * Walks List View class
 *
 * @since  1.6
 */
class HtmlView extends BaseHtmlView
{
    /**
     * The item model state
     *
     * @var    \Joomla\Registry\Registry
     * @since  1.6.0
     */
    protected $state;

    /**
     * The item details
     *
     * @var    \JObject
     * @since  1.6.0
     */
    protected $items;

    /**
     * The pagination object
     *
     * @var    \JPagination
     * @since  1.6.0
     */
    protected $pagination;

    /**
     * The page parameters
     *
     * @var    \Joomla\Registry\Registry|null
     * @since  4.0.0
     */
    protected $params = null;

    /**
     * Method to display the view.
     *
     * @param   string  $tpl  The name of the template file to parse; automatically searches through the template paths.
     *
     * @return  mixed  \Exception on failure, void on success.
     *
     * @since   1.6
     */
    public function display($tpl = null)
    {
        $app    = Factory::getApplication();
        $params = $app->getParams();

        // Get some data from the models
        $state      = $this->get('State');
        $items      = $this->get('Items');
        $pagination = $this->get('Pagination');

        // Flag indicates to not add limitstart=0 to URL
        $pagination->hideEmptyLimitstart = true;

        // Check for errors.
        if (count($errors = $this->get('Errors')))
        {
            throw new GenericDataException(implode("\n", $errors), 500);
        }

        $this->state      = &$state;
        $this->items      = &$items;
        $this->params     = &$params;
        $this->pagination = &$pagination;

        return parent::display($tpl);
    }
}

Ready for the models?

Getting the data - The Model files

For the single walk model we need a model file that implements populateState, getItem and getVisits. For the list of walks we need populateState, getListQuery, getItems and a few more to sort on columns and paginate long lists, neither of which are implemented in this tutorial.

Model file: MywalkModel

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

namespace Joomla\Component\Mywalks\Site\Model;

defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\ItemModel;

/**
 * Mywalk Component Mywalk Model
 *
 * @since  1.5
 */
class MywalkModel extends ItemModel
{
    /**
     * Model context string.
     *
     * @var        string
     */
    protected $_context = 'com_mywalks.mywalk';

    /**
     * Method to auto-populate the model state.
     *
     * Note. Calling getState in this method will result in recursion.
     *
     * @since   1.6
     *
     * @return void
     */
    protected function populateState()
    {
        $app = Factory::getApplication();

        // Load state from the request.
        $pk = $app->input->getInt('id');
        $this->setState('mywalk.id', $pk);

        $offset = $app->input->getUInt('limitstart');
        $this->setState('list.offset', $offset);

        // Load the parameters.
        $params = $app->getParams();
        $this->setState('params', $params);
    }

    /**
     * Method to get walk data.
     *
     * @param   integer  $pk  The id of the walk.
     *
     * @return  object|boolean  Menu item data object on success, boolean false
     */
    public function getItem($pk = null)
    {
        $pk = (!empty($pk)) ? $pk : (int) $this->getState('mywalk.id');

            try
            {
                $db = $this->getDbo();
                $query = $db->getQuery(true)
                    ->select(
                        $this->getState(
                            'item.select', 'a.*'
                        )
                    );
                $query->from('#__mywalks AS a')
                    ->where('a.id = ' . (int) $pk);

                $db->setQuery($query);

                $data = $db->loadObject();

                if (empty($data))
                {
                    throw new \Exception(Text::_('COM_MYWALKS_ERROR_WALK_NOT_FOUND'), 404);
                }
            }
            catch (\Exception $e)
            {
                if ($e->getCode() == 404)
                {
                    // Need to go through the error handler to allow Redirect to work.
                    throw new \Exception($e->getMessage(), 404);
                }
                else
                {
                    $this->setError($e);
                    $this->_item[$pk] = false;
                }
            }

        return $data;
    }
    /**
     * Method to get walk visit data.
     *
     * @param   integer  $pk  The id of the walk.
     *
     * @return  object|boolean  Menu item data object on success, boolean false
     */
    public function getReports($pk = null)
    {
        $pk = (!empty($pk)) ? $pk : (int) $this->getState('mywalk.id');

        try
        {
            $db = $this->getDbo();
            $query = $db->getQuery(true)
            ->select('b.*');
            $query->from('#__mywalk_dates AS b')
            ->where('b.walk_id = ' . (int) $pk);
            $query->order('`date` DESC');

            $db->setQuery($query);

            $data = $db->loadObjectList();

            // It is OK to have a walk without visit data - handle it the view.
        }
        catch (\Exception $e)
        {
            if ($e->getCode() == 404)
            {
                // Need to go through the error handler to allow Redirect to work.
                throw new \Exception($e->getMessage(), 404);
            }
            else
            {
                $this->setError($e);
                $this->_item[$pk] = false;
            }
        }

        return $data;
    }
}

Model file: MywalksModel

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

namespace Joomla\Component\Mywalks\Site\Model;

defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\ListModel;

/**
 * This models supports retrieving lists of articles.
 *
 * @since  1.6
 */
class MywalksModel extends ListModel
{
    /**
     * Constructor.
     *
     * @param   array  $config  An optional associative array of configuration settings.
     *
     * @see     \JController
     * @since   1.6
     */
    public function __construct($config = array())
    {
        if (empty($config['filter_fields']))
        {
            $config['filter_fields'] = array(
                'id', 'a.id',
                'title', 'a.title',
            );
        }

        parent::__construct($config);
    }

    /**
     * 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.
     *
     * @param   string  $ordering   An optional ordering field.
     * @param   string  $direction  An optional direction (asc|desc).
     *
     * @return  void
     *
     * @since   3.0.1
     */
    protected function populateState($ordering = 'ordering', $direction = 'ASC')
    {
        $app = Factory::getApplication();

        // List state information
        $value = $app->input->get('limit', $app->get('list_limit', 0), 'uint');
        $this->setState('list.limit', $value);

        $value = $app->input->get('limitstart', 0, 'uint');
        $this->setState('list.start', $value);

        $orderCol = $app->input->get('filter_order', 'a.id');

        if (!in_array($orderCol, $this->filter_fields))
        {
            $orderCol = 'a.id';
        }

        $this->setState('list.ordering', $orderCol);

        $listOrder = $app->input->get('filter_order_Dir', 'ASC');

        if (!in_array(strtoupper($listOrder), array('ASC', 'DESC', '')))
        {
            $listOrder = 'ASC';
        }

        $this->setState('list.direction', $listOrder);

        $params = $app->getParams();
        $this->setState('params', $params);

        //$this->setState('layout', $app->input->getString('layout'));
    }

    /**
     * Method to get a store id based on model configuration state.
     *
     * This is necessary because the model is used by the component and
     * different modules that might need different sets of data or different
     * ordering requirements.
     *
     * @param   string  $id  A prefix for the store id.
     *
     * @return  string  A store id.
     *
     * @since   1.6
     */
    protected function getStoreId($id = '')
    {
        // Compile the store id.

        return parent::getStoreId($id);
    }

    /**
     * Get the master query for retrieving a list of walks subject to the model state.
     *
     * @return  \JDatabaseQuery
     *
     * @since   1.6
     */
    protected function getListQuery()
    {
        // Get the current user for authorisation checks
        $user = Factory::getUser();

        // Create a new query object.
        $db    = $this->getDbo();
        $query = $db->getQuery(true);

        // Select the required fields from the table.
        $query->select(
            $this->getState(
                'list.select',
                'a.*,
                (SELECT MAX(`date`) from #__mywalk_dates WHERE walk_id = a.id) AS last_visit,
                (SELECT count(`date`) from #__mywalk_dates WHERE walk_id = a.id) AS nvisits
                ')
        );
        $query->from('#__mywalks AS a');

        $params      = $this->getState('params');

        // Add the list ordering clause.
        $query->order($this->getState('list.ordering', 'a.id') . ' ' . $this->getState('list.direction', 'ASC'));

        return $query;
    }

    /**
     * Method to get a list of walks.
     *
     * Overridden to inject convert the attribs field into a \JParameter object.
     *
     * @return  mixed  An array of objects on success, false on failure.
     *
     * @since   1.6
     */
    public function getItems()
    {
        $items  = parent::getItems();
        return $items;
    }

    /**
     * Method to get the starting number of items for the data set.
     *
     * @return  integer  The starting number of items available in the data set.
     *
     * @since   3.0.1
     */
    public function getStart()
    {
        return $this->getState('list.start');
    }
}

Control Flow

Starting the component - the Controller

It is worth remembering that the non-SEF url for the list of walks page is index.php?option=com_mywalks&task=display&view=mywalks. The task part is often left out, in which case the default task is set to display. If the view part is left out then the component must set the default view.

Each page request starts with an initialisation sequence. That done, the entry points to the component are via their controller files. The default component view is display so it should be no surprise that the default controller is DisplayController. This controller does not do much other than call its parent controller. However, controllers can be used to do initial processing. For example, if a form is being submitted it is common practice to check the form token and die if it is invalid.

DisplayController

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

namespace Joomla\Component\Mywalks\Site\Controller;

defined('_JEXEC') or die;

use Joomla\CMS\MVC\Controller\BaseController;

/**
 * Mywalks Component Controller
 *
 * @since  1.5
 */
class DisplayController extends BaseController
{
    /**
     * Method to display a view.
     *
     * @param   boolean  $cachable   If true, the view output will be cached
     * @param   array    $urlparams  An array of safe URL parameters and their variable types, for valid values see {@link \JFilterInput::clean()}.
     *
     * @return  static  This object to support chaining.
     *
     * @since   1.5
     */
    public function display($cachable = false, $urlparams = array())
    {
        return parent::display();
    }
}

Essential Administrator Files

Although we are still developing the code for the Site display, some Administrator code is needed. The services/provider.php file is used to boot the component, either to display its own site views or for use by the menu module to create menu items.

The services provider file: administrator/components/com_mywalks/services/provider.php

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

defined('_JEXEC') or die;

//use Joomla\CMS\Categories\CategoryFactoryInterface;
use Joomla\CMS\Component\Router\RouterFactoryInterface;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
//use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\Extension\Service\Provider\CategoryFactory;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\Extension\Service\Provider\RouterFactory;
use Joomla\CMS\HTML\Registry;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\Component\Mywalks\Administrator\Extension\MywalksComponent;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

/**
 * The mywalks service provider.
 *
 * @since  4.0.0
 */
return new class implements ServiceProviderInterface
{
    /**
     * Registers the service provider with a DI container.
     *
     * @param   Container  $container  The DI container.
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function register(Container $container)
    {
        $container->registerServiceProvider(new CategoryFactory('\\Joomla\\Component\\Mywalks'));
        $container->registerServiceProvider(new MVCFactory('\\Joomla\\Component\\Mywalks'));
        $container->registerServiceProvider(new ComponentDispatcherFactory('\\Joomla\\Component\\Mywalks'));
        $container->registerServiceProvider(new RouterFactory('\\Joomla\\Component\\Mywalks'));
        $container->set(
                ComponentInterface::class,
                function (Container $container)
                {
                    $component = new MywalksComponent($container->get(ComponentDispatcherFactoryInterface::class));

                    $component->setRegistry($container->get(Registry::class));
                    $component->setMVCFactory($container->get(MVCFactoryInterface::class));
//                  $component->setCategoryFactory($container->get(CategoryFactoryInterface::class));
                    $component->setRouterFactory($container->get(RouterFactoryInterface::class));

                    return $component;
        }
        );
    }
};

The component boot file: administrator/components/com_mywalks/Extension/MywalksComponent.php

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

namespace Joomla\Component\Mywalks\Administrator\Extension;

defined('JPATH_PLATFORM') or die;

use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Component\Router\RouterServiceInterface;
use Joomla\CMS\Component\Router\RouterServiceTrait;
use Joomla\CMS\Extension\BootableExtensionInterface;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLRegistryAwareTrait;
use Psr\Container\ContainerInterface;

/**
 * Component class for com_mywalks
 *
 * @since  4.0.0
 */
class MywalksComponent extends MVCComponent implements
BootableExtensionInterface, RouterServiceInterface
{
    use RouterServiceTrait;
    use HTMLRegistryAwareTrait;

    /**
     * Booting the extension. This is the function to set up the environment of the extension like
     * registering new class loaders, etc.
     *
     * If required, some initial set up can be done from services of the container, eg.
     * registering HTML services.
     *
     * @param   ContainerInterface  $container  The container
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function boot(ContainerInterface $container)
    {
        //$this->getRegistry()->register('mywalksadministrator', new AdministratorService);
    }
}

For the moment the call to register the Administrator Service is commented out. That results in a run-time error when invoking the Mywalks component from the Administrator interface. Something to come back to in Part 2.

The Component Router

At this stage the com_mywalks component works. One menu item is needed to link to the list of walks. There is snag: in the list of walks the links to individual walks loke like this:

/site-root/my-walks.html?view=mywalk&id=1

(where the site-root my or may not be a subfolder tree). Time for a custom SEF router? And take a break to read Supporting SEF URLs in your component . I have another Joomla package that uses SEF urls of the form [domain]/XXX/YY/page-title.html where XXX is an organisation branch code and YY is a language code. Some branches use multiple languages. Non-standard! Yes, but that is what the organisation asked for.

For the mywalks component I want to use individual walk urls like this:

/site-root/mywalks/walk-n/walk-title.html

Where n is the individual walk id and the walk-title is automatically generated from the actual title. Neither walk-title nor .html are actually needed. The former is for friendliness and the latter because I am old fashioned.

There are no menu items for individual walks. They are not wanted and there is no way to generate them. A custom router is required, consisting of two files: Router.php and MywalksNomenuRules.php.

The Router File: component/com_mywalks/Services/Router.php

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

namespace Joomla\Component\Mywalks\Site\Service;

defined('_JEXEC') or die;

use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Categories\CategoryFactoryInterface;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Component\Router\RouterViewConfiguration;
use Joomla\CMS\Component\Router\Rules\MenuRules;
//use Joomla\CMS\Component\Router\Rules\NomenuRules;
use Joomla\Component\Mywalks\Site\Service\MywalksNomenuRules as NomenuRules;
use Joomla\CMS\Component\Router\Rules\StandardRules;
use Joomla\CMS\Menu\AbstractMenu;
use Joomla\Database\DatabaseInterface;

/**
 * Routing class of com_mywalks
 *
 * @since  3.3
 */
class Router extends RouterView
{
    protected $noIDs = false;

    /**
     * The category factory
     *
     * @var CategoryFactoryInterface
     *
     * @since  4.0.0
     */
    private $categoryFactory;

    /**
     * The db
     *
     * @var DatabaseInterface
     *
     * @since  4.0.0
     */
    private $db;

    /**
     * Mywalks Component router constructor
     *
     * @param   SiteApplication           $app              The application object
     * @param   AbstractMenu              $menu             The menu object to work with
     * @param   CategoryFactoryInterface  $categoryFactory  The category object
     * @param   DatabaseInterface         $db               The database object
     */
    public function __construct(SiteApplication $app, AbstractMenu $menu,
            CategoryFactoryInterface $categoryFactory, DatabaseInterface $db)
    {
        $this->categoryFactory = $categoryFactory;
        $this->db              = $db;

        $params = ComponentHelper::getParams('com_mywalks');
        $this->noIDs = (bool) $params->get('sef_ids');

        $mywalks = new RouterViewConfiguration('mywalks');
        $mywalks->setKey('id');
        $this->registerView($mywalks);

        $mywalk = new RouterViewConfiguration('mywalk');
        $mywalk->setKey('id');
        $this->registerView($mywalk);

        parent::__construct($app, $menu);

        $this->attachRule(new MenuRules($this));
        $this->attachRule(new StandardRules($this));
        $this->attachRule(new NomenuRules($this));
    }
}

Notice the lines that define and use custom rules:

use Joomla\Component\Mywalks\Site\Service\MywalksNomenuRules as NomenuRules;
...
        $this->attachRule(new NomenuRules($this));

The rules include a build function to create the links to individual walks, and a parse function to translate an incoming SEF url to an internal Joomla route. There is no need to worry about the link in the menu item as it is take care of by the MenuRules rules.

The Router Rules file: components/my_walks/Service/MywalksNomenuRules.php

<?php
/**
 * Joomla! Content Management System
 *
 * @copyright  Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\Component\Mywalks\Site\Service;

defined('JPATH_PLATFORM') or die;

use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Component\Router\Rules\RulesInterface;

/**
 * Rule to process URLs without a menu item
 *
 * @since  3.4
 */
class MywalksNomenuRules implements RulesInterface
{
    /**
     * Router this rule belongs to
     *
     * @var RouterView
     * @since 3.4
     */
    protected $router;

    /**
     * Class constructor.
     *
     * @param   RouterView  $router  Router this rule belongs to
     *
     * @since   3.4
     */
    public function __construct(RouterView $router)
    {
        $this->router = $router;
    }

    /**
     * Dummymethod to fullfill the interface requirements
     *
     * @param   array  &$query  The query array to process
     *
     * @return  void
     *
     * @since   3.4
     * @codeCoverageIgnore
     */
    public function preprocess(&$query)
    {
        $test = 'Test';
    }

    /**
     * Parse a menu-less URL
     *
     * @param   array  &$segments  The URL segments to parse
     * @param   array  &$vars      The vars that result from the segments
     *
     * @return  void
     *
     * @since   3.4
     */
    public function parse(&$segments, &$vars)
    {
        //with this url: http://localhost/j4x/my-walks/mywalk-n/walk-title.html
        // segments: [[0] => mywalk-n, [1] => walk-title]
        // vars: [[option] => com_mywalks, [view] => mywalks, [id] => 0]

        $vars['view'] = 'mywalk';
        $vars['id'] = substr($segments[0], strpos($segments[0], '-') + 1);
        array_shift($segments);
        array_shift($segments);
        return;
    }

    /**
     * Build a menu-less URL
     *
     * @param   array  &$query     The vars that should be converted
     * @param   array  &$segments  The URL segments to create
     *
     * @return  void
     *
     * @since   3.4
     */
    public function build(&$query, &$segments)
    {
        // content of $query ($segments is empty or [[0] => mywalk-3])
        // when called by the menu: [[option] => com_mywalks, [Itemid] => 126]
        // when called by the component: [[option] => com_mywalks, [view] => mywalk, [id] => 1, [Itemid] => 126]
        // when called from a module: [[option] => com_mywalks, [view] => mywalks, [format] => html, [Itemid] => 126]
        // when called from breadcrumbs: [[option] => com_mywalks, [view] => mywalks, [Itemid] => 126]

        // the url should look like this: /site-root/mywalks/walk-n/walk-title.html

        // if the view is not mywalk - the single walk view
        if (!isset($query['view']) || (isset($query['view']) && $query['view'] !== 'mywalk') || isset($query['format']))
        {
            return;
        }
        $segments[] = $query['view'] . '-' . $query['id'];
        $segments[] = $query['slug'];
        unset($query['view']);
        unset($query['id']);
        unset($query['slug']);
    }
}

When there is a menu item for a mywalks list page the MywalksNomenuRules build function will be called for every internal link in a page: in modules, menus and even content articles. So watch out for run-time error messages.

And Finally

That is it - a working component, but only working on the site side!

Todo:

  • Add code to sort the walks list on a selected column.
  • Add code for for walks list pagination.
  • Add some sort of back link in the single walk page, or modify the breadcrumb module.