Tutorial: Providing Custom Views in VirtueMart using vmextended Plugins
In this tutorial, I will show how a plugin of type vmextended can be used to add your own custom view to the VirtueMart backend. As an example, we will implement a view that generates a simple tax report (for each tax rate we will show the amount of taxes charged in the selected period). In the first step, the view will not offer any configuration settings. The example contains no copyright and license statements to make it easier to read. You should add them yourself if you build upon this example. The example plugin developed here is released under the GPL v3+.
The full code for the example in this tutorial can be downloaded HERE (plg_vmextended_taxreport_v0.1.zip, 22kB).
The view will have the internal name "taxreport" and will be implemented as a Joomla plugin of type vmextended and its files are located in plugins/vmextended/taxreport. The view will be displayed in the backend when the URL administrator/index.php?option=com_virtuemart&view=taxreport is called.
In typical Joomla fashion, we will set up a full MVC (Model-View-Controller) structure within the plugin directory (see the file structure on the right). The corresponding code and file structure is not much different from a normal Joomla component, except that every now and then we need to tell the classes about the appropriate model/view/template pathes, since with our approach those are not in the usual places, but inside the plugin's directory.
The general structure and flow of the plugin is as follows:
- The vmextended plugin called "taxreport" (file plugins/vmextended/taxreport/taxreport.php) provides a function onVmAdminController that is called when a view is requested that is not provided by the VM core. That function loads the corresponding controller class VirtuemartControllerTaxReport.
- The VirtuemartControllerTaxReport controller class (file plugins/vmextended/taxreport/controllers/taxreport.php) handles all requests for the tax report view, i.e. it is the URL handler that simply relays the request to the corresponding view. The display function for the default view is already available, so we don't have to implement it ourselves. All we have to do in a first step is to set the appropriate view path to tell VM to look in the plugin directory for the taxreport view.
- The view itself is provided in a class VirtuemartViewTaxReport (file plugins/vmextended/taxreport/views/taxreport/view.html.php) and needs to set the corresponding template path (plugins/vmextended/taxreport/views/taxreport/tmpl/) and provide a display($tmpl) function that actually renders the view.
- The data used by the view is provided by a model class VirtuemartModelTaxReport (file plugins/vmextended/taxreport/models/taxreport.php). This is the place where the actual database queries are set up, executed and the result returned to the view. This way the view does not have to care about where the data comes from, and the model is only responsible for retrieving (and/or storing) the data.
- The view will be added as a menu item to the VirtueMart backend so it is easily accessible. Unfortunately, VirtueMart does not yet support dynamically adding menu items to its backend, so we have to create a hardcoded database entry. Its title will not be translated (because translateions are only loaded when the view is actually called) and it wil also be shown when the plugin is disabled.
1. The plugin itself (plugins/taxreport/taxreport.php): setting up pathes and loading the controller
The plugin is a normal VirtueMart vmextended plugin, derived from the vmExtendedPlugin class. The constructor needs to load the translations (not done automatically for vmextended plugins), and the plugin needs to implement the onVmAdminController function, which loads the corresponding Controller class. Instantiating the controller class will be done by the VM core code, the plugin just needs to make sure the class is actually defined in memory:
<?php class plgVmExtendedTaxReport extends vmExtendedPlugin { parent::__construct($subject, $config); $this->_path = JPATH_PLUGINS.DS.'vmextended'.DS.$this->getName(); JPlugin::loadLanguage('plg_vmextended_'.$this->getName()); } public function onVmAdminController ($controller) { if ($controller = $this->getName()) { VmModel::addIncludePath($this->_path . DS . 'models'); // TODO: Make sure the model exists. We probably should find a better way to load this automatically! // Currently, some path config seems missing, so the model is not found by default. require_once($this->_path.DS.'models'.DS.'taxreport.php'); require_once($this->_path.DS.'controllers'.DS.'taxreport.php'); return true; } } }
2. The Controller (plugins/taxreport/controllers/taxreport.php)
The controller is the first thing that is called whenever VM detects that the taxreport view is requested. It's purpose is to handle the different tasks (typical tasks are save, cancel, export). The default task is already properly set up in the base class to display the taxreport view, so all we have to do is to add the proper view path, since we have the view not in the default VM location, but inside the plugin directory:
<?php class VirtuemartControllerEuRecap extends VmController { function __construct(){ parent::__construct(); // Add the proper view pathes... $this->addViewPath(JPATH_PLUGINS.DS . 'vmextended' . DS . 'taxreport' . DS . 'views'); } }
3. The tax data model (plugins/taxreport/models/taxreport.php)
Before we create the actual view, we need to design which data we want to display and how it is retrieved from the database. For the tax report, we want to display the following columns in our report:
Billing Country | Tax Rule | Tax Rate | Orders | Net revenue | Taxes |
---|
The corresponding information is split in the database over multiple tables, which we need to join:
- [prefix]_virtuemart_orders: Base table for all orders
- [prefix]_virtuemart_order_userinfos: Columns virtuemart_country_id; Joined on virtuemart_order_id column.
- [prefix]_virtuemart_countries: Column country_name; Joined with userinfos table on column virtuemart_country_id.
- [prefix]_virtuemart_order_items: product_priceWithoutTax, product_quantity; Joined with orders on virtuemart_order_id
- [prefix]_virtuemart_order_calc_rules: Columns calc_rule_name, calc_kind, calc_amount, calc_value, calc_currency; Joined with orderitems on virtuemart_order_item_id
From this database structure, we can first write the SQL statement to retrieve the data in the desired format:
SELECT `c`.`country_name` AS `country`, `cr`.`calc_rule_name` AS `taxrule`, `cr`.`calc_value` AS `taxrate`, COUNT(DISTINCT `o`.`virtuemart_order_id`) AS `ordercount`, SUM(`oi`.`product_quantity` * `oi`.`product_priceWithoutTax`) AS `sum_revenue_net`, SUM(`cr`.`calc_amount`) AS `sum_order_tax` FROM j25_virtuemart_orders AS o LEFT JOIN j25_virtuemart_order_userinfos AS ui ON `o`.`virtuemart_order_id` = `ui`.`virtuemart_order_id` LEFT JOIN j25_virtuemart_countries AS c ON `ui`.`virtuemart_country_id` = `c`.`virtuemart_country_id` INNER JOIN j25_virtuemart_order_items AS `oi` ON `o`.`virtuemart_order_id` = `oi`.`virtuemart_order_id` INNER JOIN j25_virtuemart_order_calc_rules AS cr ON `oi`.`virtuemart_order_item_id` = `cr`.`virtuemart_order_item_id` WHERE (`o`.`order_status` = "C" OR `o`.`order_status` = "S" ) AND `ui`.`address_type` = "BT" AND DATE( o.created_on ) BETWEEN "2015-03-22 00:00:00" AND "2015-03-23 00:00:00" GROUP BY `c`.`virtuemart_country_id`, `cr`.`virtuemart_calc_id`, `cr`.`calc_currency`;
Finally, we can create the model class (plugins/taxreport/models/taxreport.php), which will extract the data from the database using the SQL derived above and return it to the view for proper display:
<?php class VirtuemartModelTaxReport extends VmModel { public $from_period = ''; public $until_period = ''; function __construct () { parent::__construct (); $this->setMainTable ('orders'); $this->removevalidOrderingFieldName ('virtuemart_order_id'); $this->addvalidOrderingFieldName (array('`country`', '`taxrule`', '`taxrate`', '`ordercount`', '`sum_revenue_net`', '`sum_order_tax`')); $this->_selectedOrdering = '`country`'; } function correctTimeOffset(&$inputDate) { $config = JFactory::getConfig(); $this->siteOffset = $config->get('offset'); $date = new JDate($inputDate); $date->setTimezone($this->siteTimezone); $inputDate = $date->format('Y-m-d H:i:s',true); } function setPeriod () { $this->from_period = vRequest::getVar ('from_period'); $this->until_period = vRequest::getVar ('until_period'); $config = JFactory::getConfig(); $siteOffset = $config->get('offset'); $this->siteTimezone = new DateTimeZone($siteOffset); $this->correctTimeOffset($this->from_period); $this->correctTimeOffset($this->until_period); } function getTaxes() { $user = JFactory::getUser(); if($user->authorise('core.admin', 'com_virtuemart') or $user->authorise('core.manager', 'com_virtuemart')){ $vendorId = vRequest::getInt('virtuemart_vendor_id'); } else { $vendorId = VmConfig::isSuperVendor(); } $this->setPeriod(); $mainTable = "`#__virtuemart_orders` AS `o`"; $joins[] = "LEFT JOIN #__virtuemart_order_userinfos AS `ui` ON `o`.`virtuemart_order_id` = `ui`.`virtuemart_order_id` "; $joins[] = "LEFT JOIN #__virtuemart_countries AS `c` ON `ui`.`virtuemart_country_id` = `c`.`virtuemart_country_id` "; $joins[] = "INNER JOIN #__virtuemart_order_items AS `oi` ON `o`.`virtuemart_order_id` = `oi`.`virtuemart_order_id` "; $joins[] = "INNER JOIN #__virtuemart_order_calc_rules AS `cr` ON `oi`.`virtuemart_order_item_id` = `cr`.`virtuemart_order_item_id` "; $select[] = "`c`.`country_name` AS `country`"; $select[] = "`cr`.`calc_rule_name` AS `taxrule`"; $select[] = "`cr`.`calc_value` AS `taxrate`"; $select[] = "COUNT(DISTINCT `o`.`virtuemart_order_id`) as `ordercount`"; $select[] = "SUM(`oi`.`product_quantity` * `oi`.`product_priceWithoutTax`) AS `sum_revenue_net`"; $select[] = "SUM(`cr`.`calc_amount`) AS `sum_order_tax`"; $where[] = '`ui`.`address_type` = "BT"'; // Otherwise, amounts will be double due to summation! // Order status: foreach ($orderstates as $s) { $ostatus[] = '`o`.`order_status` = "' . $s . '"'; } if ($ostatus) { } $where[] = ' DATE( o.created_on ) BETWEEN "' . $this->from_period . '" AND "' . $this->until_period . '" '; $groupbys = array("`c`.`virtuemart_country_id`", "`cr`.`virtuemart_calc_id`", "`cr`.`calc_currency`"); $orderBy = $this->_getOrdering (); return $this->exeSortSearchListQuery (1, $selectString, $joinedTables, $whereString, $groupBy, $orderBy); } }
4. The taxreport view (plugins/taxreport/views/taxreport/view.html.php)
Now that we have created the model to retrieve the data, we can finally display it in a view. Again, we have to adjust a few pathes, extract the data from the model and then simply call the template:
<?php // VM2 has class VmView instead of VmViewAdmin: class VmViewAdmin extends VmView {} } else { } class VirtuemartViewTaxReport extends VmViewAdmin { function __construct(){ parent::__construct(); $this->_addPath('template', JPATH_PLUGINS.DS . 'vmextended' . DS . 'taxreport' . DS . 'views' . DS . $this->getName() . DS . 'tmpl'); } function display($tpl = null){ if (!class_exists('CurrencyDisplay')) require(VMPATH_ADMIN . DS . 'helpers' . DS . 'currencydisplay.php'); vRequest::setvar('task',''); $this->SetViewTitle('TAXREPORT'); $model = VmModel::getModel(); $this->addStandardDefaultViewLists($model); $myCurrencyDisplay = CurrencyDisplay::getInstance(); $taxData = $model->getTaxes(); $this->assignRef('report', $taxData); $orderstatusM =VmModel::getModel('orderstatus'); $this->lists['state_list'] = $orderstatusM->renderOSList($orderstates,'order_status_code',TRUE); $this->lists['select_date'] = $model->renderDateSelectList(); $this->assignRef('from_period', $model->from_period); $this->assignRef('until_period', $model->until_period); $this->pagination = $model->getPagination(); parent::display($tpl); } }
5. The view template (plugins/vmextended/taxreport/views/taxreport/tmpl/default.php)
Finally, the view template creates the HTML code to display the view. It gets all the variables that we set in the view's display function.
<?php AdminUIHelper::startAdminArea($this); JHtml::_('behavior.framework', true); require(VMPATH_ADMIN . DS . 'helpers' . DS . 'currencydisplay.php'); $myCurrencyDisplay = CurrencyDisplay::getInstance(); ?> <form action="index.php" method="post" name="adminForm" id="adminForm"> <div id="header"> <div id="filterbox"> <table> <tr> <td align="left" width="100%"> <?php echo vmText::_('COM_VIRTUEMART_ORDERSTATUS') . $this->lists['state_list']; echo vmText::_('COM_VIRTUEMART_REPORT_FROM_PERIOD') . vmJsApi::jDate($this->from_period, 'from_period'); echo vmText::_('COM_VIRTUEMART_REPORT_UNTIL_PERIOD') . vmJsApi::jDate($this->until_period, 'until_period'); ?> <button class="btn btn-small" onclick="this.form.submit();"><?php echo vmText::_('COM_VIRTUEMART_GO'); ?> </button> </td> </tr> </table> </div> <div id="resultscounter"> <?php if ($this->pagination) echo $this->pagination->getResultsCounter();?> </div> </div> <div id="editcell"> <table class="adminlist table table-striped" cellspacing="0" cellpadding="0"> <thead> <tr> </tr> </thead> <tbody> <?php $i = 0; foreach ($this->report as $r) { ?> <tr class="row<?php echo $i;?>"> <td align="center"><?php echo $r['country']; ?></td> <td align="center"><?php echo $r['taxrule']; ?></td> <td align="center"><?php echo $r['ordercount']; ?></td> <td align="right"><?php echo $myCurrencyDisplay->priceDisplay($r['sum_revenue_net']); ?></td> <td align="right"><?php echo $myCurrencyDisplay->priceDisplay($r['sum_order_tax']); ?></td> </tr> <?php $i = 1-$i; } ?> </tbody> <tfoot> <tr> <td colspan="10"> <?php if ($this->pagination) echo $this->pagination->getListFooter(); ?> </td> </tr> </tfoot> </table> </div> <?php echo $this->addStandardHiddenToForm(); ?> </form> <?php AdminUIHelper::endAdminArea(); ?>
6. Creating an admin menu entry in the VirtueMart Backend
Now that we have created the full plugin with the view, model and controller, we of course also want to have a menu enty in the VirtueMart backend admin area to make the view easily available. VirtueMart currently does not provide a way to dynamically add menu entries to the backend menu, so we will have to create database entries for the menu item when the plugin is installed. As a downside, the menu item will also be visible if the plugin is disabled. VirtueMart also does not load vmextended plugins and their translations, unless an unknown view is requested. So the translatable menu item is not possible, because the translation will only be loaded when the view is actually called. To solve this, we will load the translation when the plugin is installed and write the string directly into the database.
<?php require(JPATH_ROOT.DS.'administrator'.DS.'components'.DS.'com_virtuemart'.DS.'helpers'.DS.'config.php'); class plgVmExtendedTaxReportInstallerScript { public function postflight ($type, $parent = null) { JPlugin::loadLanguage('plg_vmextended_taxreport'); $db = JFactory::getDBO(); $db->setQuery("SELECT `id` FROM `#__virtuemart_adminmenuentries` WHERE `view` = 'taxreport'"); $exists = $db->loadResult(); if (!$exists) { $q = "INSERT INTO `#__virtuemart_adminmenuentries` (`module_id`, `name`, `link`, `depends`, `icon_class`, `ordering`, `published`, `tooltip`, `view`, `task`) VALUES (2, '" . vmText::_('COM_VIRTUEMART_TAXREPORT') . "', '', '', 'vmicon vmicon-16-report', 25, 1, '', 'taxreport', '')"; $db->setQuery($q); $db->query(); } } public function install(JAdapterInstance $adapter) { $db = JFactory::getDBO(); $db->setQuery('update #__extensions set enabled = 1 where type = "plugin" and element = "taxreport" and folder = "vmextended"'); $db->query(); return True; } public function uninstall(JAdapterInstance $adapter) { $db = JFactory::getDBO(); $q = "DELETE FROM `#__virtuemart_adminmenuentries` WHERE `view` = 'taxreport' AND `task` = '' AND `module_id` = 2"; $db->setQuery($q); $db->query(); } }
7. The final view in the VirtueMart Backend
This is how the tax report view we developed above actually looks like in the VirtueMart backend. Notice the "Tax report" menu item in the "Orders & Shoppers" section on the left.
Of course, the view is not perfect and cannot be configured, but it should serve as a nice example how VirtueMart can be easily extended using normal plugins of type vmextended.
If you want to look further and see an example how configuration options can be added to such plugins, please take a look at our "EU Sales Reports" plugin.