Assuming you already have your entity and a grid for it in the backend here is what you need.
In my example, my entity is called article
and the module is Easylife_Press
. Adjust the names to fit your module.
First you need a new table to remember the relation between products and your entities. For this add in your config.xml
the table declaration inside the resource model tag. (models->press->press_resource->entities
)
<article_product><!-- relation table -->
<table>press_article_product</table>
</article_product>
Now you need to create the table. In one of your upgrade scripts add this:
$this->run("
CREATE TABLE {$this->getTable('press/article_product')} (
`rel_id` int(11) unsigned NOT NULL auto_increment,
`article_id` int(11) unsigned NOT NULL,
`product_id` int(11) unsigned NOT NULL,
`position` int(11) unsigned NOT NULL default '0',
PRIMARY KEY (`rel_id`),
UNIQUE KEY `UNIQUE_ARTICLE_PRODUCT` (`article_id`,`product_id`),
CONSTRAINT `PRESS_ARTICLE_ARTICLE_PRODUCT` FOREIGN KEY (`article_id`) REFERENCES {$this->getTable('press/article')} (`entity_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `PRESS_ARTICLE_PRODUCT_ARTICLE` FOREIGN KEY (`product_id`) REFERENCES {$this->getTable('catalog_product_entity')} (`entity_id`) ON DELETE CASCADE ON UPDATE CASCADE
)
ENGINE=InnoDB DEFAULT CHARSET=utf8;
");
If the PK of your entity table is not entity_id
change it. Sorry that I don't have the DDL install script. It's easier to understand this way. If you want I will write one.
Now you need a model to handle this table.
app/code/local/Easylife/Press/Model/Article/Product.php
<?php
class Easylife_Press_Model_Article_Product extends Mage_Core_Model_Abstract{
protected function _construct(){
$this->_init('press/article_product');
}
/**
* Save data for article-product relation
* @access public
* @param Easylife_Press_Model_Article $article
* @return Easylife_Press_Model_Article_Product
*
*/
public function saveArticleRelation($article){
$data = $article->getProductsData();
if (!is_null($data)) {
$this->_getResource()->saveArticleRelation($article, $data);
}
return $this;
}
/**
* get products for article
* @access public
* @param Easylife_Press_Model_Article $article
* @return Easylife_Press_Model_Resource_Article_Product_Collection
*
*/
public function getProductCollection($article){
$collection = Mage::getResourceModel('press/article_product_collection')
->addArticleFilter($article);
return $collection;
}
}
And a model that maps on a table requires a resource model.
app/code/local/Easylife/Press/Model/Resource/Article/Product.php
<?php
class Easylife_Press_Model_Resource_Article_Product extends Mage_Core_Model_Resource_Db_Abstract{
/**
* initialize resource model
* @access protected
* @return void
* @see Mage_Core_Model_Resource_Abstract::_construct()
*
*/
protected function _construct(){
$this->_init('press/article_product', 'rel_id');
}
/**
* Save article - product relations
* @access public
* @param Easylife_Press_Model_Article $article
* @param array $data
* @return Easylife_Press_Model_Resource_Article_Product
*
*/
public function saveArticleRelation($article, $data){
if (!is_array($data)) {
$data = array();
}
$deleteCondition = $this->_getWriteAdapter()->quoteInto('article_id=?', $article->getId());
$this->_getWriteAdapter()->delete($this->getMainTable(), $deleteCondition);
foreach ($data as $productId => $info) {
$this->_getWriteAdapter()->insert($this->getMainTable(), array(
'article_id' => $article->getId(),
'product_id' => $productId,
'position' => @$info['position']
));
}
return $this;
}
/**
* Save product - article relations
* @access public
* @param Mage_Catalog_Model_Product $prooduct
* @param array $data
* @return Easylife_Press_Model_Resource_Article_Product
* @
*/
public function saveProductRelation($product, $data){
if (!is_array($data)) {
$data = array();
}
$deleteCondition = $this->_getWriteAdapter()->quoteInto('product_id=?', $product->getId());
$this->_getWriteAdapter()->delete($this->getMainTable(), $deleteCondition);
foreach ($data as $articleId => $info) {
$this->_getWriteAdapter()->insert($this->getMainTable(), array(
'article_id' => $articleId,
'product_id' => $product->getId(),
'position' => @$info['position']
));
}
return $this;
}
}
...and a collection resource model:
app/code/local/Easylife/Press/Model/Resource/Article/Product/Collection.php
<?php
class Easylife_Press_Model_Resource_Article_Product_Collection extends Mage_Catalog_Model_Resource_Product_Collection{
/**
* remember if fields have been joined
* @var bool
*/
protected $_joinedFields = false;
/**
* join the link table
* @access public
* @return Easylife_Press_Model_Resource_Article_Product_Collection
*
*/
public function joinFields(){
if (!$this->_joinedFields){
$this->getSelect()->join(
array('related' => $this->getTable('press/article_product')),
'related.product_id = e.entity_id',
array('position')
);
$this->_joinedFields = true;
}
return $this;
}
/**
* add article filter
* @access public
* @param Easylife_Press_Model_Article | int $article
* @return Easylife_Press_Model_Resource_Article_Product_Collection
*
*/
public function addArticleFilter($article){
if ($article instanceof Easylife_Press_Model_Article){
$article = $article->getId();
}
if (!$this->_joinedFields){
$this->joinFields();
}
$this->getSelect()->where('related.article_id = ?', $article);
return $this;
}
}
Then you need to add a new tab in the product add/edit form and save the values from it. For this you need to observe the event core_block_abstract_prepare_layout_after
. Add the following to your config.xml
file inside the <adminhtml>
tag.
<events>
<core_block_abstract_prepare_layout_after> <!-- this will add the tab-->
<observers>
<article>
<type>singleton</type>
<class>press/adminhtml_observer</class>
<method>addArticleBlock</method>
</article>
</observers>
</core_block_abstract_prepare_layout_after>
<catalog_product_save_after><!-- this will save the relations -->
<observers>
<article>
<type>singleton</type>
<class>press/adminhtml_observer</class>
<method>saveArticleData</method>
</article>
</observers>
</catalog_product_save_after>
</events>
Now the observer that adds the tab and saves the relations
app/code/local/Easylife/Press/Model/Adminhtml/Observer.php
<?php
class Easylife_Press_Model_Adminhtml_Observer{
/**
* check if tab can be added
* @access protected
* @param Mage_Catalog_Model_Product $product
* @return bool
*
*/
protected function _canAddTab($product){
if ($product->getId()){
return true;
}
if (!$product->getAttributeSetId()){
return false;
}
$request = Mage::app()->getRequest();
if ($request->getParam('type') == 'configurable'){
if ($request->getParam('attribtues')){
return true;
}
}
return false;
}
/**
* add the article tab to products
* @access public
* @param Varien_Event_Observer $observer
* @return Easylife_Press_Model_Adminhtml_Observer
*
*/
public function addArticleBlock($observer){
$block = $observer->getEvent()->getBlock();
$product = Mage::registry('product');
if ($block instanceof Mage_Adminhtml_Block_Catalog_Product_Edit_Tabs && $this->_canAddTab($product)){
$block->addTab('articles', array(
'label' => Mage::helper('press')->__('Articles'),
'url' => Mage::helper('adminhtml')->getUrl('adminhtml/press_article_catalog_product/articles', array('_current' => true)),
'class' => 'ajax',
));
}
return $this;
}
/**
* save article - product relation
* @access public
* @param Varien_Event_Observer $observer
* @return Easylife_Press_Model_Adminhtml_Observer
*
*/
public function saveArticleData($observer){
$post = Mage::app()->getRequest()->getPost('articles', -1);
if ($post != '-1') {
$post = Mage::helper('adminhtml/js')->decodeGridSerializedInput($post);
$product = Mage::registry('product');
$articleProduct = Mage::getResourceSingleton('press/article_product')->saveProductRelation($product, $post);
}
return $this;
}
}
One helper to make it easier to get the related entities
app/code/local/Easylife/Press/Helper/Product.php
:
<?php
class Easylife_Press_Helper_Product extends Easylife_Press_Helper_Data{
/**
* get the selected articles for a product
* @access public
* @param Mage_Catalog_Model_Product $product
* @return array()
*
*/
public function getSelectedArticles(Mage_Catalog_Model_Product $product){
if (!$product->hasSelectedArticles()) {
$articles = array();
foreach ($this->getSelectedArticlesCollection($product) as $article) {
$articles[] = $article;
}
$product->setSelectedArticles($articles);
}
return $product->getData('selected_articles');
}
/**
* get article collection for a product
* @access public
* @param Mage_Catalog_Model_Product $product
* @return Easylife_Press_Model_Resource_Article_Collection
*/
public function getSelectedArticlesCollection(Mage_Catalog_Model_Product $product){
$collection = Mage::getResourceSingleton('press/article_collection')
->addProductFilter($product);
return $collection;
}
}
Now the actual grid block.
app/code/local/Easylife/Press/Block/Adminhtml/Catalog/Product/Edit/Tab/Article.php
<?php
class Easylife_Press_Block_Adminhtml_Catalog_Product_Edit_Tab_Article extends Mage_Adminhtml_Block_Widget_Grid {
/**
* Set grid params
* @access protected
* @return void
*
*/
public function __construct(){
parent::__construct();
$this->setId('article_grid');
$this->setDefaultSort('position');
$this->setDefaultDir('ASC');
$this->setUseAjax(true);
if ($this->getProduct()->getId()) {
$this->setDefaultFilter(array('in_articles'=>1));
}
}
/**
* prepare the article collection
* @access protected
* @return Easylife_Press_Block_Adminhtml_Catalog_Product_Edit_Tab_Article
*
*/
protected function _prepareCollection() {
$collection = Mage::getResourceModel('press/article_collection');
if ($this->getProduct()->getId()){
$constraint = 'related.product_id='.$this->getProduct()->getId();
}
else{
$constraint = 'related.product_id=0';
}
$collection->getSelect()->joinLeft(
array('related'=>$collection->getTable('press/article_product')),
'related.article_id=main_table.entity_id AND '.$constraint,
array('position')
);
$this->setCollection($collection);
parent::_prepareCollection();
return $this;
}
/**
* prepare mass action grid
* @access protected
* @return Easylife_Press_Block_Adminhtml_Catalog_Product_Edit_Tab_Article
*
*/
protected function _prepareMassaction(){
return $this;
}
/**
* prepare the grid columns
* @access protected
* @return Easylife_Press_Block_Adminhtml_Catalog_Product_Edit_Tab_Article
*
*/
protected function _prepareColumns(){
$this->addColumn('in_articles', array(
'header_css_class' => 'a-center',
'type' => 'checkbox',
'name' => 'in_articles',
'values'=> $this->_getSelectedArticles(),
'align' => 'center',
'index' => 'entity_id'
));
$this->addColumn('title', array(
'header'=> Mage::helper('press')->__('Title'),
'align' => 'left',
'index' => 'title',
));
$this->addColumn('position', array(
'header' => Mage::helper('press')->__('Position'),
'name' => 'position',
'width' => 60,
'type' => 'number',
'validate_class'=> 'validate-number',
'index' => 'position',
'editable' => true,
));
}
/**
* Retrieve selected articles
* @access protected
* @return array
*
*/
protected function _getSelectedArticles(){
$articles = $this->getProductArticles();
if (!is_array($articles)) {
$articles = array_keys($this->getSelectedArticles());
}
return $articles;
}
/**
* Retrieve selected articles
* @access protected
* @return array
*
*/
public function getSelectedArticles() {
$articles = array();
//used helper here in order not to override the product model
$selected = Mage::helper('press/product')->getSelectedArticles(Mage::registry('current_product'));
if (!is_array($selected)){
$selected = array();
}
foreach ($selected as $article) {
$articles[$article->getId()] = array('position' => $article->getPosition());
}
return $articles;
}
/**
* get row url
* @access public
* @return string
*
*/
public function getRowUrl($item){
return '#';
}
/**
* get grid url
* @access public
* @return string
*
*/
public function getGridUrl(){
return $this->getUrl('*/*/articlesGrid', array(
'id'=>$this->getProduct()->getId()
));
}
/**
* get the current product
* @access public
* @return Mage_Catalog_Model_Product
*
*/
public function getProduct(){
return Mage::registry('current_product');
}
/**
* Add filter
* @access protected
* @param object $column
* @return Easylife_Press_Block_Adminhtml_Catalog_Product_Edit_Tab_Article
*
*/
protected function _addColumnFilterToCollection($column){
if ($column->getId() == 'in_articles') {
$articleIds = $this->_getSelectedArticles();
if (empty($articleIds)) {
$articleIds = 0;
}
if ($column->getFilter()->getValue()) {
$this->getCollection()->addFieldToFilter('entity_id', array('in'=>$articleIds));
}
else {
if($articleIds) {
$this->getCollection()->addFieldToFilter('entity_id', array('nin'=>$articleIds));
}
}
}
else {
parent::_addColumnFilterToCollection($column);
}
return $this;
}
}
And last, a controller to handle the ajax requests for your custom grid.
app/code/local/Easylife/Press/controllers/Adminhtml/Press/Article/Catalog/ProductController.php
<?php
require_once ("Mage/Adminhtml/controllers/Catalog/ProductController.php");
class Easylife_Press_Adminhtml_Press_Article_Catalog_ProductController extends Mage_Adminhtml_Catalog_ProductController{
/**
* construct
* @access protected
* @return void
*
*/
protected function _construct(){
// Define module dependent translate
$this->setUsedModuleName('Easylife_Press');
}
/**
* articles in the catalog page
* @access public
* @return void
*
*/
public function articlesAction(){
$this->_initProduct();
$this->loadLayout();
$this->getLayout()->getBlock('product.edit.tab.article')
->setProductArticles($this->getRequest()->getPost('product_articles', null));
$this->renderLayout();
}
/**
* articles grid in the catalog page
* @access public
* @return void
*
*/
public function articlesGridAction(){
$this->_initProduct();
$this->loadLayout();
$this->getLayout()->getBlock('product.edit.tab.article')
->setProductArticles($this->getRequest()->getPost('product_articles', null));
$this->renderLayout();
}
}
Just kidding, that wasn't the last thing. You still need to define the layouts for the controller actions. In one of the admin layout files add this:
<adminhtml_press_article_catalog_product_articles>
<block type="core/text_list" name="root" output="toHtml">
<block type="press/adminhtml_catalog_product_edit_tab_article" name="product.edit.tab.article"/>
<block type="adminhtml/widget_grid_serializer" name="article_grid_serializer">
<reference name="article_grid_serializer">
<action method="initSerializerBlock">
<grid_block_name>product.edit.tab.article</grid_block_name>
<data_callback>getSelectedArticles</data_callback>
<hidden_input_name>articles</hidden_input_name>
<reload_param_name>product_articles</reload_param_name>
</action>
<action method="addColumnInputName">
<input_name>position</input_name>
</action>
</reference>
</block>
</block>
</adminhtml_press_article_catalog_product_articles>
<adminhtml_press_article_catalog_product_articlesgrid>
<block type="core/text_list" name="root" output="toHtml">
<block type="press/adminhtml_catalog_product_edit_tab_article" name="product.edit.tab.article"/>
</block>
</adminhtml_press_article_catalog_product_articlesgrid>
I hope I didn't forget any files. Clear the cache and give it a try.
Seams complicated. Well it is. But it can be easy. The code above was auto-generated by the Ultimate Module Creator extension. All you need to do with it is to configure the fields of your entity and say that you want a many to may relation with the products. I hope this is not considered self promotion, because the extension is free, you can get it directly from Magento connect, and it really solves this kind of problems.
You need a couple of things. First of all next to the normal table
you'll have created for your module that stores the data of the adminhtml
form you need a second table
that stores the URLs data with the fields
- entity_id
- parent_id
- label
- url
- sortorder
Create the resource models accordingly.
Rendering in the Adminhtml
Adding a new renderer to the form generator. I've just added the field that will have the renderer
app/code/[codepool]/Namespace/Module/BLock/Adminhtml/[Module]/Edit/Tab/General.php
class [Namespace]_[Module]_Block_Adminhtml_[Module]_Edit_Tab_General extends Mage_Adminhtml_Block_Widget_Form
{
protected function _prepareForm()
{
$form = new Varien_Data_Form();
$this->setForm($form);
$fieldset = $form->addFieldset('[module]_form', array('legend'=>Mage::helper('[namespace]_[module]')->__('General')));
[...]
$urlsField = $fieldset->addField('urls', 'text', array(
'name' => 'urls',
'label' => Mage::helper('[namespace]_[module]')->__('URLS'),
'required' => false,
));
$urlsField = $form->getElement('urls');
$urlsField->setRenderer(
$this->getLayout()->createBlock('[namespace]_[module]/adminhtml_[module]_edit_renderer_urls')
);
[...]
}
}
The renderer class that adds the phtml template
app/code/[codepool]/Namespace/Module/BLock/Adminhtml/[Module]/Edit/Renderer/Urls.php
class [Namespace]_[Module]_Block_Adminhtml_[Module]_Edit_Renderer_Urls
extends Mage_Adminhtml_Block_Widget
implements Varien_Data_Form_Element_Renderer_Interface
{
/**
* Initialize block
*/
public function __construct()
{
$this->setTemplate('[namespace]_[module]/urls.phtml');
}
/**
* Render HTML
*
* @param Varien_Data_Form_Element_Abstract $element
* @return string
*/
public function render(Varien_Data_Form_Element_Abstract $element)
{
$this->setElement($element);
return $this->toHtml();
}
}
And the template that renders the filled in options and adds a javascript template for the other rows.
app/design/adminhtml/default/default/template/[namespace]_[module]/urls.phtml
$_htmlId = $this->getElement()->getHtmlId();
$_htmlClass = $this->getElement()->getClass();
$_htmlName = $this->getElement()->getName();
$_readonly = $this->getElement()->getReadonly();
$collection = Mage::registry('[namespace]_[module]_data')->getUrls(); // this gets the URLs from the model that is loaded for this item, so the class [Namespace]_[Module]_Model_[Object] needs a method `getUrls`
$_counter = 0;
?>
<tr>
<td class="label"><?php echo $this->getElement()->getLabel() ?></td>
<td colspan="10" class="grid hours">
<table id="attribute-options-table" class="data border [module]-urls" cellspacing="0" cellpadding="0"><tbody>
<tr class="headings">
<th><?php echo $this->__('Label') ?></th>
<th><?php echo $this->__('Url') ?></th>
<th class="last"><button id="add_new_option_button" title="Add Option" type="button" class="scalable add"><span><span><span><?php echo $this->__('Add Option') ?></span></span></span></button></th>
</tr>
<?php foreach ($collection as $_item): ?>
<tr class="option-row [module]-urls-row" id="urls-row-<?php echo $_counter?>">
<td><input name="<?php echo $_htmlName; ?>[value][option_<?php echo $_counter ?>][label]" value="<?php echo $_item->getLabel() ?>" class="input-text" type="text"></td>
<td><input name="<?php echo $_htmlName; ?>[value][option_<?php echo $_counter ?>][url]" value="<?php echo $_item->getUrl() ?>" class="input-text" type="text"></td>
<td class="a-left" id="delete_button_container_option_<?php echo $_counter ?>'">
<input name="<?php echo $_htmlName; ?>[value][option_<?php echo $_counter ?>][id]" value="<?php echo $_item->getId() ?>" type="hidden">
<input id="delete-row-<?php echo $_counter ?>" type="hidden" class="delete-flag" name="<?php echo $_htmlName; ?>[delete][option_<?php echo $_counter ?>]" value=""/>
<button onclick="$('hour-row-<?php echo $_counter ?>').style.display='none'; $('delete-row-<?php echo $_counter ?>').setValue(1);" title="Delete" type="button" class="scalable delete delete-option"><span><span><span>Delete</span></span></span></button>
</td>
</tr>
<?php
$_counter++;
endforeach;
?>
</tbody></table>
<script type="text/javascript">//<![CDATA[
var _form_html_row = '<tr class="option-row [module]-urls-row" id="urls-row-{{id}}"><td><input name="<?php echo $_htmlName; ?>[value][option_{{id}}][label]" value="" class="input-text" type="text"></td><td><input name="<?php echo $_htmlName; ?>[value][option_{{id}}][url]" value="" class="input-text" type="text"></td><td class="a-left" id="delete_button_container_option_{{id}}"><input name="<?php echo $_htmlName; ?>[value][option_{{id}}][id]" value="" type="hidden"><input id="delete-row-{{id}}" type="hidden" class="delete-flag" name="<?php echo $_htmlName; ?>[delete][option_{{id}}]" value=""/><button onclick="$(\'urls-row-{{id}}\').style.display=\'none\'; $(\'delete-row-{{id}}\').setValue(1);" title="Delete" type="button" class="scalable delete delete-option"><span><span><span>Delete</span></span></span></button></td></tr>';
var _urls_counter = <?php echo $_counter?>;
$('add_new_option_button').observe('click', function(){
$('attribute-options-table').insert(_form_html_row.replace(/\{\{id\}\}/ig, _urls_counter));
_urls_counter++;
});
//]]></script>
</td>
</tr>
Storing the data on save
app/code/[codepool]/[Namespace]/[Module]/controllers/Adminhtml/[Module]Controller.php
class [Namespace]_[Module]_Adminhtml_[Module]Controller
extends Mage_Adminhtml_Controller_Action
{
public function saveAction()
{
if ( $this->getRequest()->getPost() )
{
try {
$postData = $this->getRequest()->getPost();
// Save the data
$model = Mage::getModel('[namespace]_[module]/object');
$urls = $postData['urls'];
unset($postData['urls']);
$model->setData($postData);
$model->save();
$parentId = $model->getId();
// save urls
if (!empty($urls))
{
foreach ($urls['delete'] as $_key => $_row)
{
$delete = (int)$_row;
$urlsData = $urls['value'][$_key];
$_urls = Mage::getModel('[namespace]_[module]/[urls]')->load((int)$urlsData['id']); // this is the model that stores the URLs data in a second table
if ($delete && 0 < (int)$_urls->getId()) // exists & required to delete
{
$_urls->delete();
continue;
}
if (!$delete)
{
if (0 == (int)$_urls->getId()) // new item
{
Mage::getModel('[namespace]_[module]/[urls]')->setData(array(
'parent_id' => $parentId,
'label' => $urlsData['label'],
'url' => $urlsData['url'],
'sortorder' => $urlsData['sortorder'],
))->save();
}
else
{
$_urls->addData(array(
'store_id' => $parentId,
'label' => $urlsData['label'],
'url' => $urlsData['url'],
'sortorder' => $urlsData['sortorder'],
))->save();
}
}
}
}
// And wrap up the transaction
Mage::getSingleton('adminhtml/session')->addSuccess(Mage::helper('adminhtml')->__('Item was successfully saved'));
[...]
$this->_redirect('*/*/');
return;
} catch (Exception $e) {
[...]
}
}
$this->_redirect('*/*/');
}
}
Now earlier I've referenced to the method getUrls
in the objects model. It would look something like this.
app/code/[codepool]/[Namespace]/[Module]/Model/[Object].php
class [Namespace]_[Module]_Model_[Object] extends Mage_Core_Model_Abstract
{
public function getOpeningHours()
{
return Mage::getModel('[namespace]_[module]/urls')->getCollection()
->addFieldToFilter('parent_id', $this->getId());
}
}
And that's it!
Best Answer
Replace this code
with the following
=> create edit.php file