Magento: Eigenes TinyMCE Plugin aktivieren

Magento bringt mit TinyMCE von Haus aus einen Rich Text Editor mit, der im Backend für Attribute, CMS-Seiten u.a. verwendet wird. Die Integration erfolgt über diverse Adapterklassen und Skripte, die quer über den Magento-Core verteilt sind, darunter das Formular-Element Varien_Data_Form_Element_Editor, ein paar Models im CMS-Modul und diverse Blocks und Helpers im Adminhtml-Modul sowie eigene JavaScripts in js/mage/adminhtml/wysiwyg.

Ich habe nun für meine letzte Extension ein eigenes TinyMCE Plugin entwickelt und stand vor dem Problem, wie ich es aktivieren könnte. Ein einfaches einbinden des Plugin-Skripts schlug fehl, da der TinyMCE Editor vom Magento-Adapter dynamisch nachgeladen wird. Leider habe ich dafür auch nach langem Suchen keine brauchbare Dokumentation gefunden und da Replacements und Hacks von Core-Dateien nicht in Frage kommen blieb mir letztlich nichts übrig als die oben genannten Klassen und Skripte zu analysieren. Als Resultat gibt es hier nun eine Anleitung zum einbinden von eigenen Plugins:

Wysiwyg Konfiguration

Die Konfiguration für den Editor wird zentral vom ‘cms/wysiwyg_config’ Singleton geladen (auch außerhalb des CMS-Moduls!). Dort ist leider sehr viel hart kodiert und es gibt z.B. keine Konfigurationsquelle für Plugins. Dafür wird dort allerdings ein Event getriggert so dass man die Konfiguration mittels Observer erweitern kann. Das Event dazu heißt cms_wysiwyg_config_prepare und hat als Parameter ein Varien_Object namens config.

Ein Observer dafür könnte so aussehen:

class SSE_Example_Model_Observer
{
	public function onCmsWysiwygConfigPrepare(Varien_Event_Observer $observer)
	{
		$config = $observer->getEvent()->getConfig();
		$this->addPlugin($config, $this->getContentBlockPluginConfig());
	}

	private function addPlugin(Varien_Object $config, $plugin)
	{
		$config->setData('plugins', array_merge((array) $config['plugins'], array($plugin)));
	}
	
	private function getContentBlockPluginConfig()
	{
		return array(
			'options' => array(
			),
			'name' => 'example',
			'src' => Mage::getBaseUrl(Mage_Core_Model_Store::URL_TYPE_JS) . 'sse/example/editor_plugin.js',
		);
	}

In der Methode getContentBlockPluginConfig() wird hier die Konfiguration für ein neues Plugin angelegt:

options
Das Options-Array wird einer zusätzlichen Plugin-Schnittstelle von Magento verarbeitet. Hierüber lassen sich Plugins wie der “Insert Image”-Button realisieren, die außerhalb von TinyMCE funktionieren. Dies soll uns an dieser Stelle aber nicht interessieren. 1
name
Der Name des Plugins, so wie er im Plugin-Skript als Parameter für tinymce.PluginManager.add() angegeben wurde
src
Der Pfad zum Plugin-Skript. Dieser kann relativ zum Ort von TinyMCE (tinymce.baseUrl) sein, ich empfehle allerdings der Übersichtlichkeit zuliebe wie oben einen absoluten Pfad zu nutzen und das Plugin in einem eigenen Verzeichnis abzulegen. Da das Plugin von Magento explizit über diesen Pfad geladen wird, sind die Namenskonventionen von TinyMCE hier nicht von Belang, für andere Ressourcen gelten sie weiterhin (Die Sprachdatei wäre hier z.B. in js/sse/example/langs/en.js)
name und src werden clientseitig von der Setup-Klasse in js\mage\adminhtml\wysiwyg\tiny_mce\setup.js verarbeitet, die für das Laden des Editors zuständig ist.

Einfügen des Buttons/Steuerelements ins TinyMCE Menü

Üblicherweise muss man neue Steuerelemente über die theme_advanced_buttons{n} Parameter beim Initialisieren von TinyMCE im Menü einfügen. Da diese Initialisierung aber wie gesagt komplett von Magento übernommen wird, gibt es hier einfach die Konvention dass das Element genauso heißt wie das Plugin (Magento fügt den Namen an vorderster Position in der ersten Reihe ein):

	// ...
	tinymce.create('tinymce.plugins.Example', {
		init : function(editor, url) {
			// ...
			editor.addButton('example', {
				// ...
			});
		},
	// ...
	}

Aktivierung nur für bestimmte Formulare

Dadurch dass die Konfiguration zentral über ein Singleton geladen wird, ist sie leider für jede Editor-Instanz die selbe. In meinem Fall brauchte ich das Plugin jedoch nur für die Produktbeschreibung und wollte es entsprechend nicht in anderen Formularen aktiviert haben. Das ließ sich letztendlich am besten clientseitig bewerkstelligen, indem bei der Plugin-Initialisierung die ID des Editors abgefragt wird (Die ID lässt sich am besten über den DOM-Inspektor von Firebug oder einem ähnlichen Tool herausfinden):

	// ...
	tinymce.create('tinymce.plugins.Example', {
		init : function(editor, url) {
			if (editor.id == "description_editor") {
				this._init(ed, url);
			}
		},
		_init : function(editor, url) {
			// real initialization here
		},
	// ...
	}

Fazit

Wie so oft bei Magento: Eigentlich ganz einfach, wenn man nur weiß wie es geht. Ich hoffe, dem ein oder anderen hiermit das stundenlange Wühlen im Core zu ersparen was ich dafür auf mich nehmen musste 🙂

Notes:

  1. Zum Vertiefen empfehle ich den Quelltext von Varien_Data_Form_Element_Editor::_getPluginButtonsHtml() als Einstiegspunkt.

Magento: Singletons mit Mock-Objekten/Stubs ersetzen

Und wieder etwas zum Thema Unit Test mit Magento:

Es ist möglich, Models die über Mage::getSingleton() geladen werden zur Laufzeit mit konkreten Objekten zu ersetzen, quasi der Magento way of Dependency Injection. Dazu ist allerdings ein kleiner Trick nötig. Es gibt zwar auch die Möglichkeit, Rewrites zur Laufzeit zu konfigurieren aber im Fall von Mock Objects hilft uns das nicht weiter, schließlich wollen wir Magento nicht einfach eine andere Klasse instantiieren lassen sondern einen fertig konfigurierten Stub injizieren.

Hierzu ein gekürztes Beispiel (Test Case):

//...
	const LIFE_CLASS = 'My_Namespace_Model_Life';
	const LIFE_MODEL = 'mynamespace/life';

	private $lifeStub;
//...
	public function setUp()
	{
		parent::setUp();
		$this->registerLifeStub();
	}
	/**
	 * @test
	 */
	public function testUseBackendModel()
	{
		$this->configureLifeStub(42);
		// now test something that uses Life
	}
//...
	private function registerLifeStub()
	{
		$this->lifeStub = $this->getMock(self::LIFE_CLASS);
			
		// register stub as singleton
		$registryKey = '_singleton/'.self::LIFE_MODEL;
		Mage::unregister($registryKey);
		Mage::register($registryKey, $this->lifeStub);
	}
	private function configureLifeStub($result)
	{
		$this->lifeStub
			->expects($this->any())
			->method('getMeaning')
			->will($this->returnValue($result));
	}
//...

Die Besonderheit liegt hier in der Methode registerLifeStub(), die beim Setup aufgerufen wird, das Mock Object anlegt und registriert. Dabei wird direkt so auf die Magento-Registry zugegriffen wie es Mage::getSingleton() macht. Wichtig ist auch der Aufruf von Mage::unregister() da Mage::register() kein Überschreiben zulässt.

configureLifeStub() ist nur ein Beispiel dafür dass das Mock Object jederzeit unterschiedlich konfiguriert werden kann und dies nicht beim erstellen festgelegt werden muss.

Diese Methode ist sowohl für Core-Singletons wie die diversen Session-Klassen als auch für selbst geschriebene Provider-Klassen interessant, funktioniert aber wirklich nur, wenn diese auch mit Mage.:getSingleton() geladen werden, da Mage::getModel() immer ein neues Objekt instantiiert – dafür wäre ein deutlich tieferer Eingriff notwendig.

Magento: Testen von Configuration Backend Models

Eine professionell entwickelte Magento-Extension will natürlich mit Unit Tests entwickelt werden und der modulare Aufbau sollte dies einfach machenIch nutze dazu die PHPUnit Erweiterung von EcomDev, die sich um das Ausführen der Tests, Instantiierung der Magento-Applikation, Test-Daten und mehr kümmert. In der Praxis tauchen aber häufig unerwartete Fallstricke auf, einen davon will ich hier kurz beschreiben:

Configuration Backend Models sind fürs Speichern und Laden von Werten in der Systemkonfiguration zuständig und können beispielsweise Anpassungen in beforeSave() und afterLoad() oder Validierungen vornehmen.

Mein erster Ansatz, diese Methoden zu testen, war das setzen über die Magento-Applikation:

// Hilfs-Methoden im Test Case:
	private function setConfig($value)
	{
		Mage::getConfig()->saveConfig(
			self::XML_PATH, $value)->cleanCache();
		// sicher stellen dass die neue Konfiguration gespeichert und wieder geladen ist:
		Mage::reset();
		Mage::init();
	}
	private function getConfig()
	{
		return Mage::getStoreConfig(
			self::XML_PATH);
	}

Dabei stellte sich heraus, dass mein Backend Model gar nicht benutzt wurde und natürlich ist der Fehler immer da wo man ihn zuletzt sucht: In Magentos Methoden zum setzen und abfragen von Konfigurationswerten wird direkt auf das Resource Model zugegriffen, das Backend Model kommt nur im User Interface zum Tragen.

Die Lösung ist also, das Backend Model direkt zu instantiieren und zu testen, dabei ist folgendes zu beachten:

Zunächst muss der bestehende Datensatz geladen werden, ansonsten gibt es beim Speichern Konflikte mit dem UNIQUE Index. Ich mache das über den Konfigurations-Pfad, bei mehreren Werten (z.B. Store-spezifische Konfiguration) ist die Abfrage mit load() leider nicht mehr eindeutig und müsste entsprechend zusammengebaut werden. So lange es für den Test nicht relevant ist, mit welchem konkreten Wert gearbeitet wird, kann es allerdings trotzdem so gemacht werden.

// Aufsetzen des Test Case:
	public function setUp()
	{
		parent::setUp();
		$this->model = Mage::getModel(self::MODEL)
			->load(self::XML_PATH, 'path');
	}

Die Hilf-Methoden von oben konnte ich dann refaktorisieren:

// Hilfsmethoden im Test Case:
	private function setConfig($value)
	{
		$this->model->setValue($value);
		$this->model->save();
	}
	private function getConfig()
	{
		return $this->model->getValue();
	}

Das ist so einfach dass nun eigentlich keine eigenen Methoden dafür mehr nötig wären, aber ich hatte sie nun schonmal angelegt. Weiterhin sind sie auf das Testen des Speicherns ausgerichtet (es ging mir konkret um beforeSave()), es sollte aber ein leichtes sein, bei Bedarf getConfig() um erneutes Laden zu erweitern.

Ein einfacher Test könnte nun so aussehen:

// Test
	/**
	 * @test
	 */
	public function testConvertKbOnSave()
	{
		$input = '1K';
		$expected = '1024';

		$this->setConfig($input);
		$this->assertEquals($expected, $this->getConfig(), 'configuration should be changed');
		$this->assertFalse($this->model->hasDataChanges(), 'configuration should be saved');
	}

Viel Erfolg!

Magento: Neue Kundenattribute im Backend-Fomular sichtbar machen

Wer in einem Setup-Skript mittels addAttribute() der Customer-Enitität neue Attribute hinzufügen will, erlebt ab Magento 1.5 eine Überraschung. Das Setup läuft zwar fehlerfrei und die DB-Einträge in customer_eav_attribute sind anschließend vorhanden aber es findet sich im Backend kein neues Formular-Element.

Was mit anderen Entitäten wie z.B. Kategorien problemlos funktioniert und bis Magento 1.4 auch mit Kunden, benötigt hier nun eine zusätzlichen Setup-Schritt mit dem das Attribut explizit dem Admin-Formular hinzugefügt wird.

Dazu existiert eine neue Tabelle customer_form_attribute, die für jedes Attribut festlegt, in welchen Formularen es verwendet wird:

mysql> select * from customer_form_attribute;
+----------------------------+--------------+
| form_code                  | attribute_id |
+----------------------------+--------------+
| adminhtml_customer         |            1 |
| adminhtml_customer         |            3 |
| adminhtml_customer         |            4 |
| checkout_register          |            4 |
| customer_account_create    |            4 |
| customer_account_edit      |            4 |
| adminhtml_customer         |            5 |
| checkout_register          |            5 |
| customer_account_create    |            5 |
| customer_account_edit      |            5 |
| adminhtml_customer         |            6 |
| checkout_register          |            6 |
| customer_account_create    |            6 |
| customer_account_edit      |            6 |
| adminhtml_customer         |            7 |
| checkout_register          |            7 |
| customer_account_create    |            7 |
| customer_account_edit      |            7 |
[...]
+----------------------------+--------------+
88 rows in set (0.00 sec)

Aber dies nur zum Verständnis, natürlich muss man nicht selber an der Datenbank Hand anlegen, es genügen folgende Zeilen nach dem addAttribute()-Aufruf im Setup-Skript:

    Mage::getSingleton('eav/config')
        ->getAttribute('customer', $attributeCode)
        ->setData('used_in_forms', array('adminhtml_customer'))
        ->save();

wobei $attributeCode der eindeutige Code des neuen Attributs ist und 'adminhtml_customer' der Code für das Kundenverwaltungsformular im Backend. Um die Setup-Skripte übersichtlicher zu halten empfiehlt sich eine Erweiterung der Entity Setup-Klasse etwa wie folgt:

class MyNamespace_ExtendableCustomer_Model_Entity_Setup
    extends Mage_Customer_Model_Entity_Setup
{
    public function addAttributeToForm($attributeCode, $formCode)
    {
    	Mage::getSingleton('eav/config')
            ->getAttribute('customer', $attributeCode)
            ->setData('used_in_forms', (array) $formCode)
            ->save();
    }
}

Nutzt man nun diese Klasse für seine Setup-Skripte, die Kundenattribute hinzufügen, ist es ganz einfach:

$this->addAttribute('customer', 'neues_attribut', array( /* ... */);
$this->addAttributeToForm('neues_attribut', 'adminhtml_customer');

Analog kann das Attribut auch zu anderen Formularen hinzugefügt werden (Codes siehe oben).

Danke ans Magento-Forum für den entscheidenden Hinweis!

Update

Achtung, beim Speichern wird zusätzlich das Feld eav_entity_attribute.sort_order gesetzt, unabhängig davon ob dieses schon vorher spezifiziert wurde!

Siehe dazu mein Kommentar auf StackOverflow.

Weiterhin wirkt sich sort_order nur aus wenn beim Anlegen des Attributs user_defined => 0 gesetzt wird, da vor sort_order nach user_defined sortiert wird!