Browse Source

Merge pull request #1352 from sjopet/extensions

added feature to wire extensions dynamically using configuration
Thomas 12 years ago
parent
commit
c636abcd9d

+ 108 - 0
DependencyInjection/Compiler/ExtensionCompilerPass.php

@@ -40,5 +40,113 @@ class ExtensionCompilerPass implements CompilerPassInterface
             $container->getDefinition($target)
                 ->addMethodCall('addExtension', array(new Reference($id)));
         }
+
+        $extensionConfig = $container->getParameter('sonata.admin.extension.map');
+        $extensionMap = $this->flattenExtensionConfiguration($extensionConfig);
+
+        foreach ($container->findTaggedServiceIds('sonata.admin') as $id => $attributes) {
+            $admin = $container->getDefinition($id);
+            $extensions = $this->getExtensionsForAdmin($id, $admin, $container, $extensionMap);
+
+            foreach ($extensions as $extension) {
+                if(!$container->hasDefinition($extension)){
+                    throw new \InvalidArgumentException(sprintf('Unable to find extension service for id %s', $extension));
+                }
+                $admin->addMethodCall('addExtension', array(new Reference($extension)));
+            }
+        }
+    }
+
+    /**
+     * @param string $id
+     * @param Definition $admin
+     * @param ContainerBuilder $container
+     * @param array $extensionMap
+     * @return array
+     */
+    protected function getExtensionsForAdmin($id, Definition $admin, ContainerBuilder $container, array $extensionMap)
+    {
+        $extensions = array();
+        $class = $classReflection = $subjectReflection = null;
+
+        $excludes = $extensionMap['excludes'];
+        unset($extensionMap['excludes']);
+
+        foreach ($extensionMap as $type => $subjects) {
+            foreach ($subjects as $subject => $extensionList) {
+
+                if('admins' == $type){
+                    if($id == $subject){
+                        $extensions = array_merge($extensions, $extensionList);
+                    }
+                } else {
+                    $class = $this->getManagedClass($admin, $container);
+                    $classReflection = new \ReflectionClass($class);
+                    $subjectReflection = new \ReflectionClass($subject);
+                }
+
+                if('instanceof' == $type){
+                    if($subjectReflection->getName() == $classReflection->getName() || $classReflection->isSubclassOf($subject)){
+                        $extensions = array_merge($extensions, $extensionList);
+                    }
+                }
+
+                if('implements' == $type){
+                    if($classReflection->implementsInterface($subject)){
+                        $extensions = array_merge($extensions, $extensionList);
+                    }
+                }
+
+                if('extends' == $type){
+                    if($classReflection->isSubclassOf($subject)){
+                        $extensions = array_merge($extensions, $extensionList);
+                    }
+                }
+            }
+        }
+
+        if(isset($excludes[$id])){
+            $extensions = array_diff($extensions, $excludes);
+        }
+        return $extensions;
+    }
+
+    /**
+     * Resolves the class argument of the admin to an actual class (in case of %parameter%)
+     *
+     * @param Definition $admin
+     * @param ContainerBuilder $container
+     * @return string
+     */
+    protected function getManagedClass(Definition $admin, ContainerBuilder $container)
+    {
+        return $container->getParameterBag()->resolveValue($admin->getArgument(1));
+    }
+
+    /**
+     * @param array $config
+     * @return array
+     */
+    protected function flattenExtensionConfiguration(array $config)
+    {
+        $extensionMap = array(
+            'excludes'      => array(),
+            'admins'        => array(),
+            'implements'    => array(),
+            'extends'       => array(),
+            'instanceof'    => array(),
+        );
+
+        foreach ($config as $extension => $options) {
+            foreach ($options as $key => $value) {
+                foreach ($value as $source) {
+                    if(!isset($extensionMap[$key][$source])){
+                        $extensionMap[$key][$source] = array();
+                    }
+                    array_push($extensionMap[$key][$source], $extension);
+                }
+            }
+        }
+        return $extensionMap;
     }
 }

+ 24 - 0
DependencyInjection/Configuration.php

@@ -145,6 +145,30 @@ class Configuration implements ConfigurationInterface
                     ->end()
                 ->end()
 
+                ->arrayNode('extensions')
+                ->useAttributeAsKey('id')
+                ->defaultValue(array('admins' => array(), 'excludes' => array(), 'implements' => array(), 'extends' => array(), 'instanceof' => array()))
+                    ->prototype('array')
+                        ->children()
+                            ->arrayNode('admins')
+                                ->prototype('scalar')->end()
+                            ->end()
+                            ->arrayNode('excludes')
+                                ->prototype('scalar')->end()
+                            ->end()
+                            ->arrayNode('implements')
+                                ->prototype('scalar')->end()
+                            ->end()
+                            ->arrayNode('extends')
+                                ->prototype('scalar')->end()
+                            ->end()
+                            ->arrayNode('instanceof')
+                                ->prototype('scalar')->end()
+                            ->end()
+                        ->end()
+                    ->end()
+                ->end()
+
                 ->scalarNode('persist_filters')->defaultValue(false)->cannotBeEmpty()->end()
 
             ->end()

+ 2 - 0
DependencyInjection/SonataAdminExtension.php

@@ -101,6 +101,8 @@ class SonataAdminExtension extends Extension
 
         $loader->load('security.xml');
 
+        $container->setParameter('sonata.admin.extension.map', $config['extensions']);
+
         /**
          * This is a work in progress, so for now it is hardcoded
          */

+ 93 - 0
Resources/doc/reference/extensions.rst

@@ -0,0 +1,93 @@
+Extensions
+==========
+
+Admin extensions allow you to add or change features of one or more Admin instances. To create an extension your class
+must implement the interface ``Sonata\AdminBundle\Admin\AdminExtensionInterface`` and be registered as a service. The
+interface defines a number of functions which you can use to customize the edit form, list view, form validation and
+other admin features.
+
+.. code-block:: php
+
+    use Sonata\AdminBundle\Admin\AdminExtension;
+    use Sonata\AdminBundle\Form\FormMapper;
+
+    class PublishStatusAdminExtension extends AdminExtension
+    {
+        public function configureFormFields(FormMapper $formMapper)
+        {
+            $formMapper->add('status', 'choice', array(
+                'choices' => array(
+                    'draft' => 'Draft',
+                    'published' => 'Published',
+                ),
+            ));
+        }
+    }
+
+Configuration
+~~~~~~~~~~~~~
+
+There are two ways to configure your extensions and connect them to an admin.
+
+You can include this information in the service definition of your extension.
+Add the tag *sonata.admin.extension* and use the *target* attribute to point to the admin you want to modify.
+
+.. code-block:: yaml
+
+    services:
+        acme.demo.publish.extension:
+            class: Acme\Demo\BlogBundle\Admin\Extension\PublishStatusAdminExtension
+            tags:
+                - { name: sonata.admin.extension, target: acme.demo.admin.article }
+
+The second option is to add it to your config.yml file.
+
+.. code-block:: yaml
+
+    # app/config/config.yml
+        sonata_admin:
+            extensions:
+                acme.demo.publish.extension:
+                    admins:
+                        - acme.demo.admin.article
+
+Using the config.yml file has some advantages, it allows you to keep your configuration centralized and it provides some
+extra options you can use to wire your extensions in a more dynamic way. This means you can change the behaviour of all
+admins that manage a class of a specific type.
+
+| **admins**
+| specify one or more admin service id's to which the Extension should be added
+
+| **excludes**
+| specify one or more admin service id's to which the Extension should not be added
+
+| **implements**
+| specify one or more interfaces. If the managed class of an admin implements one of the specified interfaces the
+| extension will be added to that admin.
+
+| **extends**
+| specify one or more classes. If the managed class of an admin extends one of the specified classes the extension
+| will be added to that admin.
+
+| **instanceof**
+| specify one or more classes. If the managed class of an admin extends one of the specified classes or is an instance
+| of that class the extension will be added to that admin.
+
+.. code-block:: yaml
+
+    # app/config/config.yml
+        sonata_admin:
+            extensions:
+                acme.demo.publish.extension:
+                    admins:
+                        - acme.demo.admin.article
+                    implements:
+                        - Acme\Demo\Publish\PublishStatusInterface
+                    excludes:
+                        - acme.demo.admin.blog
+                        - acme.demo.admin.news
+                    extends:
+                        - Acme\Demo\Document\Blog
+                    instanceof:
+                        -  Acme\Demo\Document\Page
+

+ 328 - 0
Tests/DependencyInjection/Compiler/ExtensionCompilerPassTest.php

@@ -0,0 +1,328 @@
+<?php
+
+/*
+ * This file is part of the Sonata package.
+ *
+ * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Sonata\AdminBundle\Tests\DependencyInjection;
+
+use Sonata\AdminBundle\DependencyInjection\SonataAdminExtension;
+use Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Sonata\AdminBundle\Admin\Admin;
+
+class ExtensionCompilerPassTest extends \PHPUnit_Framework_TestCase
+{
+    /** @var SonataAdminExtension $extension */
+    private $extension;
+
+    /** @var array $config */
+    private $config;
+
+    private $publishExtension;
+    private $historyExtension;
+    private $orderExtension;
+
+    /**
+     * Root name of the configuration
+     *
+     * @var string
+     */
+    private $root;
+
+    public function setUp()
+    {
+        parent::setUp();
+
+        $this->extension = new SonataAdminExtension();
+        $this->config    = $this->getConfig();
+        $this->root      = "sonata.admin";
+
+        $this->publishExtension = $this->getMock('Sonata\AdminBundle\Admin\AdminExtensionInterface');
+        $this->historyExtension = $this->getMock('Sonata\AdminBundle\Admin\AdminExtensionInterface');
+        $this->orderExtension = $this->getMock('Sonata\AdminBundle\Admin\AdminExtensionInterface');
+    }
+
+    /**
+     * @covers Sonata\AdminBundle\DependencyInjection\SonataAdminExtension::load
+     */
+    public function testAdminExtensionLoad()
+    {
+        $this->extension->load(array(), $container = $this->getContainer());
+
+        $this->assertTrue($container->hasParameter($this->root . ".extension.map"));
+        $this->assertTrue(is_array($extensionMap = $container->getParameter($this->root . ".extension.map")));
+
+        $this->assertArrayHasKey('admins', $extensionMap);
+        $this->assertArrayHasKey('excludes', $extensionMap);
+        $this->assertArrayHasKey('implements', $extensionMap);
+        $this->assertArrayHasKey('extends', $extensionMap);
+        $this->assertArrayHasKey('instanceof', $extensionMap);
+    }
+
+    /**
+     * @covers Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass::flattenExtensionConfiguration
+     */
+    public function testFlattenEmptyExtensionConfiguration()
+    {
+        $this->extension->load(array(), $container = $this->getContainer());
+        $extensionMap = $container->getParameter($this->root . ".extension.map");
+
+        $method = new \ReflectionMethod(
+                  'Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass', 'flattenExtensionConfiguration'
+                );
+
+        $method->setAccessible(TRUE);
+        $extensionMap = $method->invokeArgs(new ExtensionCompilerPass(), array($extensionMap));
+
+        $this->assertArrayHasKey('admins', $extensionMap);
+        $this->assertArrayHasKey('excludes', $extensionMap);
+        $this->assertArrayHasKey('implements', $extensionMap);
+        $this->assertArrayHasKey('extends', $extensionMap);
+        $this->assertArrayHasKey('instanceof', $extensionMap);
+
+        $this->assertEmpty($extensionMap['admins']);
+        $this->assertEmpty($extensionMap['excludes']);
+        $this->assertEmpty($extensionMap['implements']);
+        $this->assertEmpty($extensionMap['extends']);
+        $this->assertEmpty($extensionMap['instanceof']);
+    }
+
+    /**
+     * @covers Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass::flattenExtensionConfiguration
+     */
+    public function testFlattenExtensionConfiguration()
+    {
+        $config = $this->getConfig();
+        $this->extension->load(array($config), $container = $this->getContainer());
+        $extensionMap = $container->getParameter($this->root . ".extension.map");
+
+        $method = new \ReflectionMethod(
+                  'Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass', 'flattenExtensionConfiguration'
+                );
+
+        $method->setAccessible(TRUE);
+        $extensionMap = $method->invokeArgs(new ExtensionCompilerPass(), array($extensionMap));
+
+        // Admins
+        $this->assertArrayHasKey('admins', $extensionMap);
+        $this->assertCount(1, $extensionMap['admins']);
+
+        $this->assertContains('sonata_extension_publish', $extensionMap['admins']['sonata_post_admin']);
+        $this->assertCount(1, $extensionMap['admins']['sonata_post_admin']);
+
+        // Excludes
+        $this->assertArrayHasKey('excludes', $extensionMap);
+        $this->assertCount(2, $extensionMap['excludes']);
+
+        $this->assertArrayHasKey('sonata_article_admin', $extensionMap['excludes']);
+        $this->assertCount(1, $extensionMap['excludes']['sonata_article_admin']);
+        $this->assertContains('sonata_extension_history', $extensionMap['excludes']['sonata_article_admin']);
+
+        $this->assertArrayHasKey('sonata_post_admin', $extensionMap['excludes']);
+        $this->assertCount(1, $extensionMap['excludes']['sonata_post_admin']);
+        $this->assertContains('sonata_extension_order', $extensionMap['excludes']['sonata_post_admin']);
+
+        // Implements
+        $this->assertArrayHasKey('implements', $extensionMap);
+        $this->assertCount(1, $extensionMap['implements']);
+
+        $this->assertArrayHasKey('Sonata\AdminBundle\Tests\DependencyInjection\Publishable', $extensionMap['implements']);
+        $this->assertCount(2, $extensionMap['implements']['Sonata\AdminBundle\Tests\DependencyInjection\Publishable']);
+        $this->assertContains('sonata_extension_publish', $extensionMap['implements']['Sonata\AdminBundle\Tests\DependencyInjection\Publishable']);
+        $this->assertContains('sonata_extension_order', $extensionMap['implements']['Sonata\AdminBundle\Tests\DependencyInjection\Publishable']);
+
+        // Extends
+        $this->assertArrayHasKey('extends', $extensionMap);
+        $this->assertCount(1, $extensionMap['extends']);
+
+        $this->assertArrayHasKey('Sonata\AdminBundle\Tests\DependencyInjection\Post', $extensionMap['extends']);
+        $this->assertCount(1, $extensionMap['extends']['Sonata\AdminBundle\Tests\DependencyInjection\Post']);
+        $this->assertContains('sonata_extension_order', $extensionMap['extends']['Sonata\AdminBundle\Tests\DependencyInjection\Post']);
+
+        // Instanceof
+        $this->assertArrayHasKey('instanceof', $extensionMap);
+        $this->assertCount(1, $extensionMap['instanceof']);
+
+        $this->assertArrayHasKey('Sonata\AdminBundle\Tests\DependencyInjection\Post', $extensionMap['instanceof']);
+        $this->assertCount(1, $extensionMap['instanceof']['Sonata\AdminBundle\Tests\DependencyInjection\Post']);
+        $this->assertContains('sonata_extension_history', $extensionMap['instanceof']['Sonata\AdminBundle\Tests\DependencyInjection\Post']);
+    }
+
+    /**
+     * @covers Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass::process
+     * @expectedException \InvalidArgumentException
+     */
+    public function testProcessWithInvalidExtensionId()
+    {
+        $config = array(
+            'extensions' => array(
+                'sonata_extension_unknown' => array(
+                    'excludes' => array('sonata_article_admin'),
+                    'instanceof' => array('Sonata\AdminBundle\Tests\DependencyInjection\Post'),
+                ),
+            )
+        );
+
+        $container = $this->getContainer();
+        $this->extension->load(array($config), $container);
+
+        $extensionsPass = new ExtensionCompilerPass();
+        $extensionsPass->process($container);
+        $container->compile();
+    }
+
+    /**
+     * @covers Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass::process
+     */
+    public function testProcessWithInvalidAdminId()
+    {
+        $config = array(
+            'extensions' => array(
+                'sonata_extension_publish' => array(
+                    'admins' => array('sonata_unknown_admin'),
+                    'implements' => array('Sonata\AdminBundle\Tests\DependencyInjection\Publishable'),
+                ),
+            )
+        );
+
+        $container = $this->getContainer();
+        $this->extension->load(array($config), $container);
+
+        $extensionsPass = new ExtensionCompilerPass();
+        $extensionsPass->process($container);
+        $container->compile();
+
+        // nothing should fail the extension just isn't added to the 'sonata_unknown_admin'
+    }
+
+    /**
+     * @covers Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass::process
+     */
+    public function testProcess()
+    {
+        $container = $this->getContainer();
+        $this->extension->load(array($this->config), $container);
+
+        $extensionsPass = new ExtensionCompilerPass();
+        $extensionsPass->process($container);
+        $container->compile();
+
+        $this->assertTrue($container->hasDefinition('sonata_extension_publish'));
+        $this->assertTrue($container->hasDefinition('sonata_extension_history'));
+        $this->assertTrue($container->hasDefinition('sonata_extension_order'));
+
+        $this->assertTrue($container->hasDefinition('sonata_post_admin'));
+        $this->assertTrue($container->hasDefinition('sonata_article_admin'));
+        $this->assertTrue($container->hasDefinition('sonata_news_admin'));
+
+        $def = $container->get('sonata_post_admin');
+        $extensions = $def->getExtensions();
+        $this->assertCount(2, $extensions);
+
+        $this->assertInstanceOf(get_class($this->publishExtension), $extensions[0]);
+        $this->assertInstanceOf(get_class($this->historyExtension), $extensions[1]);
+
+        $def = $container->get('sonata_article_admin');
+        $extensions = $def->getExtensions();
+        $this->assertCount(2, $extensions);
+        $this->assertInstanceOf(get_class($this->publishExtension), $extensions[0]);
+        $this->assertInstanceOf(get_class($this->orderExtension), $extensions[1]);
+
+        $def = $container->get('sonata_news_admin');
+        $extensions = $def->getExtensions();
+        $this->assertCount(2, $extensions);
+        $this->assertInstanceOf(get_class($this->orderExtension), $extensions[0]);
+        $this->assertInstanceOf(get_class($this->historyExtension), $extensions[1]);
+    }
+
+    /**
+     * @return array
+     */
+    protected function getConfig()
+    {
+        $config = array(
+            'extensions' => array(
+                'sonata_extension_publish' => array(
+                    'admins' => array('sonata_post_admin'),
+                    'implements' => array('Sonata\AdminBundle\Tests\DependencyInjection\Publishable'),
+                ),
+                'sonata_extension_history' => array(
+                    'excludes' => array('sonata_article_admin'),
+                    'instanceof' => array('Sonata\AdminBundle\Tests\DependencyInjection\Post'),
+                ),
+                'sonata_extension_order' => array(
+                    'excludes' => array('sonata_post_admin'),
+                    'extends' => array('Sonata\AdminBundle\Tests\DependencyInjection\Post'),
+                    'implements' => array('Sonata\AdminBundle\Tests\DependencyInjection\Publishable'),
+                ),
+            )
+        );
+        return $config;
+    }
+
+    private function getContainer()
+    {
+        $container = new ContainerBuilder();
+        $container->setParameter('kernel.bundles', array());
+
+        // Add dependencies for SonataAdminBundle (these services will never get called so dummy classes will do)
+        $container
+            ->register('twig')
+            ->setClass('Symfony\Bundle\FrameworkBundle\Templating\EngineInterface');
+        $container
+            ->register('templating')
+            ->setClass('Symfony\Bundle\FrameworkBundle\Templating\EngineInterface');
+        $container
+            ->register('translator')
+            ->setClass('Symfony\Bundle\FrameworkBundle\Translation\TranslatorInterface');
+        $container
+            ->register('validator.validator_factory')
+            ->setClass('Symfony\Bundle\FrameworkBundle\Validator\ConstraintValidatorFactory');
+        $container
+            ->register('router')
+            ->setClass('Symfony\Component\Routing\RouterInterface');
+
+        // Add admin definition's
+        $container
+            ->register('sonata_post_admin')
+            ->setClass('Sonata\AdminBundle\Tests\DependencyInjection\MockAdmin')
+            ->setArguments(array('', 'Sonata\AdminBundle\Tests\DependencyInjection\Post', 'SonataAdminBundle:CRUD'))
+            ->addTag('sonata.admin');
+        $container
+            ->register('sonata_news_admin')
+            ->setClass('Sonata\AdminBundle\Tests\DependencyInjection\MockAdmin')
+            ->setArguments(array('', 'Sonata\AdminBundle\Tests\DependencyInjection\News', 'SonataAdminBundle:CRUD'))
+            ->addTag('sonata.admin');
+        $container
+            ->register('sonata_article_admin')
+            ->setClass('Sonata\AdminBundle\Tests\DependencyInjection\MockAdmin')
+            ->setArguments(array('', 'Sonata\AdminBundle\Tests\DependencyInjection\Article', 'SonataAdminBundle:CRUD'))
+            ->addTag('sonata.admin');
+
+        // Add admin extension definition's
+        $container
+            ->register('sonata_extension_publish')
+            ->setClass(get_class($this->publishExtension));
+        $container
+            ->register('sonata_extension_history')
+            ->setClass(get_class($this->historyExtension));
+        $container
+            ->register('sonata_extension_order')
+            ->setClass(get_class($this->orderExtension));
+
+        return $container;
+    }
+}
+
+class MockAdmin extends Admin {}
+
+class Post {}
+interface Publishable {}
+class News extends Post {}
+class Article implements Publishable {}