Türchen 23: Pimp My Produktgrid

In diesen Türchen soll es um die kundenspezifische Erweiterung und Anpassung vom Magento Produktgrid gehen. Das Productgrid von Magento bietet im Standard folgende Spalten: ID, Name, Typ, Attributsetname, Artikelnummer, Preis, Anzahl, Sichtbarkeit, Status, Aktion. Diese Spaltenauswahl ist schon sehr nützlich, aber natürlich recht allgemein gehalten, je nach dem, an was für einen Shop wir arbeiten, interessiert den Kunden einiges davon nicht, anderes wird ihm fehlen. Wir können also unserem Kunden das Leben einiges einfacher machen, wenn wir dieser zentralen Stelle um einige nützliche Infomationen/Features unterbringen.

Unser Beispiel-Kunde hat einen einfachen T-Shirt-Shop ohne Warenwirtschaftsanbindung, die Verwaltung der Produkte passiert komplett in Magento und er hat sehr viele Produkte und ein schlechtes Gedächtnis, so dass er gute Filtermöglichkeiten braucht, um die Produkte wiederzufinden.

Um unserem Beispiel-Kunden entgegenzukommen werden wir folgendes tun:

  • Eine Spalte mit dem letzten Änderungsdatum (Magento-Standard-Attribut) hinzufügen, damit er "vernachlässigte" Produkte findet
  • Die Spalte Farbe (kundenspezifisches Attribute) in dem Grid unterbringen, über die zugehörigen Filter findet er seine Produkte schnell wieder.
  • Da unser Grid immer breiter wird, werden wir die Spalte Attributset entfernen, unser Kunde hat eh nur "T-Shirts". Auch die "Sichtbarkeit" werfen wir raus, alle Produkte stehen eh immer auf "Katalog/Suche"

Aber zuerst ein bisschen Theorie:

Allgemeine Funktionsweise von Grids Alle Grids in Magento werden von der Klasse Mage_Adminhtml_Block_Widget_Grid bzw. von Ihren Subklassen gerendert. Diese Klasse verwaltet im Attribut $_collection die angezeigte Magento-Collection und im Attribut $_columns die angezeigten Spalten des Grids. Die Collection und die Spalten werden in den abgeleiteten Klassen in den Methoden _prepareCollection() und _prepareColumns() befüllt. Beide Methoden werden automatisch aus der Methode _beforeToHtml() der Elternklasse aufgerufen. Für das bekannte Produkte-Grid sieht das so aus
	class Mage_Adminhtml_Block_Catalog_Product_Grid extends Mage_Adminhtml_Block_Widget_Grid {
		// ...
		protected function _prepareCollection()
		{
			$store = $this->_getStore();
			$collection = Mage::getModel('catalog/product')->getCollection()
				->addAttributeToSelect('sku')
				->addAttributeToSelect('name')
				->addAttributeToSelect('attribute_set_id')
				->addAttributeToSelect('type_id')
			// weitere Attribute werden geladen
			// ...
		 }


		protected function _prepareColumns()
		{
		    $this->addColumn('entity_id',
		        array(
		            'header'=> Mage::helper('catalog')->__('ID'),
		            'width' => '50px',
		            'type'  => 'number',
		            'index' => 'entity_id',
		    ));
		    $this->addColumn('name',
		        array(
		            'header'=> Mage::helper('catalog')->__('Name'),
		            'index' => 'name',
		    ));
		// weitere Spalten werden definiert
		// ...
		}
		// ...
	}
Das Produkt-Grid erweitern Um das ganze zu erweitern könnten wir jetzt ein rewrite auf den Block adminhtml/catalog_product_grid machen und diese Methoden erweitern. Das wäre aber sehr unhöflich ;). Einserseits weiteren Entwicklern/Extensions gegenüber die dass Grid auch erweitern wollen, andererseits dem Magento Core Team gegenüber, die in der nächsten Version bestimmt ganz viele tolle Features in das Grid packen, die wir dann leider nicht mitbekommen. Ausserdem kommn wir, sofern Market Ready Germany installiert ist, eh schon zu spät, den deren Extension Symmetrics_DeliveryTime hat den Block schon überschrieben. Wir gehen also einen anderen Weg und zwar über Events: Schön wäre es, gäbe es ein Event "adminhtml_block_catalog_product_grid_html_before", gibt es aber nicht. Wir steigen also in der Block-Klasssenhierarchie ein paar Meter weiter nach oben und finden dort das Event "adminhtml_block_html_before". Das nehmen wir und überprüfen in unserem Observer ob wir jetzt den richtigen Block haben:
	<adminhtml>
		<events>			
			<adminhtml_block_html_before>
				<observers>
                    <webguys_productgrid_adminhtml_block_html_before>
                        <class>webguys_productgrid/observer</class>
                        <method>beforeBlockToHtml</method>
                    </webguys_productgrid_adminhtml_block_html_before>
                </observers>				
			</adminhtml_block_html_before>
		</events>
	</adminhtml>
Im Observer filtern wir uns den "interessanten" Block heraus:
	class Webguys_Productgrid_Model_Observer {
		// ...
		public function beforeBlockToHtml(Varien_Event_Observer $observer) {
			$block = $observer->getEvent()->getBlock();
			if ($block instanceof Mage_Adminhtml_Block_Catalog_Product_Grid) {
				$this->_modifyProductGrid($block);
		    	}
		// ...
		}
	}

So, jetzt können wir loslegen:

Ein Standard-Attribut (Änderungsdatum) im Grid anzeigen: Die Grid-Klasse bietet die Funktionen addColumn($columnId, $column) und addColumnAfter($columnId, $column, $after) um das Grid zu manipulieren. Wir benutzen addColumnAfter() um Kontrolle über die Positionierung der Spalte zu bekommen.
	/**
	 * Fügt dem Grid die Spalte "zuletzt geändert" hinter der Spalte "Name" hinzu 
	 * 
	 * @param Mage_Adminhtml_Block_Catalog_Product_Grid $grid 
	 */
	protected function _addUpdatedAtColumn(Mage_Adminhtml_Block_Catalog_Product_Grid $grid) {		
		$grid->addColumnAfter(
			'updated_at', // interne Spalten ID
			array(
				'header' => 'l. Änderung', // Text im Header
				'index' => 'updated_at',	  // Array index der aktuellen Row				
				'type' => 'date',			  // Welcher renderer
				'format' => 'dd.MM.YYYY',	  // Datumsformat a la Zend_Date (type=date spezifisch)
				'width' => '100px',			  // Breite der Spalte (empfohlen)				
				'header_css_class' => 'updated_at', // zusätliche css Klasse für den Header
				'sortable' => true,
				'align' => 'right'
			),
			'status' // Nach welcher Spalte einfügen
        	);
		// muss von uns extra aufgerufen werden, damit die Sortierung greift
		$grid->sortColumnsByOrder();
	}
Ein eigenes Select-Attribut im Grid anzeigen Wenn wir ein eigenes (nicht von Magento im Grid vorgesehenes Attribut) im Grid anzeigen wollen, müssen wir das Attribut erst - EAV sei Dank - an die zugrunde liegende Collection joinen. Auch das machen wir mit einem Event-Listener:
	<adminhtml>
		<events>			
			<catalog_product_collection_load_before>
				<observers>
					<webguys_productgrid_adminhtml_block_html_before>
						<class>webguys_productgrid/observer</class>
						<method>beforeCatalogProductCollectionLoad</method>
					</webguys_productgrid_adminhtml_block_html_before>
				</observers>								
			</catalog_product_collection_load_before>
		</events>		
	</adminhtml>
und im Observer dann:
	public function beforeCatalogProductCollectionLoad(Varien_Event_Observer $observer) {
		$collection = $observer->getEvent()->getCollection();
		if ($collection instanceof Mage_Catalog_Model_Resource_Eav_Mysql4_Product_Collection) {
			$collection->addAttributeToSelect('color');
		}
	}
Ein Select-Attribut fügt man mit dem type 'options' ein, zusätzlich werden die Optionen im Feld 'options' übergeben:
	protected function _addColorColumn(Mage_Adminhtml_Block_Catalog_Product_Grid $grid) {		
		$grid->addColumnAfter(
			'color', // interne Spalten ID
			array(
				'header' => 'Farbe', 
				'index' => 'color',	  
				'type'  => 'options',
				'options' => $this->_getProductAttributeOptions('color')
			),
			'status' // Nach welcher Spalte einfügen
        );
	}
Und hier zughörige Helper-Funktion getProductAttributeOptions(): warum auch immer Magento alle Integer Werte mit 4 Nachkommstellen formatiert, wir kommen Magento da entgegen und machen das genauso ;)
	/**
	 * Holt Attribute-Options im erwarteten Format 
	 * @param string $attributeName 
	 * @return array
	 */
	protected function _getProductAttributeOptions($attributeName) {
		$attribute = Mage::getModel('eav/config')->getAttribute('catalog_product',$attributeName);
		/* @var $attribute Mage_Catalog_Model_Resource_Eav_Attribute */		
		$attributeOptions = $attribute->getSource()->getAllOptions();
        	$options = array();
		// options in key => value Format bringen
		foreach ($attributeOptions as $option) {
			$options[number_format($option['value'], 4, '.', '')] = $option['label'];
		}		
		return $options;		
	}
Spalten entfernen So, jetzt machen wir uns daran, unser langsam ein wenig eng werdendes Grid wieder zu entschlacken: Spalten entfernen kann man mit folgendem Code:
	/**
	 * Entfernt eine Spalte aus dem Grid
	 * 
	 * @param Mage_Adminhtml_Block_Catalog_Product_Grid $block 
	 * @param string $columnName
	 */
	protected function _removeColumn(Mage_Adminhtml_Block_Catalog_Product_Grid $block, $columnName) {
		$columns = $block->getColumns();
		// entfernt die Spalte
		unset($columns[$columnName]);		
		// Autsch, aber leider gibt es kein setColumns()
		$this->_mutateProtectedProperty($block, '_columns', $columns);
	}
Leider fehlt in den Magento-Grids eine public Methode um die bestehenden Columns zu modifizieren, egal, wir bauen uns unsere eigene: PHP5.3 macht es mit ReflectionProperty::setAccessible möglich:
	protected function _mutateProtectedProperty($object, $propertyName, $value) {
		$reflection = new ReflectionClass($object);
		$property = $reflection->getProperty($propertyName);
		$property->setAccessible(true);
		$property->setValue($object, $value);		
	}
Schön ist das natürlich nicht, aber uns bleibt bei der Event-Methode keine andere Wahl. Hätten wir ein rewrite gemacht, wäre es kein Problem $this->_columns zu nutzen. Mist! Ausserdem gibt es noch einen Haken Nicht alle unsere Änderungen werden von Magento übernommen, zum Beispiel funktioniert die Sortierung und Filterung der neuen Attribute nicht. Das liegt daran, dass unsere Änderungen nach dem Aufruf von _prepareCollection(), in dem die Spalteninfos an die Collection übergeben werden, stattfinden. Was tun? Mage_Adminhtml_Block_Widget_Grid::_prepareCollection() noch mal selber aufrufen geht leider nicht, da die Methode protected ist. Ihr ahnt es schon, oder?
	protected function _callProtectedMethod($object, $methodName) {
		$reflection = new ReflectionClass($object);
		$method = $reflection->getMethod($methodName);
		$method->setAccessible(true);
		return $method->invoke($object);
	}
und ganz ungeniert nach all den Änderungen _prepareCollection() aufrufen:
	protected function _modifyProductGrid(Mage_Adminhtml_Block_Catalog_Product_Grid $grid) {
		
		$this->_addUpdatedAtColumn($grid);
		$this->_addColorColumn($grid);
		
		$this->_removeColumn($grid, 'set_name');
		$this->_removeColumn($grid, 'visibility');		

		// reinitialisiert die Spaltensortierung
		$grid->sortColumnsByOrder();
		// reinitialisiert die Sortierung und Filter der Collection 
		$this->_callProtectedMethod($grid, '_prepareCollection');				
		
	}

Fazit

Ein an den Kunden angepasstes Produktgrid kann man den Workflow oft sehr vereinfachen. Der Programmier-Aufwand ist dabei nicht sonderlich gross, die Usability für den Kunden aber viel besser. Am besten fragt man nach den ersten Wochen Produktpflege mal beim Kunden nach, ob etwas fehlt. Alle anderen Grids im Backend sind natürlich genauso anpassbar, zum Beispiel könnte man die Zahlart im Bestellgrid anzeigen, o.ä. Zum technischen Aspekt: Eigentlich sollte dieser Artikel ein großer Lobgesang auf Events statt Rewrites werden und zeigen, wie man mit Events viel eleganter zum Ziel kommt. Beim Schreiben des Artikels bin ich aber einige Male an die Grenzen von Events gestossen. Die Verrenkungen, die man anstellen muss, um an die protected Attribute und Methoden zu kommen, sind wirklich nicht schön. Auch das Timing der Events ist nicht ganz perfekt. Andererseits bleibt man upgradefähiger und unabhängiger von anderen Extensions. Ich persönlich werde wohl in Zukunft projektspezifische Anpassungen doch wieder in einem Rewrite programmieren, allgemein wiederverwendbare Module (z.B. einen Kategoriefilter) aber per Events umsetzen. Am praktischsten wäre es natürlich, würde Magento die benötigten Methoden public machen und Events a la "adminhtml_grid_prepare_columns" und "adminhtml_grid_prepare_collection" bieten würde.


Ein Beitrag von Johannes Künsebeck
Johannes's avatar

Johannes Künsebeck lebt in Bielefeld und ärgert sich schon seit fast 10 Jahren mit PHP herum. Seit 2010 arbeitet er bei der HDNET GmbH & Co. KG als Magento-Entwickler. Besonders schlimme Magento-Erfahrungen teilt er im Blog [Mage::log()] mit.

Alle Beiträge von Johannes

Kommentare
Christian am

Hallo,

ich habe mal eine Frage.

Ich habe nach dieser Anleitung das Admin Grid erweitert. Leider habe ich jetzt ein Problem. Ich möchte den "Stock Status" anzeigen.

Ich setze Magento 1.9 ein und habe folgendes geändert.

public function beforeCatalogProductCollectionLoad(Varien_Event_Observer $observer) { $collection = $observer->getEvent()->getCollection(); if ($collection instanceof Mage_Catalog_Model_Resource_Product_Collection) { $collection->joinTable( 'cataloginventory/stock_status', 'product_id=entity_id', array("stock_status" => "stock_status") ); }

Leider geht das nicht. SQLSTATE[42S22]: Column not found: 1054 Unknown column 'e.stock_status' in 'where clause', query was: SELECT COUNT(DISTINCT e.entity_id) FROM catalog_product_entity AS e INNER JOIN catalog_product_entity_int AS at_status ON (at_status.entity_id = e.entity_id) AND (at_status.attribute_id = '96') AND (at_status.store_id = 0) INNER JOIN catalog_product_entity_int AS at_visibility ON (at_visibility.entity_id = e.entity_id) AND (at_visibility.attribute_id = '102') AND (at_visibility.store_id = 0) INNER JOIN cataloginventory_stock_status ON (cataloginventory_stock_status.product_id=e.entity_id) WHERE (e.stock_status = '1')

Ist mir auch klar weil er e.stock_status = '1' nimmt...

Wenn ich das ganze in der Grid.php in die Funktion _prepareCollection() einbaue geht es...

Kann mir da jemand bei helfen?

Gruß

Christian

Sebastian Lemke am

Hallo,

ein kleines Problem - zumindest mit Magento 1.8.1 - gibt es - das wird wahrscheinlich/vielleicht auch der Grund der Probleme von MageCoder sein:

In der Funktion "beforeCatalogProductCollectionLoad" ist $collection keine Instanz von Mage_Catalog_Model_Resource_Eav_Mysql4_Product_Collection, sondern von Mage_Catalog_Model_Resource_Product_Collection - vielleicht war das in einer älteren Version so? Ich hab´s wie folgt angepasst:


        if (($collection instanceof Mage_Catalog_Model_Resource_Eav_Mysql4_Product_Collection) ||
            ($collection instanceof Mage_Catalog_Model_Resource_Product_Collection)) {

Nun werden die Werte auch in der neuen Spalte angezeigt.

Grüße, Sebastian

quafzi am

Nutzt man den CSV-Export, kommt man mit der Event-/Observer-Variante leider auch nicht weit - zumindest ist es mir bislang nicht gelungen, meine eigenen Spalten damit unter zu bekommen. Falls jemand Vorschläge hat, bin ich dafür offen :)

MageCoder am

Sehr schönes Tutorial, funktioniert alles wunderbar, bis auf die Anzeige der Value meine EAV Attributes. Die Daten sind korrekt geladen und man kann auch danach filtern, aber der Wert wird nicht angezeigt in der Spalte.

Deswegen habe ich noch einen CustomColumnRenderer hinzugefügt zum meinem EAV Attribute, welcher die Anzeige übernimmt und gleichzeitig aus 1 und 0 noch yes oder no ausgibt.

Elias am

Vielen Dank - hat mir gerade einen Haufen Arbeit und Rumsucherei erspart.

Someone am

Super Beitrag!

Habe mal eine Frage... Ist es möchglich im Grid wenn z.B. die Zahlungsmethode Nachname ist einfach nur ein Ja anstatt des Eintrags "method" unter .sales_flat_order_payment auszugeben?

Danke schon mal!

Johannes am

PS: Den Code gibt es übrigens komplett unter: https://github.com/hnesk/Webguys_Productgrid @Andreas: Wenn man nur Spalten hinzufügt, aber die Filter/Sortierung neu initialisieren will, kann man in PHP5.2 statt dem protected _prepareCollection() Aufruf auch z.B. getCSV() werden, das ruft _prepareCollection() intern auf. Schöner wird es dadurch natürlich auch nicht ;) @Vinai: Wird gemacht.

Vinai am

Hallo Johannes, danke für diesen klasse Artikel! Bzgl. den fehlenden Events, bzw. fehlenden removeColumn() Methode: wie wäre es einen Patch zu submitten? Mit sind die Lücken an diesen und anderen Stellen auch schon öfter mal auf die Nerven gegangen (z.B. das fehlende $prefix . _get_select_count_sql_before event in getSelectCountSql für collections).

Weitere Infos zu dem Thema Community Contributions sind hier zu finden: http://www.magentocommerce.com/blog/comments/be-part-of-the-solution-become-a-magento-contributor

Danke und eine gute Weihnachtzeit etc, Vinai

Ralf Siepker am

Nö, immer noch PHP 5.2, siehe index.php im root: if (version_compare(phpversion(), '5.2.0', '

Tobias Vogt am

Magento 1.6 setzt doch PHP 5.3 voraus?

Ralf Siepker am

Interessanter Artikel, auf den ich gerne zurückkomme, da ich im Moment auch eher adminhtml/catalog_product_grid überschreibe, um dann in _prepareColumns() Spalten zu bearbeiten:


    protected function _prepareColumns()
    {
        $this->addColumnAfter(...);
        parent::_prepareColumns();
        unset($this->_columns['type']);
        unset($this->_columns['set_name']);
        return $this;
    }

Zum Glück ist kein MRG installiert und ich habe die Kontrolle über die zu installierenden Module. :-)

Andreas von Studnitz am

Schöner Beitrag, vielen Dank. Leider funktioniert, wie du geschrieben hast, das mit den Reflection Properties in PHP 5.2 nicht, welches von Magento ja prinzipiell auch unterstützt wird - daher würde ich wohl (leider) auch Rewrites einsetzen, sobald es an das Löschen bestehender Spalten geht. Dennoch, das Hinzufügen neuer Spalten, was ja die häufigste Anforderung ist, ist, wie du beschrieben hast, ja ohne weiteres möglich.

Tobias Vogt am

Schöner Artikel. Gerade dein Fazit hat mir sehr gefallen :)

Dein Kommentar