浏览代码

Added sonata_type_model_autocomplete form type

Added support for many-to-many in sonata_type_model_autocomplete

Changed `collection` form type to `sonata_type_native_collection`

Fixed unit tests

Fixed issues with Sf 2.3

Fixed dates in changelog

Fixed minor issues

Make code more universal.

Another improvements. Fixed bugs in paging.

Added toStringCallback option. Fixed datagrid parameters.

Fixed unit tests

Improved code coverage of Datagrid

Improved doc and added some more tests

Fixed CS
Andrej Hudec 11 年之前
父节点
当前提交
3d86eca666

+ 7 - 0
CHANGELOG.md

@@ -1,6 +1,10 @@
 CHANGELOG
 =========
 
+### 2014-08-08
+ * added new form type ``sonata_type_model_autocomplete``
+ * changed ``collection`` form type to ``sonata_type_native_collection``
+
 ### 2013-12-27
 
 * [BC BREAK] Added KnpMenuBundle v2.x compatibility, ``buildSideMenu`` must now use the ``Admin::generateMenuUrl`` method to generate the route arguments for the KnpMenu options.
@@ -19,6 +23,9 @@ CHANGELOG
   If you do not extend the Admin class, you need to add this method to
   your admin.
 
+### 2013-10-26
+ * added new form type ``sonata_type_model_hidden``
+
 ### 2013-10-13
 
 * [BC BREAK] added ``setCurrentChild``, ``getCurrentChild`` to the AdminInterface

+ 142 - 0
Controller/HelperController.php

@@ -21,6 +21,9 @@ use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Validator\ValidatorInterface;
 use Sonata\AdminBundle\Admin\Pool;
 use Sonata\AdminBundle\Admin\AdminHelper;
+use Sonata\AdminBundle\Admin\AdminInterface;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+use Sonata\AdminBundle\Filter\FilterInterface;
 
 class HelperController
 {
@@ -293,4 +296,143 @@ class HelperController
 
         return new JsonResponse(array('status' => 'OK', 'content' => $content));
     }
+
+    /**
+     * Retrieve list of items for autocomplete form field
+     *
+     * @param Request $request
+     *
+     * @return JsonResponse
+     *
+     * @throws \RuntimeException
+     * @throws AccessDeniedException
+     */
+    public function retrieveAutocompleteItemsAction(Request $request)
+    {
+        $admin = $this->pool->getInstance($request->get('code'));
+        $admin->setRequest($request);
+
+        // check user permission
+        if (false === $admin->isGranted('LIST')) {
+            throw new AccessDeniedException();
+        }
+
+        // subject will be empty to avoid unnecessary database requests and keep autocomplete function fast
+        $admin->setSubject($admin->getNewInstance());
+
+        $fieldDescription = $this->retrieveFieldDescription($admin, $request->get('field'));
+        $formAutocomplete = $admin->getForm()->get($fieldDescription->getName());
+
+        if ($formAutocomplete->getConfig()->getAttribute('disabled')) {
+            throw new AccessDeniedException('Autocomplete list can`t be retrieved because the form element is disabled or read_only.');
+        }
+
+        $property           = $formAutocomplete->getConfig()->getAttribute('property');
+        $callback           = $formAutocomplete->getConfig()->getAttribute('callback');
+        $minimumInputLength = $formAutocomplete->getConfig()->getAttribute('minimum_input_length');
+        $itemsPerPage       = $formAutocomplete->getConfig()->getAttribute('items_per_page');
+        $reqParamPageNumber = $formAutocomplete->getConfig()->getAttribute('req_param_name_page_number');
+        $toStringCallback   = $formAutocomplete->getConfig()->getAttribute('to_string_callback');
+
+        $searchText = $request->get('q');
+
+        if (mb_strlen($searchText, 'UTF-8') < $minimumInputLength) {
+            return new JsonResponse(array('status' => 'KO', 'message' => 'Too short search string.', 403));
+        }
+
+        $targetAdmin = $fieldDescription->getAssociationAdmin();
+        $datagrid = $targetAdmin->getDatagrid();
+
+        if ($callback !== null) {
+            if (!is_callable($callback)) {
+                throw new \RuntimeException('Callback doesn`t contain callable function.');
+            }
+
+            call_user_func($callback, $datagrid, $property, $searchText);
+        } else {
+            if (is_array($property)) {
+                // multiple properties
+                foreach ($property as $prop) {
+                    if (!$datagrid->hasFilter($prop)) {
+                        throw new \RuntimeException(sprintf('To retrieve autocomplete items, you should add filter "%s" to "%s" in configureDatagridFilters() method.', $prop, get_class($targetAdmin)));
+                    }
+
+                    $filter = $datagrid->getFilter($prop);
+                    $filter->setCondition(FilterInterface::CONDITION_OR);
+
+                    $datagrid->setValue($prop, null, $searchText);
+                }
+            } else {
+                if (!$datagrid->hasFilter($property)) {
+                    throw new \RuntimeException(sprintf('To retrieve autocomplete items, you should add filter "%s" to "%s" in configureDatagridFilters() method.', $prop, get_class($targetAdmin)));
+                }
+
+                $datagrid->setValue($property, null, $searchText);
+            }
+        }
+
+        $datagrid->setValue('_per_page', null, $itemsPerPage);
+        $datagrid->setValue('_page', null, $request->query->get($reqParamPageNumber, 1));
+        $datagrid->buildPager();
+
+        $pager = $datagrid->getPager();
+
+        $items = array();
+        $results = $pager->getResults();
+
+        foreach ($results as $entity) {
+            if ($toStringCallback !== null) {
+                if (!is_callable($toStringCallback)) {
+                    throw new \RuntimeException('Option "to_string_callback" doesn`t contain callable function.');
+                }
+
+                $label = call_user_func($toStringCallback, $entity, $property);
+            } else {
+                $resultMetadata = $targetAdmin->getObjectMetadata($entity);
+                $label = $resultMetadata->getTitle();
+            }
+
+            $items[] = array(
+                'id'    => $admin->id($entity),
+                'label' => $label,
+            );
+        }
+
+        return new JsonResponse(array(
+            'status' => 'OK',
+            'more'   => !$pager->isLastPage(),
+            'items'  => $items
+        ));
+    }
+
+    /**
+     * Retrieve the field description given by field name.
+     *
+     * @param AdminInterface $admin
+     * @param string         $field
+     *
+     * @return \Symfony\Component\Form\FormInterface
+     *
+     * @throws \RuntimeException
+     */
+    private function retrieveFieldDescription(AdminInterface $admin, $field)
+    {
+        $admin->getFormFieldDescriptions();
+
+        $fieldDescription = $admin->getFormFieldDescription($field);
+
+        if (!$fieldDescription) {
+            throw new \RuntimeException(sprintf('The field "%s" does not exist.', $field));
+        }
+
+        if ($fieldDescription->getType() !== 'sonata_type_model_autocomplete') {
+            throw new \RuntimeException(sprintf('Unsupported form type "%s" for field "%s".', $fieldDescription->getType(), $field));
+        }
+
+        if (null === $fieldDescription->getTargetEntity()) {
+            throw new \RuntimeException(sprintf('No associated entity with field "%s".', $field));
+        }
+
+        return $fieldDescription;
+    }
 }

+ 16 - 2
Datagrid/Datagrid.php

@@ -130,8 +130,22 @@ class Datagrid implements DatagridInterface
             }
         }
 
-        $this->pager->setMaxPerPage(isset($this->values['_per_page']) ? $this->values['_per_page'] : 25);
-        $this->pager->setPage(isset($this->values['_page']) ? $this->values['_page'] : 1);
+        $maxPerPage = 25;
+        if (isset($this->values['_per_page']['value'])) {
+            $maxPerPage = $this->values['_per_page']['value'];
+        } elseif (isset($this->values['_per_page'])) {
+            $maxPerPage = $this->values['_per_page'];
+        }
+        $this->pager->setMaxPerPage($maxPerPage);
+
+        $page = 1;
+        if (isset($this->values['_page']['value'])) {
+            $page = $this->values['_page']['value'];
+        } elseif (isset($this->values['_page'])) {
+            $page = $this->values['_page'];
+        }
+        $this->pager->setPage($page);
+
         $this->pager->setQuery($this->query);
         $this->pager->init();
 

+ 120 - 0
Form/DataTransformer/ModelToIdPropertyTransformer.php

@@ -0,0 +1,120 @@
+<?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\Form\DataTransformer;
+
+use Symfony\Component\Form\DataTransformerInterface;
+use Sonata\AdminBundle\Model\ModelManagerInterface;
+use Doctrine\Common\Util\ClassUtils;
+use RuntimeException;
+
+/**
+ * Transform object to ID and property label
+ *
+ * @author Andrej Hudec <pulzarraider@gmail.com>
+ */
+class ModelToIdPropertyTransformer implements DataTransformerInterface
+{
+    protected $modelManager;
+
+    protected $className;
+
+    protected $property;
+
+    protected $multiple;
+
+    protected $toStringCallback;
+
+    /**
+     * @param ModelManagerInterface $modelManager
+     * @param string                $className
+     * @param string                $property
+     */
+    public function __construct(ModelManagerInterface $modelManager, $className, $property, $multiple=false, $toStringCallback=null)
+    {
+        $this->modelManager     = $modelManager;
+        $this->className        = $className;
+        $this->property         = $property;
+        $this->multiple         = $multiple;
+        $this->toStringCallback = $toStringCallback;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function reverseTransform($value)
+    {
+        $collection = $this->modelManager->getModelCollectionInstance($this->className);
+
+        if (empty($value) || empty($value['identifiers'])) {
+            if (!$this->multiple) {
+                return null;
+            } else {
+                return $collection;
+            }
+        }
+
+        if (!$this->multiple) {
+             return $this->modelManager->find($this->className, current($value['identifiers']));
+        }
+
+        foreach ($value['identifiers'] as $id) {
+            $collection->add($this->modelManager->find($this->className, $id));
+        }
+
+        return $collection;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function transform($entityOrCollection)
+    {
+        $result = array('identifiers' => array(), 'labels' => array());
+
+        if (!$entityOrCollection) {
+            return $result;
+        }
+        if ($entityOrCollection instanceof \ArrayAccess) {
+            $collection = $entityOrCollection;
+        } else {
+            $collection = array($entityOrCollection);
+        }
+
+        if (empty($this->property)) {
+            throw new RuntimeException('Please define "property" parameter.');
+        }
+
+        foreach ($collection as $entity) {
+            $id  = current($this->modelManager->getIdentifierValues($entity));
+
+            if ($this->toStringCallback !== null) {
+                if (!is_callable($this->toStringCallback)) {
+                    throw new RuntimeException('Callback in "to_string_callback" option doesn`t contain callable function.');
+                }
+
+                $label = call_user_func($this->toStringCallback, $entity, $this->property);
+            } else {
+                try {
+                    $label = (string) $entity;
+                } catch (\Exception $e) {
+                    throw new RuntimeException(sprintf("Unable to convert the entity %s to String, entity must have a '__toString()' method defined", ClassUtils::getClass($entity)), 0, $e);
+                }
+            }
+
+            $result['identifiers'][] = $id;
+            $result['labels'][] = $label;
+        }
+
+        return $result;
+    }
+}

+ 6 - 0
Form/FormMapper.php

@@ -70,6 +70,12 @@ class FormMapper extends BaseGroupedMapper
              $fieldName = str_replace('.', '__', $fieldName);
         }
 
+        // change `collection` to `sonata_type_native_collection` form type to
+        // avoid BC break problems
+        if ($type == 'collection') {
+            $type = 'sonata_type_native_collection';
+        }
+
         $label = $fieldName;
 
         $group = $this->addFieldToCurrentGroup($label);

+ 40 - 0
Form/Type/CollectionType.php

@@ -0,0 +1,40 @@
+<?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\Form\Type;
+
+use Symfony\Component\Form\AbstractType;
+
+/**
+ * This type wrap native `collection` form type and render `add` and `delete`
+ * buttons in standard Symfony` collection form type.
+ *
+ * @author Andrej Hudec <pulzarraider@gmail.com>
+ */
+class CollectionType extends AbstractType
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function getParent()
+    {
+        return 'collection';
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getName()
+    {
+        return 'sonata_type_native_collection';
+    }
+}

+ 119 - 0
Form/Type/ModelAutocompleteType.php

@@ -0,0 +1,119 @@
+<?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\Form\Type;
+
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\OptionsResolver\OptionsResolverInterface;
+use Symfony\Component\Form\FormView;
+use Symfony\Component\Form\FormInterface;
+use Sonata\AdminBundle\Form\DataTransformer\ModelToIdPropertyTransformer;
+
+/**
+ * This type defines a standard text field with autocomplete feature.
+ *
+ * @author Andrej Hudec <pulzarraider@gmail.com>
+ * @author Florent Denis <dflorent.pokap@gmail.com>
+ */
+class ModelAutocompleteType extends AbstractType
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function buildForm(FormBuilderInterface $builder, array $options)
+    {
+        $builder->addViewTransformer(new ModelToIdPropertyTransformer($options['model_manager'], $options['class'], $options['property'], $options['multiple'], $options['to_string_callback']), true);
+
+        $builder->add('title', 'text', array('attr'=>array('class'=>'span5'), 'property_path' => '[labels][0]'));
+        $builder->add('identifiers', 'collection', array('type'=>'hidden', 'allow_add' => true, 'allow_delete' => true));
+
+        $builder->setAttribute('property', $options['property']);
+        $builder->setAttribute('callback', $options['callback']);
+        $builder->setAttribute('minimum_input_length', $options['minimum_input_length']);
+        $builder->setAttribute('items_per_page', $options['items_per_page']);
+        $builder->setAttribute('req_param_name_page_number', $options['req_param_name_page_number']);
+        $builder->setAttribute('disabled', $options['disabled'] || $options['read_only']);
+        $builder->setAttribute('to_string_callback', $options['to_string_callback']);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function buildView(FormView $view, FormInterface $form, array $options)
+    {
+        $view->vars['placeholder'] = $options['placeholder'];
+        $view->vars['multiple'] = $options['multiple'];
+        $view->vars['minimum_input_length'] = $options['minimum_input_length'];
+        $view->vars['items_per_page'] = $options['items_per_page'];
+
+        // ajax parameters
+        $view->vars['url'] = $options['url'];
+        $view->vars['route'] = $options['route'];
+        $view->vars['req_params'] = $options['req_params'];
+        $view->vars['req_param_name_search'] = $options['req_param_name_search'];
+        $view->vars['req_param_name_page_number'] = $options['req_param_name_page_number'];
+        $view->vars['req_param_name_items_per_page'] = $options['req_param_name_items_per_page'];
+
+        // dropdown list css class
+        $view->vars['dropdown_css_class'] = $options['dropdown_css_class'];
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDefaultOptions(OptionsResolverInterface $resolver)
+    {
+        $resolver->setDefaults(array(
+            'compound'                        => true,
+            'model_manager'                   => null,
+            'class'                           => null,
+            'callback'                        => null,
+            'multiple'                        => false,
+
+            'placeholder'                     => '',
+            'minimum_input_length'            => 3, //minimum 3 chars should be typed to load ajax data
+            'items_per_page'                  => 10, //number of items per page
+
+            'to_string_callback'              => null,
+
+            // ajax parameters
+            'url'                             => '',
+            'route'                           => array('name'=>'sonata_admin_retrieve_autocomplete_items', 'parameters'=>array()),
+            'req_params'                      => array(),
+            'req_param_name_search'           => 'q',
+            'req_param_name_page_number'      => '_page',
+            'req_param_name_items_per_page'   => '_per_page',
+
+            // dropdown list css class
+            'dropdown_css_class'              => 'sonata-autocomplete-dropdown',
+        ));
+
+        $resolver->setRequired(array('property'));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getParent()
+    {
+        return 'form';
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getName()
+    {
+        return 'sonata_type_model_autocomplete';
+    }
+}

+ 8 - 0
Resources/config/form_types.xml

@@ -27,6 +27,14 @@
             <tag name="form.type" alias="sonata_type_model_hidden" />
         </service>
 
+        <service id="sonata.admin.form.type.model_autocomplete" class="Sonata\AdminBundle\Form\Type\ModelAutocompleteType">
+            <tag name="form.type" alias="sonata_type_model_autocomplete" />
+        </service>
+
+        <service id="sonata.admin.form.type.collection" class="Sonata\AdminBundle\Form\Type\CollectionType">
+            <tag name="form.type" alias="sonata_type_native_collection" />
+        </service>
+
         <!-- Form Extension -->
         <service id="sonata.admin.form.extension.field" class="Sonata\AdminBundle\Form\Extension\Field\Type\FormTypeFieldExtension">
             <tag name="form.type_extension" alias="form" />

+ 4 - 0
Resources/config/routing/sonata_admin.xml

@@ -35,4 +35,8 @@
     <route id="sonata_admin_search" pattern="/search">
         <default key="_controller">SonataAdminBundle:Core:search</default>
     </route>
+
+    <route id="sonata_admin_retrieve_autocomplete_items" pattern="/core/get-autocomplete-items">
+        <default key="_controller">sonata.admin.controller.admin:retrieveAutocompleteItemsAction</default>
+    </route>
 </routes>

+ 132 - 7
Resources/doc/reference/form_types.rst

@@ -116,6 +116,127 @@ class
   calculated from the linked Admin class. You usually should not need to set
   this manually.
 
+sonata_type_model_autocomplete
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Setting a field type of ``sonata_type_model_autocomplete`` will use an instance of
+``ModelAutocompleteType`` to render that field. This Type allows you to choose an existing
+entity from the linked model class. In effect it shows a list of options from
+which you can choose a value. The list of options is loaded dynamically
+with ajax after typing 3 chars (autocomplete). It is best for entities with many
+items.
+
+This field type works by default if the related entity has an admin instance and
+in the related entity datagrid is a string filter on the ``property`` field.
+
+For example, we have an entity class called ``Article`` (in the ``ArticleAdmin``)
+which has a field called ``category`` which maps a relationship to another entity
+class called ``Category``. All we need to do now is add a reference for this field
+in our ``ArticleAdmin`` class and make sure, that in the CategoryAdmin exists
+datagrid filter for the property ``title``.
+
+.. code-block:: php
+
+    class ArticleAdmin extends Admin
+    {
+        protected function configureFormFields(FormMapper $formMapper)
+        {
+            // the dropdown autocomplete list will show only Category entities that contains specified text in "title" attribute
+            $formMapper
+                ->add('category', 'sonata_type_model_autocomplete', array('property'=>'title'))
+            ;
+        }
+    }
+
+    class CategoryAdmin extends Admin
+    {
+        protected function configureDatagridFilters(DatagridMapper $datagridMapper)
+        {
+            // this text filter will be used to retrieve autocomplete fields
+            $formMapper
+                ->add('title')
+            ;
+        }
+    }
+
+The available options are:
+
+property
+  defaults to null. You have to set this to designate which field (or a list of fields) to use for the choice values.
+  This value can be string or array of strings.
+
+class
+  The entity class managed by this field. Defaults to null, but is actually
+  calculated from the linked Admin class. You usually should not need to set
+  this manually.
+
+model_manager
+  defaults to null, but is actually calculated from the linked Admin class.
+  You usually should not need to set this manually.
+
+callback
+  defaults to null. Callable function that can be used to modify the query which is used to retrieve autocomplete items.
+
+.. code-block:: php
+
+    $formMapper
+        ->add('category', 'sonata_type_model_autocomplete', array(
+            'property'=>'title',
+            'callback' => function ($datagrid, $property, $value) {
+                $queryBuilder = $datagrid->getQuery();
+                $queryBuilder->andWhere($queryBuilder->getRootAlias() . '.enabled=1 ');
+                $datagrid->setValue($property, null, $value);
+            },
+        )
+    );
+
+to_string_callback
+  defaults to null. Callable function that can be used to change the default toString behaviour of entity.
+
+.. code-block:: php
+
+    $formMapper
+        ->add('category', 'sonata_type_model_autocomplete', array(
+            'property'=>'title',
+            'to_string_callback' => function($enitity, $property) {
+                return $enitity->getTitle();
+            },
+        )
+    );
+
+multiple
+  defaults to false. Set to true, if you`re field is in many-to-many relation.
+
+placeholder
+  defaults to "". Placeholder is shown when no item is selected.
+
+minimum_input_length
+  defaults to 3. Minimum number of chars that should be typed to load ajax data.
+
+items_per_page
+  defaults to 10. Number of items per one ajax request.
+
+url
+  defaults to "". Target external remote url for ajax requests.
+  You usually should not need to set this manually.
+
+route
+  The route ``name`` with ``parameters`` that is used as target url for ajax
+  requests.
+
+dropdown_css_class
+  defaults to "sonata-autocomplete-dropdown". CSS class of dropdown list.
+
+req_param_name_search
+  defaults to "q". Ajax request parameter name which contains the searched text.
+
+req_param_name_page_number
+  defaults to "_page". Ajax request parameter name which contains the page number.
+
+req_param_name_items_per_page
+  defaults to "_per_page".  Ajax request parameter name which contains the limit of
+  items per page.
+
 sonata_type_admin
 ^^^^^^^^^^^^^^^^^
 
@@ -225,19 +346,23 @@ btn_add and btn_catalogue:
 **TIP**: A jQuery event is fired after a row has been added (``sonata-admin-append-form-element``).
 You can listen to this event to trigger custom javascript (eg: add a calendar widget to a newly added date field)
 
-collection
-^^^^^^^^^^
+sonata_type_native_collection (previously collection)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-This bundle handle the symfony ``collection`` form type by adding:
+This bundle handle the native symfony ``collection`` form type by adding:
 
 * an ``add`` button if you set the ``allow_add`` option to ``true``.
 * a ``delete`` button if you set the ``allow_delete`` option to ``true``.
 
-**TIP**: A jQuery event is fired after a row has been added (``sonata-admin-append-form-element``).
-You can listen to this event to trigger custom javascript (eg: add a calendar widget to a newly added date field)
+.. TIP::
+
+    A jQuery event is fired after a row has been added (``sonata-admin-append-form-element``).
+    You can listen to this event to trigger custom javascript (eg: add a calendar widget to a newly added date field)
+
+.. TIP::
 
-**TIP**: A jQuery event is fired after a row has been added (``sonata-collection-item-added``)
-or deleted (``sonata-collection-item-deleted``). You can listen to these events to trigger custom javascript.
+    A jQuery event is fired after a row has been added (``sonata-collection-item-added``)
+    or deleted (``sonata-collection-item-deleted``). You can listen to these events to trigger custom javascript.
 
 FieldDescription options
 ^^^^^^^^^^^^^^^^^^^^^^^^

+ 131 - 6
Resources/views/Form/form_admin_fields.html.twig

@@ -184,7 +184,7 @@ file that was distributed with this source code.
     {% endif %}
 {% endblock form_row %}
 
-{% block collection_widget_row %}
+{% block sonata_type_native_collection_widget_row %}
 {% spaceless %}
     <div class="sonata-collection-row">
         {% if allow_delete %}
@@ -193,18 +193,18 @@ file that was distributed with this source code.
         {{ form_row(child) }}
     </div>
 {% endspaceless %}
-{% endblock %}
+{% endblock sonata_type_native_collection_widget_row %}
 
-{% block collection_widget %}
+{% block sonata_type_native_collection_widget %}
 {% spaceless %}
     {% if prototype is defined %}
         {% set child = prototype %}
-        {% set attr = attr|merge({'data-prototype': block('collection_widget_row'), 'data-prototype-name': prototype.vars.name, 'class': attr.class|default('') }) %}
+        {% set attr = attr|merge({'data-prototype': block('sonata_type_native_collection_widget_row'), 'data-prototype-name': prototype.vars.name, 'class': attr.class|default('') }) %}
     {% endif %}
     <div {{ block('widget_container_attributes') }}>
         {{ form_errors(form) }}
         {% for child in form %}
-            {{ block('collection_widget_row') }}
+            {{ block('sonata_type_native_collection_widget_row') }}
         {% endfor %}
         {{ form_rest(form) }}
         {% if allow_add %}
@@ -212,7 +212,7 @@ file that was distributed with this source code.
         {% endif %}
     </div>
 {% endspaceless %}
-{% endblock collection_widget %}
+{% endblock sonata_type_native_collection_widget %}
 
 {% block sonata_type_immutable_array_widget %}
     {% spaceless %}
@@ -251,3 +251,128 @@ file that was distributed with this source code.
         </div>
     {% endspaceless %}
 {% endblock %}
+
+{% block sonata_type_model_autocomplete_widget %}
+{% spaceless %}
+
+    {{ form_widget(form.title) }}
+
+    {% for child in form %}
+        {% if not child.rendered %}
+            {{ form_widget(child) }}
+        {% endif %}
+    {% endfor %}
+
+    <script>
+        (function ($) {
+            var autocompleteInput = $("#{{ form.title.vars.id }}");
+            autocompleteInput.select2({
+                placeholder: "{{ placeholder }}",
+                allowClear: {{ required ? 'false' : 'true' }},
+                enable: {{ disabled ? 'false' : 'true' }},
+                readonly: {{ read_only ? 'true' : 'false' }},
+                minimumInputLength: {{ minimum_input_length }},
+                multiple: {{ multiple ? 'true' : 'false' }},
+                ajax: {
+                    url:  "{{ url ?: url(route.name, route.parameters|default([])) }}",
+                    dataType: 'json',
+                    quietMillis: 100,
+                    data: function (term, page) { // page is the one-based page number tracked by Select2
+                        return {
+                                //search term
+                                "{{ req_param_name_search }}": term,
+
+                                // page size
+                                "{{ req_param_name_items_per_page }}": {{ items_per_page }},
+
+                                // page number
+                                "{{ req_param_name_page_number }}": page,
+
+                                // admin
+                                'uniqid': "{{ sonata_admin.admin.root.uniqid }}",
+                                'code':   "{{ sonata_admin.admin.root.code }}",
+                                'field':  "{{ name }}"
+
+                                // other parameters
+                                {% if req_params is not empty %},
+                                    {%- for key, value in req_params -%}
+                                        "{{- key|e('js') -}}": "{{- value|e('js') -}}"
+                                        {%- if not loop.last -%}, {% endif -%}
+                                    {%- endfor -%}
+                                {% endif %}
+                            };
+                    },
+                    results: function (data, page) {
+                        // notice we return the value of more so Select2 knows if more results can be loaded
+                        return {results: data.items, more: data.more};
+                    }
+                },
+                initSelection: function(element, callback) {
+                    //remove initial text from input
+                    $(element).val("");
+
+                    var data = [];
+                    {% if multiple -%}
+                        data = [
+                        {%- for key, label_text in value.labels -%}
+                            {id:{{ value.identifiers[key]|e('js') }}, label:'{{ label_text|e('js') }}'}
+                            {%- if not loop.last -%}, {% endif -%}
+                        {%- endfor -%}
+                        ];
+                    {%- elseif value.labels[0] is defined -%}
+                        data = {id: {{ value.identifiers[0]|e('js') }}, label:'{{ value.labels[0]|e('js') }}'};
+                    {%- endif  %}
+
+                    callback(data);
+                },
+                formatResult: function (item) {
+                    return {% block sonata_type_model_autocomplete_dropdown_item_format %}'<div class="sonata-autocomplete-dropdown-item">'+item.label+'</div>'{% endblock %};// format of one dropdown item
+                },
+                formatSelection: function (item) {
+                    return {% block sonata_type_model_autocomplete_selection_format %}item.label{% endblock %};// format selected item '<b>'+item.label+'</b>';
+                },
+                dropdownCssClass: "{{ dropdown_css_class }}",
+                escapeMarkup: function (m) { return m; } // we do not want to escape markup since we are displaying html in results
+            });
+
+            autocompleteInput.on("change", function(e) {
+
+                // console.log("change "+JSON.stringify({val:e.val, added:e.added, removed:e.removed}));
+
+                // add new input
+                var el = null;
+                if (undefined !== e.added) {
+
+                    var addedItems = e.added;
+
+                    if(!$.isArray(addedItems)) {
+                        addedItems = [addedItems];
+                    }
+
+                    var length = addedItems.length;
+                    for (var i = 0; i < length; i++) {
+                        el = addedItems[i];
+                        $("#{{ form.identifiers.vars.id }}").append('<input type="hidden" name="{{ form.identifiers.vars.full_name }}[]" value="'+el.id+'" />');
+                    }
+                }
+
+                // remove input
+                if (undefined !== e.removed) {
+                    var removedItems = e.removed;
+
+                    if(!$.isArray(removedItems)) {
+                        removedItems = [removedItems];
+                    }
+
+                    var length = removedItems.length;
+                    for (var i = 0; i < length; i++) {
+                        el = removedItems[i];
+                        $('#{{ form.identifiers.vars.id }} input:hidden[value="'+el.id+'"]').remove();
+                    }
+                }
+            });
+
+        })(jQuery);
+    </script>
+{% endspaceless %}
+{% endblock sonata_type_model_autocomplete_widget %}

+ 81 - 0
Tests/Datagrid/DatagridTest.php

@@ -332,6 +332,16 @@ class DatagridTest extends \PHPUnit_Framework_TestCase
             ->method('isSortable')
             ->will($this->returnValue(true));
 
+        $this->pager->expects($this->once())
+            ->method('setMaxPerPage')
+            ->with($this->equalTo('25'))
+            ->will($this->returnValue(null));
+
+        $this->pager->expects($this->once())
+            ->method('setPage')
+            ->with($this->equalTo('1'))
+            ->will($this->returnValue(null));
+
         $this->datagrid = new Datagrid($this->query, $this->columns, $this->pager, $this->formBuilder, array('_sort_by'=>$sortBy));
 
         $filter = $this->getMock('Sonata\AdminBundle\Filter\FilterInterface');
@@ -360,4 +370,75 @@ class DatagridTest extends \PHPUnit_Framework_TestCase
         $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_page'));
         $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_per_page'));
     }
+
+    public function testBuildPagerWithPage()
+    {
+        $sortBy = $this->getMock('Sonata\AdminBundle\Admin\FieldDescriptionInterface');
+        $sortBy->expects($this->once())
+            ->method('isSortable')
+            ->will($this->returnValue(true));
+
+        $this->pager->expects($this->once())
+            ->method('setMaxPerPage')
+            ->with($this->equalTo('50'))
+            ->will($this->returnValue(null));
+
+        $this->pager->expects($this->once())
+            ->method('setPage')
+            ->with($this->equalTo('3'))
+            ->will($this->returnValue(null));
+
+        $this->datagrid = new Datagrid($this->query, $this->columns, $this->pager, $this->formBuilder, array('_sort_by'=>$sortBy, '_page'=>3, '_per_page'=>50));
+
+        $filter = $this->getMock('Sonata\AdminBundle\Filter\FilterInterface');
+        $filter->expects($this->once())
+            ->method('getName')
+            ->will($this->returnValue('foo'));
+        $filter->expects($this->any())
+            ->method('getFormName')
+            ->will($this->returnValue('fooFormName'));
+        $filter->expects($this->any())
+            ->method('isActive')
+            ->will($this->returnValue(false));
+        $filter->expects($this->any())
+            ->method('getRenderSettings')
+            ->will($this->returnValue(array('foo', array('bar'=>'baz'))));
+
+        $this->datagrid->addFilter($filter);
+
+        $this->datagrid->buildPager();
+
+        $this->assertEquals(array('_sort_by'=>$sortBy, 'foo'=>null, '_page' => 3, '_per_page' => 50), $this->datagrid->getValues());
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('fooFormName'));
+        $this->assertEquals(array('bar' => 'baz'), $this->formBuilder->get('fooFormName')->getOptions());
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_sort_by'));
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_sort_order'));
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_page'));
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_per_page'));
+    }
+
+    public function testBuildPagerWithPage2()
+    {
+        $this->pager->expects($this->once())
+            ->method('setMaxPerPage')
+            ->with($this->equalTo('50'))
+            ->will($this->returnValue(null));
+
+        $this->pager->expects($this->once())
+            ->method('setPage')
+            ->with($this->equalTo('3'))
+            ->will($this->returnValue(null));
+
+        $this->datagrid = new Datagrid($this->query, $this->columns, $this->pager, $this->formBuilder, array());
+        $this->datagrid->setValue('_per_page', null, 50);
+        $this->datagrid->setValue('_page', null, 3);
+
+        $this->datagrid->buildPager();
+
+        $this->assertEquals(array('_page' => array('type'=>null, 'value'=>3), '_per_page' => array('type'=>null, 'value'=>50)), $this->datagrid->getValues());
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_sort_by'));
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_sort_order'));
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_page'));
+        $this->assertInstanceOf('Symfony\Component\Form\FormBuilder', $this->formBuilder->get('_per_page'));
+    }
 }

+ 5 - 0
Tests/Fixtures/Entity/Foo.php

@@ -27,4 +27,9 @@ class Foo
     {
         $this->baz = $baz;
     }
+
+    public function __toString()
+    {
+        return (string) $this->bar;
+    }
 }

+ 191 - 0
Tests/Form/DataTransformer/ModelToIdPropertyTransformerTest.php

@@ -0,0 +1,191 @@
+<?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\Form\DataTransformer;
+
+use Sonata\AdminBundle\Form\DataTransformer\ModelToIdPropertyTransformer;
+use Sonata\AdminBundle\Tests\Fixtures\Entity\Foo;
+use Doctrine\Common\Collections\ArrayCollection;
+
+class ModelToIdPropertyTransformerTest extends \PHPUnit_Framework_TestCase
+{
+    private $modelManager = null;
+
+    public function setUp()
+    {
+        $this->modelManager = $this->getMock('Sonata\AdminBundle\Model\ModelManagerInterface');
+    }
+
+    public function testReverseTransform()
+    {
+        $transformer = new ModelToIdPropertyTransformer($this->modelManager, 'Sonata\AdminBundle\Tests\Fixtures\Entity\Foo', 'bar', false);
+
+        $entity = new Foo();
+        $entity->setBar('example');
+
+        $this->modelManager
+            ->expects($this->any())
+            ->method('find')
+            ->with($this->equalTo('Sonata\AdminBundle\Tests\Fixtures\Entity\Foo'), $this->equalTo(123))
+            ->will($this->returnValue($entity));
+
+        $this->assertNull($transformer->reverseTransform(null));
+        $this->assertNull($transformer->reverseTransform(false));
+        $this->assertNull($transformer->reverseTransform(12));
+        $this->assertEquals($entity, $transformer->reverseTransform(array('identifiers' => array(123), 'titles' => array('example'))));
+    }
+
+    /**
+     * @dataProvider getReverseTransformMultipleTests
+     */
+    public function testReverseTransformMultiple($expected, $params, $entity1, $entity2, $entity3)
+    {
+        $transformer = new ModelToIdPropertyTransformer($this->modelManager, 'Sonata\AdminBundle\Tests\Fixtures\Entity\Foo', 'bar', true);
+
+        $this->modelManager
+            ->expects($this->any())
+            ->method('find')
+            ->will($this->returnCallback(function ($className, $value) use ($entity1, $entity2, $entity3) {
+                if ($className != 'Sonata\AdminBundle\Tests\Fixtures\Entity\Foo') {
+                    return null;
+                }
+
+                if ($value == 123) {
+                    return $entity1;
+                }
+
+                if ($value == 456) {
+                    return $entity2;
+                }
+
+                if ($value == 789) {
+                    return $entity3;
+                }
+
+                return null;
+            }));
+
+        $collection = new ArrayCollection();
+        $this->modelManager
+            ->expects($this->any())
+            ->method('getModelCollectionInstance')
+            ->with($this->equalTo('Sonata\AdminBundle\Tests\Fixtures\Entity\Foo'))
+            ->will($this->returnValue($collection));
+
+        $result = $transformer->reverseTransform($params);
+        $this->assertInstanceOf('Doctrine\Common\Collections\ArrayCollection', $result);
+        $this->assertEquals($expected, $result->getValues());
+    }
+
+    public function getReverseTransformMultipleTests()
+    {
+        $entity1 = new Foo();
+        $entity1->setBaz(123);
+        $entity1->setBar('example');
+
+        $entity2 = new Foo();
+        $entity2->setBaz(456);
+        $entity2->setBar('example2');
+
+        $entity3 = new Foo();
+        $entity3->setBaz(789);
+        $entity3->setBar('example3');
+
+        return array(
+            array(array(), null, $entity1, $entity2, $entity3),
+            array(array(), false, $entity1, $entity2, $entity3),
+            array(array(), true, $entity1, $entity2, $entity3),
+            array(array(), 12, $entity1, $entity2, $entity3),
+            array(array($entity1), array('identifiers' => array(123), 'titles' => array('example')), $entity1, $entity2, $entity3),
+            array(array($entity1, $entity2, $entity3), array('identifiers' => array(123, 456, 789), 'titles' => array('example', 'example2', 'example3')), $entity1, $entity2, $entity3),
+        );
+    }
+
+    public function testTransform()
+    {
+        $entity = new Foo();
+        $entity->setBar('example');
+
+        $this->modelManager->expects($this->once())
+            ->method('getIdentifierValues')
+            ->will($this->returnValue(array(123)));
+
+        $transformer = new ModelToIdPropertyTransformer($this->modelManager, 'Sonata\AdminBundle\Tests\Fixtures\Entity\Foo', 'bar', false);
+
+        $this->assertEquals(array('identifiers' => array(), 'labels' => array()), $transformer->transform(null));
+        $this->assertEquals(array('identifiers' => array(), 'labels' => array()), $transformer->transform(false));
+        $this->assertEquals(array('identifiers' => array(), 'labels' => array()), $transformer->transform(0));
+        $this->assertEquals(array('identifiers' => array(), 'labels' => array()), $transformer->transform('0'));
+
+        $this->assertEquals(array('identifiers' => array(123), 'labels' => array('example')), $transformer->transform($entity));
+    }
+
+    public function testTransformToStringCallback()
+    {
+        $entity = new Foo();
+        $entity->setBar('example');
+        $entity->setBaz('bazz');
+
+        $this->modelManager->expects($this->once())
+            ->method('getIdentifierValues')
+            ->will($this->returnValue(array(123)));
+
+        $transformer = new ModelToIdPropertyTransformer($this->modelManager, 'Sonata\AdminBundle\Tests\Fixtures\Entity\Foo', 'bar', false, function ($entity) {
+            return $entity->getBaz();
+        });
+
+        $this->assertEquals(array('identifiers' => array(123), 'labels' => array('bazz')), $transformer->transform($entity));
+    }
+
+    public function testTransformMultiple()
+    {
+        $entity1 = new Foo();
+        $entity1->setBar('foo');
+
+        $entity2 = new Foo();
+        $entity2->setBar('bar');
+
+        $entity3 = new Foo();
+        $entity3->setBar('baz');
+
+        $collection = new ArrayCollection();
+        $collection[] = $entity1;
+        $collection[] = $entity2;
+        $collection[] = $entity3;
+
+        $this->modelManager->expects($this->exactly(3))
+            ->method('getIdentifierValues')
+            ->will($this->returnCallback(function ($value) use ($entity1, $entity2, $entity3) {
+                if ($value == $entity1) {
+                    return array(123);
+                }
+
+                if ($value == $entity2) {
+                    return array(456);
+                }
+
+                if ($value == $entity3) {
+                    return array(789);
+                }
+
+                return array(999);
+            }));
+
+        $transformer = new ModelToIdPropertyTransformer($this->modelManager, 'Sonata\AdminBundle\Tests\Fixtures\Entity\Foo', 'bar', true);
+
+        $this->assertEquals(array('identifiers' => array(), 'labels' => array()), $transformer->transform(null));
+        $this->assertEquals(array('identifiers' => array(), 'labels' => array()), $transformer->transform(false));
+        $this->assertEquals(array('identifiers' => array(), 'labels' => array()), $transformer->transform(0));
+        $this->assertEquals(array('identifiers' => array(), 'labels' => array()), $transformer->transform('0'));
+
+        $this->assertEquals(array('identifiers' => array(123, 456, 789), 'labels' => array('foo', 'bar', 'baz')), $transformer->transform($collection));
+    }
+}

+ 50 - 0
Tests/Form/Type/ModelAutocompleteTypeTest.php

@@ -0,0 +1,50 @@
+<?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\Form\Type;
+
+use Sonata\AdminBundle\Form\Type\ModelAutocompleteType;
+
+use Symfony\Component\Form\Tests\Extension\Core\Type\TypeTestCase;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class ModelAutocompleteTypeTest extends TypeTestCase
+{
+    public function testGetDefaultOptions()
+    {
+        $type = new ModelAutocompleteType();
+        $modelManager = $this->getMock('Sonata\AdminBundle\Model\ModelManagerInterface');
+        $optionResolver = new OptionsResolver();
+
+        $type->setDefaultOptions($optionResolver);
+
+        $options = $optionResolver->resolve(array('model_manager' => $modelManager, 'class' => 'Foo', 'property'=>'bar'));
+
+        $this->assertTrue($options['compound']);
+        $this->assertInstanceOf('Sonata\AdminBundle\Model\ModelManagerInterface', $options['model_manager']);
+        $this->assertEquals($modelManager, $options['model_manager']);
+        $this->assertEquals('Foo', $options['class']);
+        $this->assertEquals('bar', $options['property']);
+        $this->assertNull($options['callback']);
+
+        $this->assertEquals('', $options['placeholder']);
+        $this->assertEquals(3, $options['minimum_input_length']);
+        $this->assertEquals(10, $options['items_per_page']);
+
+        $this->assertEquals('', $options['url']);
+        $this->assertEquals(array('name'=>'sonata_admin_retrieve_autocomplete_items', 'parameters'=>array()), $options['route']);
+        $this->assertEquals(array(), $options['req_params']);
+        $this->assertEquals('q', $options['req_param_name_search']);
+        $this->assertEquals('_page', $options['req_param_name_page_number']);
+        $this->assertEquals('_per_page', $options['req_param_name_items_per_page']);
+        $this->assertEquals('sonata-autocomplete-dropdown', $options['dropdown_css_class']);
+    }
+}