Recently I created a custom module for a client, that required a change to the standard UI for the backend data. The standard UI creates a grid of objects (in my case freight weight and size classes) with a button to create a new one, and action column in the grid to let you edit or delete once that item is loaded.
Having a large number of simple and related values, we changed to a new custom UI, with an editable grid where the last row in the grid is to create a new entry, e.g.:
Custom editable grid
Our other requirement was that more than one of these tables could be shown in the same tab container (in our example one called weight and one called size).
Overview:
To achieve this result, we need to create the following file structure for our custom module:
/app/code/local/Namespace/Freight/
|– Block
| `– Adminhtml
| |– Axis.php
| `– Freight
| `– Axis
| |– Axis.php
| |– Form.php
| |– Tabs.php
| |– Xgrid.php
| `– Ygrid.php
|– controllers
| |– Adminhtml
| | `– FreightController.php
| `– AxisController.php
|– etc
| |– config.xml
| `– system.xml
|– Helper
| `– Data.php
|– Model
| |– Axis.php
| |– Mysql4
| | |– Axis
| | | `– Collection.php
| | `– Axis.php
| `– Shipping
| `– Freight.php
|– sql
| `– freight_setup
| `– mysql4-install-0.1.0.php
SQL
There is nothing special about the setup at all, in our example we assign a few fairly standard columns. (app/code/local//Freight):
<?php
$installer = $this; $installer->
startSetup();
$installer->run("
DROP TABLE IF EXISTS {$this->getTable('freight_axis')};
CREATE TABLE {$this->getTable('freight_axis')} (
`axis_id` int(11) NOT NULL auto_increment,
`name` varchar(100) collate latin1_general_cs default NULL,
`desc` varchar(500) collate latin1_general_cs default NULL,
`value` decimal(15,5) default NULL,
`priority` int(11) NOT NULL,
`type` varchar(100) collate latin1_general_cs default NULL,
`status` smallint(6) NOT NULL default '0',
PRIMARY KEY (`axis_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_general_cs;
");
$installer->endSetup();
Models
The models are also very standard.
Model/Axis.php
<?php
class Turboweb_Freight_Model_Axis extends Mage_Core_Model_Abstract
{
public function _construct()
{
parent::_construct();
$this->_init('freight/axis');
}
}
Model/Mysql4/Axis.php
<?php
class Turboweb_Freight_Model_Mysql4_Axis extends Mage_Core_Model_Mysql4_Abstract
{
public function _construct()
{
$this->_init('freight/axis', 'axis_id');
}
}
Model/Mysql4/Axis/Collection.php
<?php
class Turboweb_Freight_Model_Mysql4_Axis_Collection extends Mage_Core_Model_Mysql4_Collection_Abstract
{
public function _construct()
{
//parent::__construct();
$this->_init('freight/axis', 'freight/axis');
}
public function getSize()
{
$this->load();
$this->_totalRecords = count($this->getItems());
return intval($this->_totalRecords);
}
}
The getSize() method is required as we add an item in our block, but there is a bug where the total number of items does not change.
Helpers
Again, no difference here.
Helper/Data.php
<?php
class Turboweb_Freight_Helper_Data extends Mage_Core_Helper_Abstract
{
}
Blocks
Here is where things get interesting. The default UI uses the following layout hierarchy:
Widget_Grid_Container - Widget_Grid
Then when edit / create is clicked:
Widget_Form_Container – Widget_Form & Widget_Tabs – and finally the individual Widget_Form classes for each tab.
The layout we want is very different:
Widget_Form_Container – Widget_Tabs -> ajax call -> widget_grid (for each tab)
Lets look at them in turn:
Block/Adminhtml/Axis.php
<?php class Turboweb_Freight_Block_Adminhtml_Axis extends Mage_Adminhtml_Block_Widget_Form_Container\r\n { public function __construct() { parent::__construct(); $this->_objectId = 'id';
$this->_blockGroup = 'freight';
$this->_controller = 'adminhtml_freight';
$this->_mode = 'axis';
$this->_updateButton('save', 'label', Mage::helper('freight')->__('Save'));
$this->_updateButton('save', 'onclick', 'var a = freight_tabsJsTabs.activeTab.id;
if(a == \'freight_tabs_x_section\')editForm.submit(\''.$this->getUrl('*/*/savexaxis').'\');
else editForm.submit(\''.$this->getUrl('*/*/saveyaxis').'\');');
$this->_addButton('delete', array(
'label' => Mage::helper('adminhtml')->__('Delete Checked'),
'class' => 'delete',
'onclick' => 'if(confirm(\''. Mage::helper('adminhtml')->__('Are you sure you want to do this?')
.'\')){
var a = freight_tabsJsTabs.activeTab.id;
if(a == \'freight_tabs_x_section\')editForm.submit(\''.$this->getUrl('*/*/deletexaxis').'\');
else editForm.submit(\''.$this->getUrl('*/*/deleteyaxis').'\');}',
));
$this->removeButton('back');
}
public function getHeaderText()
{
return Mage::helper('freight')->__('Freight Classes Configuration');
}
}
Here I add code to switch which controller handles the save function, remove the back button, and add a delete button.
Block/Adminhtml/Freight/Axis/Xgrid.php
<?php class Turboweb_Freight_Block_Adminhtml_Freight_Axis_Xgrid extends Mage_Adminhtml_Block_Widget_Grid { public function __construct() { parent::__construct(); $this->setId('x');
// This is the primary key of the database
$this->setDefaultSort('table_id');
$this->setDefaultDir('ASC');
$this->setSaveParametersInSession(true);
$this->setSkipGenerateContent(true);
$this->setUseAjax(true);
$this->setPagerVisibility(false);
$this->setFilterVisibility(false);
}
protected function _prepareCollection()
{
$collection = Mage::getModel('freight/axis')->getCollection();
$collection->addFieldToFilter('type',array('eq'=>'x'));
$collection->setOrder('priority', 'ASC');
$collection->load();
$axis = Mage::getModel('freight/axis');
$axis->setId(0);
$collection->addItem($axis);
$this->setCollection($collection);
return parent::_prepareCollection();
}
protected function _prepareColumns()
{
$column = $this->addColumn('sizes_select', array(
'align' =>'left',
'width' => '20px',
'field_name' => 'sizes_select',
'values' => $this->getSelectedSizes(),
'index' => 'axis_id',
'type' => 'checkbox',
));
$this->addColumn('xname[]', array(
'header' => Mage::helper('freight')->__('Name'),
'align' =>'left',
'width' => '20px',
'index' => 'name',
'type' => 'input',
'name' => 'xname[]'
));
$this->addColumn('xdescription[]', array(
'header' => Mage::helper('freight')->__('Description'),
'align' =>'left',
'width' => '200px',
'index' => 'desc',
'type' => 'input',
'name' => 'xdescription[]'
));
$this->addColumn('xvalue[]', array(
'header' => Mage::helper('freight')->__('Minimum Value'),
'align' =>'left',
'width' => '20px',
'index' => 'value',
'type' => 'input',
'name' => 'xvalue[]'
));
$this->addColumn('xstatus[]', array(
'header' => Mage::helper('freight')->__('Status'),
'align' =>'left',
'width' => '50px',
'index' => 'status',
'type' => 'select',
'options' => array(
1 => Mage::helper('freight')->__('Enabled'),
2 => Mage::helper('freight')->__('Disabled'),
),
'editable' => true,
'name' => 'xstatus[]'
));
$this->addColumn('xpriority[]', array(
'header' => Mage::helper('freight')->__('Order'),
'align' =>'left',
'width' => '20px',
'index' => 'priority',
'type' => 'input',
'name' => 'xpriority[]'
));
}
public function getSelectedSizes()
{
$sizes = $this->getSizes();
if (!is_array($sizes)) {
$sizes = $this->getSelectedSizesGroup();
}
return $sizes;
}
public function getSelectedSizesGroup()
{
$sizes = array();
$collection = Mage::getModel('freight/axis')->getCollection();
$collection->addFieldToFilter('type',array('eq'=>'x'));
$collection->setOrder('priority', 'ASC');
$collection->load();
foreach($collection as $sizeObj)
{
$sizes[] = $sizeObj->getId();
}
//add create new
$sizes[] = 0;
return $sizes;
}
public function getRowUrl($row)
{
return '';
}
public function getRowClass(Varien_Object $row) {
return $row->getId() ? '' : 'new';
}
public function setSizesGroup()
{
return $this;
}
public function getGridUrl()
{
return $this->_getData('grid_url') ? $this->_getData('grid_url') : $this->getUrl('*/*/sizesGridOnly', array('_current'=>true));
}
}
Important to note here is the addItem() call in the _prepareCollection() method. This becomes our create new row.
To make this row distinguishable we override the getRowClass() to give a class of new if no ID is set!
The getSelectedSizes intialises our serialised grid, so when we submit we know what is checked and what is not.
The only other thing to note is the inclusion of the name (eg ‘name’ = ‘xname[]‘) variable in the add column fields. This creates a useable array of data.
Block/Adminhtml/Freight/Axis/Tabs
<?php class Turboweb_Freight_Block_Adminhtml_Freight_Axis_Tabs extends Mage_Adminhtml_Block_Widget_Tabs { public function __construct() { parent::__construct(); $this->setId('freight_tabs');
$this->setDestElementId('edit_form');
$this->setTitle(Mage::helper('freight')->__('Class'));
}
protected function _beforeToHtml()
{
$this->addTab('x_section', array(
'label' => Mage::helper('freight')->__('Size'),
'url' => $this->getUrl('*/*/sizeGrid', array('_current'=>true)),
'class' => 'ajax',
));
$this->addTab('y_section', array(
'label' => Mage::helper('freight')->__('Weight'),
'url' => $this->getUrl('*/*/weightGrid', array('_current'=>true)),
'class' => 'ajax',
));
return parent::_beforeToHtml();
}
}
This is where we set the tabs to call the ajax requests.
Layout
To plumb this all together the admin layout is configured to know about the 2 ajax grids, while the controller handles including the tabs.
<?xml version="1.0"?>
<layout version="0.1.0">
<freight_adminhtml_freight_axis>
<reference name="content">
<block type="freight/adminhtml_axis" name="xformcontainer"/>
<!-- <block type="freight/adminhtml_xaxis" name="xformcontainer"/> -->
<!-- <block type="freight/adminhtml_yaxis" name="yformcontainer"/> -->
</reference>
</freight_adminhtml_freight_axis>
<freight_adminhtml_freight_sizegrid>
<block type="core/text_list" name="root" output="toHtml">
<block type="freight/adminhtml_freight_axis_xgrid" name="freight/adminhtml_freight_axis_xgrid" />
<block type="adminhtml/widget_grid_serializer" name="freight_grid_x_serializer">
<reference name="freight_grid_x_serializer">
<action method="initSerializerBlock">
<grid_block_name>freight/adminhtml_freight_axis_xgrid</grid_block_name>
<data_callback>getSelectedSizes</data_callback>
<hidden_input_name>selectedSizes</hidden_input_name>
<reload_param_name>freight_sizes</reload_param_name>
</action>
</reference>
</block>
</block>
</freight_adminhtml_freight_sizegrid>
<freight_adminhtml_freight_sizegridonly>
<block type="core/text_list" name="root">
<block type="freight/adminhtml_freight_axis_xgrid" name="freight/adminhtml_freight_axis_xgrid" />
</block>
</freight_adminhtml_freight_sizegridonly>
<freight_adminhtml_freight_weightgrid>
<block type="core/text_list" name="root" output="toHtml">
<block type="freight/adminhtml_freight_axis_ygrid" name="freight/adminhtml_freight_axis_ygrid" />
<block type="adminhtml/widget_grid_serializer" name="freight_grid_y_serializer">
<reference name="freight_grid_y_serializer">
<action method="initSerializerBlock">
<grid_block_name>freight/adminhtml_freight_axis_ygrid</grid_block_name>
<data_callback>getSelectedWeights</data_callback>
<hidden_input_name>selectedWeights</hidden_input_name>
<reload_param_name>freight_weights</reload_param_name>
</action>
</reference>
</block>
</block>
</freight_adminhtml_freight_weightgrid>
<freight_adminhtml_freight_weightgridonly>
<block type="core/text_list" name="root">
<block type="freight/adminhtml_freight_axis_ygrid" name="freight/adminhtml_freight_axis_ygrid" />
</block>
</freight_adminhtml_freight_weightgridonly>
</layout>
The serialiser is set up for the weight and size grids here.
Controller
This is where the magic happens. Basically we need 2 separate save methods, 4 methods for the ajax calls (the whole table, and when filtering/sorting the grid a grid only variation).
<?php class Turboweb_Freight_Adminhtml_FreightController extends Mage_Adminhtml_Controller_action { protected function _initAction() { $this->loadLayout()
->_setActiveMenu('freight/freight')
->_addBreadcrumb(Mage::helper('adminhtml')->__('Freight'), Mage::helper('adminhtml')->__('Freight'));
return $this;
}
public function axisAction() {
$this->_initAction();
$this->_addLeft($this->getLayout()->createBlock('freight/adminhtml_freight_axis_tabs'));
$this->renderLayout();
}
public function savexaxisAction()
{
try {
$postData = $this->getRequest()->getPost();
//data is arranged in priority order
$collection = Mage::getModel('freight/axis')->getCollection();
$collection->addFieldToFilter('type',array('eq'=>'x'));
$collection->setOrder('priority', 'ASC');
$collection->load();
$i = 0;
foreach($collection as $id => $size)
{
$size->setName($postData['xname'][$i])
->setDesc($postData['xdescription'][$i])
->setValue($postData['xvalue'][$i])
->setPriority($postData['xpriority'][$i])
->setStatus($postData['xstatus'][$i])
->save();
$i ++;
}
//check if create new
if($postData['xname'][$i] != "" && $postData['xvalue'][$i] != "" && $postData['xpriority'][$i] != "")
{
$size = Mage::getModel('freight/axis');
$size->setName($postData['xname'][$i])
->setDesc($postData['xdescription'][$i])
->setValue($postData['xvalue'][$i])
->setPriority($postData['xpriority'][$i])
->setType('x')
->setStatus($postData['xstatus'][$i])
->save();
}
Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Axis was successfully saved'));
} catch (Exception $e) {
Mage::getSingleton('adminhtml/session')->addError($e->getMessage());
$this->_redirect('*/*/axis');
return;
}
$this->_redirect('*/*/axis');
}
public function saveyaxisAction()
{
try {
$postData = $this->getRequest()->getPost();
//data is arranged in priority order
$collection = Mage::getModel('freight/axis')->getCollection();
$collection->addFieldToFilter('type',array('eq'=>'y'));
$collection->setOrder('priority', 'ASC');
$collection->load();
$i = 0;
foreach($collection as $id => $weight)
{
$weight->setName($postData['yname'][$i])
->setDesc($postData['ydescription'][$i])
->setValue($postData['yvalue'][$i])
->setPriority($postData['ypriority'][$i])
->setStatus($postData['ystatus'][$i])
->save();
$i ++;
}
//check if create new
if($postData['yname'][$i] != "" && $postData['yvalue'][$i] != "" && $postData['ypriority'][$i] != "")
{
$weight = Mage::getModel('freight/axis');
$weight->setName($postData['yname'][$i])
->setDesc($postData['ydescription'][$i])
->setValue($postData['yvalue'][$i])
->setPriority($postData['ypriority'][$i])
->setType('y')
->setStatus($postData['ystatus'][$i])
->save();
}
Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Axis was successfully saved'));
} catch (Exception $e) {
Mage::getSingleton('adminhtml/session')->addError($e->getMessage());
Mage::getSingleton('adminhtml/session')->setRecruitmentData($this->getRequest()->getPost());
$this->_redirect('*/*/axis');
return;
}
$this->_redirect('*/*/axis');
}
public function deletexaxisAction()
{
try {
$postDataIds = explode('&',$this->getRequest()->getPost('selectedSizes'));
foreach($postDataIds as $id)
{
//only delete non zeros
if($id)
{
Mage::getModel('freight/axis')->setId($id)->delete();
}
}
Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Size classes were successfully deleted'));
$this->_redirect('*/*/axis');
} catch (Exception $e) {
Mage::getSingleton('adminhtml/session')->addError($e->getMessage());
$this->_redirect('*/*/axis');
}
}
public function deleteyaxisAction()
{
try {
$postDataIds = explode('&',$this->getRequest()->getPost('selectedWeights'));
foreach($postDataIds as $id)
{
//only delete non zeros
if($id)
{
Mage::getModel('freight/axis')->setId($id)->delete();
}
}
Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Weight Classes were successfully deleted'));
$this->_redirect('*/*/axis');
} catch (Exception $e) {
Mage::getSingleton('adminhtml/session')->addError($e->getMessage());
$this->_redirect('*/*/axis');
}
}
public function sizeGridAction()
{
$this->loadLayout();
$this->getLayout()->getBlock('freight/adminhtml_freight_axis_xgrid')
->setSizesGroup($this->getRequest()->getPost('products_grouped', null));
$this->renderLayout();
}
public function sizeGridOnlyAction()
{
$this->loadLayout();
$this->getLayout()->getBlock('freight/adminhtml_freight_axis_xgrid')
->setSizesGroup($this->getRequest()->getPost('products_grouped', null));
$this->renderLayout();
}
public function weightGridAction()
{
$this->loadLayout();
$this->getLayout()->getBlock('freight/adminhtml_freight_axis_ygrid')
->setWeightsGroup($this->getRequest()->getPost('products_grouped', null));
$this->renderLayout();
}
public function weightGridOnlyAction()
{
$this->loadLayout();
$this->getLayout()->getBlock('freight/adminhtml_freight_axis_ygrid')
->setWeightsGroup($this->getRequest()->getPost('products_grouped', null));
$this->renderLayout();
}
}
Conclusions
During the development of this UI, I was constantly challenged and often ran into dead ends. I would recommend using this concept of serialised ajax calls, and remind anyone of the fix for the total records when adding an item to the collection.