From 6e0e3564e1830444f8762b366c3f3259733d865d Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 16 May 2013 10:34:45 +1200 Subject: [PATCH] NEW Added beforeExtending, afterExtending, and beforeUpdateCMSFields to allow user code better control over interaction with extending methods --- core/Object.php | 62 ++++++++++++ docs/en/changelogs/3.1.0.md | 3 + docs/en/reference/dataextension.md | 51 ++++++++++ docs/en/reference/dataobject.md | 2 + model/DataObject.php | 10 ++ tests/model/DataExtensionTest.php | 148 +++++++++++++++++++++++++++++ 6 files changed, 276 insertions(+) diff --git a/core/Object.php b/core/Object.php index 00d7f1061..52aae3c17 100755 --- a/core/Object.php +++ b/core/Object.php @@ -66,6 +66,52 @@ abstract class Object { */ protected $extension_instances = array(); + /** + * List of callbacks to call prior to extensions having extend called on them, + * each grouped by methodName. + * + * @var array[callable] + */ + protected $beforeExtendCallbacks = array(); + + /** + * Allows user code to hook into Object::extend prior to control + * being delegated to extensions. Each callback will be reset + * once called. + * + * @param string $method The name of the method to hook into + * @param callable $callback The callback to execute + */ + protected function beforeExtending($method, $callback) { + if(empty($this->beforeExtendCallbacks[$method])) { + $this->beforeExtendCallbacks[$method] = array(); + } + $this->beforeExtendCallbacks[$method][] = $callback; + } + + /** + * List of callbacks to call after extensions having extend called on them, + * each grouped by methodName. + * + * @var array[callable] + */ + protected $afterExtendCallbacks = array(); + + /** + * Allows user code to hook into Object::extend after control + * being delegated to extensions. Each callback will be reset + * once called. + * + * @param string $method The name of the method to hook into + * @param callable $callback The callback to execute + */ + protected function afterExtending($method, $callback) { + if(empty($this->afterExtendCallbacks[$method])) { + $this->afterExtendCallbacks[$method] = array(); + } + $this->afterExtendCallbacks[$method][] = $callback; + } + /** * An implementation of the factory method, allows you to create an instance of a class * @@ -929,6 +975,14 @@ abstract class Object { public function extend($method, &$a1=null, &$a2=null, &$a3=null, &$a4=null, &$a5=null, &$a6=null, &$a7=null) { $values = array(); + if(!empty($this->beforeExtendCallbacks[$method])) { + foreach(array_reverse($this->beforeExtendCallbacks[$method]) as $callback) { + $value = call_user_func($callback, $a1, $a2, $a3, $a4, $a5, $a6, $a7); + if($value !== null) $values[] = $value; + } + $this->beforeExtendCallbacks[$method] = array(); + } + if($this->extension_instances) foreach($this->extension_instances as $instance) { if(method_exists($instance, $method)) { $instance->setOwner($this); @@ -938,6 +992,14 @@ abstract class Object { } } + if(!empty($this->afterExtendCallbacks[$method])) { + foreach(array_reverse($this->afterExtendCallbacks[$method]) as $callback) { + $value = call_user_func($callback, $a1, $a2, $a3, $a4, $a5, $a6, $a7); + if($value !== null) $values[] = $value; + } + $this->afterExtendCallbacks[$method] = array(); + } + return $values; } diff --git a/docs/en/changelogs/3.1.0.md b/docs/en/changelogs/3.1.0.md index 4b3ea8780..f038bcd8c 100644 --- a/docs/en/changelogs/3.1.0.md +++ b/docs/en/changelogs/3.1.0.md @@ -455,3 +455,6 @@ you can enable those warnings and future-proof your code already. * Hard limit displayed pages in the CMS tree to `500`, and the number of direct children to `250`, to avoid excessive resource usage. Configure through `Hierarchy.node_threshold_total` and ` Hierarchy.node_threshold_leaf`. Set to `0` to show tree unrestricted. + * `Object` now has `beforeExtending` and `afterExtending` to inject behaviour around method extension. + `DataObject` also has `beforeUpdateCMSFields` to insert fields between automatic scaffolding and extension + by `updateCMSFields`. See the [DataExtension Reference](/reference/dataextension) for more information. diff --git a/docs/en/reference/dataextension.md b/docs/en/reference/dataextension.md index bbb0da421..bea26555f 100644 --- a/docs/en/reference/dataextension.md +++ b/docs/en/reference/dataextension.md @@ -78,6 +78,57 @@ The `$`fields parameter is passed by reference, as it is an object. $fields->push(new UploadField('Image', 'Profile Image')); } +### Adding/modifying fields prior to extensions + +User code can intervene in the process of extending cms fields by using `beforeUpdateCMSFields` +in its implementation of `getCMSFields`. This can be useful in cases where user code will add +fields to a dataobject that should be present in the `$fields` parameter when passed to +`updateCMSFields` in extensions. + +This method is preferred to disabling, enabling, and calling cms field extensions manually. + + :::php + function getCMSFields() { + $this->beforeUpdateCMSFields(function($fields) { + // Include field which must be present when updateCMSFields is called on extensions + $fields->addFieldToTab("Root.Main", new TextField('Detail', 'Details', null, 255)); + }); + + $fields = parent::getCMSFields(); + // ... additional fields here + return $fields; + } + +### Object extension injection points + +`Object` now has two additional methods, `beforeExtending` and `afterExtending`, each of which takes a +method name and a callback to be executed immediately before and after `Object::extend()` is called on +extensions. + +This is useful in many cases where working with modules such as `Translatable` which operate on +`DataObject` fields that must exist in the `FieldList` at the time that `$this->extend('UpdateCMSFields')` +is called. + +
+Please note that each callback is only ever called once, and then cleared, so multiple extensions +to the same function require that a callback is registered each time, if necessary. +
+ +Example: A class that wants to control default values during object initialisation. The code +needs to assign a value if not specified in self::$defaults, but before extensions have been called: + + :::php + function __construct() { + $self = $this; + $this->beforeExtending('populateDefaults', function() uses ($self) { + if(empty($self->MyField)) { + $self->MyField = 'Value we want as a default if not specified in $defaults, but set before extensions'; + } + }); + parent::__construct(); + } + + ### Custom database generation Some extensions are designed to transparently add more sophisticated data-collection capabilities to your data object. diff --git a/docs/en/reference/dataobject.md b/docs/en/reference/dataobject.md index 3b26d4b6c..8bf8ba877 100644 --- a/docs/en/reference/dataobject.md +++ b/docs/en/reference/dataobject.md @@ -63,6 +63,8 @@ data management interfaces with very little custom coding. You can also alter the fields of built-in and module `DataObject` classes through your own `[DataExtension](/reference/dataextension)`, and a call to `[api:DataExtension->updateCMSFields()]`. +`[api::DataObject->beforeUpdateCMSFields()]` can also be used to interact with and add to automatically +scaffolded fields prior to being passed to extensions (See `[DataExtension](/reference/dataextension)`). ### Searchable Fields diff --git a/model/DataObject.php b/model/DataObject.php index 090067037..b20398381 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -1999,6 +1999,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $fs->getFieldList(); } + /** + * Allows user code to hook into DataObject::getCMSFields prior to updateCMSFields + * being called on extensions + * + * @param callable $callback The callback to execute + */ + protected function beforeUpdateCMSFields($callback) { + $this->beforeExtending('updateCMSFields', $callback); + } + /** * Centerpiece of every data administration interface in Silverstripe, * which returns a {@link FieldList} suitable for a {@link Form} object. diff --git a/tests/model/DataExtensionTest.php b/tests/model/DataExtensionTest.php index a00ebb41f..e289ee717 100644 --- a/tests/model/DataExtensionTest.php +++ b/tests/model/DataExtensionTest.php @@ -8,6 +8,9 @@ class DataExtensionTest extends SapphireTest { 'DataExtensionTest_Player', 'DataExtensionTest_RelatedObject', 'DataExtensionTest_MyObject', + 'DataExtensionTest_CMSFieldsBase', + 'DataExtensionTest_CMSFieldsChild', + 'DataExtensionTest_CMSFieldsGrandchild' ); protected $requiredExtensions = array( @@ -156,6 +159,61 @@ class DataExtensionTest extends SapphireTest { $this->assertEquals("hello world", $mo->testMethodApplied()); $this->assertEquals("hello world", $do->testMethodApplied()); } + + public function testPageFieldGeneration() { + $page = new DataExtensionTest_CMSFieldsBase(); + $fields = $page->getCMSFields(); + $this->assertNotEmpty($fields); + + // Check basic field exists + $this->assertNotEmpty($fields->dataFieldByName('PageField')); + } + + public function testPageExtensionsFieldGeneration() { + $page = new DataExtensionTest_CMSFieldsBase(); + $fields = $page->getCMSFields(); + $this->assertNotEmpty($fields); + + // Check extending fields exist + $this->assertNotEmpty($fields->dataFieldByName('ExtendedFieldRemove')); // Not removed yet! + $this->assertNotEmpty($fields->dataFieldByName('ExtendedFieldKeep')); + } + + public function testSubpageFieldGeneration() { + $page = new DataExtensionTest_CMSFieldsChild(); + $fields = $page->getCMSFields(); + $this->assertNotEmpty($fields); + + // Check extending fields exist + $this->assertEmpty($fields->dataFieldByName('ExtendedFieldRemove')); // Removed by child class + $this->assertNotEmpty($fields->dataFieldByName('ExtendedFieldKeep')); + $this->assertNotEmpty($preExtendedField = $fields->dataFieldByName('ChildFieldBeforeExtension')); + $this->assertEquals($preExtendedField->Title(), 'ChildFieldBeforeExtension: Modified Title'); + + // Post-extension fields + $this->assertNotEmpty($fields->dataFieldByName('ChildField')); + } + + public function testSubSubpageFieldGeneration() { + $page = new DataExtensionTest_CMSFieldsGrandchild(); + $fields = $page->getCMSFields(); + $this->assertNotEmpty($fields); + + // Check extending fields exist + $this->assertEmpty($fields->dataFieldByName('ExtendedFieldRemove')); // Removed by child class + $this->assertNotEmpty($fields->dataFieldByName('ExtendedFieldKeep')); + + // Check child fields removed by grandchild in beforeUpdateCMSFields + $this->assertEmpty($fields->dataFieldByName('ChildFieldBeforeExtension')); // Removed by grandchild class + + // Check grandchild field modified by extension + $this->assertNotEmpty($preExtendedField = $fields->dataFieldByName('GrandchildFieldBeforeExtension')); + $this->assertEquals($preExtendedField->Title(), 'GrandchildFieldBeforeExtension: Modified Title'); + + // Post-extension fields + $this->assertNotEmpty($fields->dataFieldByName('ChildField')); + $this->assertNotEmpty($fields->dataFieldByName('GrandchildField')); + } } class DataExtensionTest_Member extends DataObject implements TestOnly { @@ -313,3 +371,93 @@ DataExtensionTest_MyObject::add_extension('DataExtensionTest_Ext1'); DataExtensionTest_MyObject::add_extension('DataExtensionTest_Ext2'); DataExtensionTest_MyObject::add_extension('DataExtensionTest_Faves'); +/** + * Base class for CMS fields + */ +class DataExtensionTest_CMSFieldsBase extends DataObject implements TestOnly { + + private static $db = array( + 'PageField' => 'Varchar(255)' + ); + + private static $extensions = array( + 'DataExtensionTest_CMSFieldsBaseExtension' + ); + + public function getCMSFields() { + $fields = parent::getCMSFields(); + $fields->addFieldToTab('Root.Test', new TextField('PageField')); + return $fields; + } +} + +/** + * Extension to top level test class, tests that updateCMSFields work + */ +class DataExtensionTest_CMSFieldsBaseExtension extends DataExtension implements TestOnly { + private static $db = array( + 'ExtendedFieldKeep' => 'Varchar(255)', + 'ExtendedFieldRemove' => 'Varchar(255)' + ); + + public function updateCMSFields(FieldList $fields) { + $fields->addFieldToTab('Root.Test', new TextField('ExtendedFieldRemove')); + $fields->addFieldToTab('Root.Test', new TextField('ExtendedFieldKeep')); + + if($childField = $fields->dataFieldByName('ChildFieldBeforeExtension')) { + $childField->setTitle('ChildFieldBeforeExtension: Modified Title'); + } + + if($grandchildField = $fields->dataFieldByName('GrandchildFieldBeforeExtension')) { + $grandchildField->setTitle('GrandchildFieldBeforeExtension: Modified Title'); + } + } +} + +/** + * Second level test class. + * Tests usage of beforeExtendingCMSFields + */ +class DataExtensionTest_CMSFieldsChild extends DataExtensionTest_CMSFieldsBase implements TestOnly { + private static $db = array( + 'ChildField' => 'Varchar(255)', + 'ChildFieldBeforeExtension' => 'Varchar(255)' + ); + + public function getCMSFields() { + $this->beforeExtending('updateCMSFields', function(FieldList $fields) { + $fields->addFieldToTab('Root.Test', new TextField('ChildFieldBeforeExtension')); + }); + + $this->afterExtending('updateCMSFields', function(FieldList $fields){ + $fields->removeByName('ExtendedFieldRemove', true); + }); + + $fields = parent::getCMSFields(); + $fields->addFieldToTab('Root.Test', new TextField('ChildField')); + return $fields; + } +} + +/** + * Third level test class, testing that beforeExtendingCMSFields can be nested + */ +class DataExtensionTest_CMSFieldsGrandchild extends DataExtensionTest_CMSFieldsChild implements TestOnly { + private static $db = array( + 'GrandchildField' => 'Varchar(255)' + ); + + public function getCMSFields() { + $this->beforeUpdateCMSFields(function(FieldList $fields) { + // Remove field from parent's beforeExtendingCMSFields + $fields->removeByName('ChildFieldBeforeExtension', true); + + // Adds own pre-extension field + $fields->addFieldToTab('Root.Test', new TextField('GrandchildFieldBeforeExtension')); + }); + + $fields = parent::getCMSFields(); + $fields->addFieldToTab('Root.Test', new TextField('GrandchildField')); + return $fields; + } +}