This commit is contained in:
Xes
2025-08-14 22:41:49 +02:00
parent 2de81ccc46
commit 8ce45119b6
39774 changed files with 4309466 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
Bootlint
========
The admin comes with `Bootlint`_ integration
since version 3.0. Bootlint is an HTML linter for Bootstrap projects.
You should use it when you want add some contributions on Sonata UI to check
the eventual Twitter Bootstrap conventions' mistakes.
Enable Bootlint
---------------
To use Bootlint in your admin, you can enable it in ``config.yml``.
.. configuration-block::
.. code-block:: yaml
sonata_admin:
options:
use_bootlint: true # enable Bootlint
Then open your browser debugger to look after some Bootlint warnings on console.
No warning? Congrats! Your page is fully Bootstrap compliant! ;)
.. _`Bootlint`: https://github.com/twbs/bootlint

View File

@@ -0,0 +1,274 @@
Creating a Custom Admin Action
==============================
This is a full working example of creating a custom list action for SonataAdmin.
The example is based on an existing ``CarAdmin`` class in an ``AppBundle``.
It is assumed you already have an admin service up and running.
The recipe
----------
SonataAdmin provides a very straight-forward way of adding your own custom actions.
To do this we need to:
- extend the ``SonataAdmin:CRUD`` Controller and tell our admin class to use it
- create the custom action in our Controller
- create a template to show the action in the list view
- add the route and the new action in the Admin class
Extending the Admin Controller
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
First you need to create your own Controller extending the one from SonataAdmin
.. code-block:: php
<?php
// src/AppBundle/Controller/CRUDController.php
namespace AppBundle\Controller;
use Sonata\AdminBundle\Controller\CRUDController as Controller;
class CRUDController extends Controller
{
// ...
}
Admin classes by default use the ``SonataAdmin:CRUD`` controller, this is the third parameter
of an admin service definition, you need to change it to your own.
Register the Admin as a Service
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Either by using XML:
.. code-block:: xml
<!-- src/AppBundle/Resources/config/admin.xml -->
<service id="app.admin.car" class="AppBundle\Admin\CarAdmin">
<tag name="sonata.admin" manager_type="orm" group="Demo" label="Car" />
<argument />
<argument>AppBundle\Entity\Car</argument>
<argument>AppBundle:CRUD</argument>
</service>
or by adding it to your ``admin.yml``:
.. code-block:: yaml
# src/AppBundle/Resources/config/admin.yml
services:
app.admin.car:
class: AppBundle\Admin\CarAdmin
tags:
- { name: sonata.admin, manager_type: orm, group: Demo, label: Car }
arguments:
- null
- AppBundle\Entity\Car
- AppBundle:CRUD
public: true
For more information about service configuration please refer to Step 3 of :doc:`../reference/getting_started`
Create the custom action in your Controller
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Now it is time to actually create your custom action here, for this example I chose
to implement a ``clone`` action.
.. code-block:: php
<?php
// src/AppBundle/Controller/CRUDController.php
namespace AppBundle\Controller;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Sonata\AdminBundle\Controller\CRUDController as Controller;
use Symfony\Component\HttpFoundation\RedirectResponse;
class CRUDController extends Controller
{
/**
* @param $id
*/
public function cloneAction($id)
{
$object = $this->admin->getSubject();
if (!$object) {
throw new NotFoundHttpException(sprintf('unable to find the object with id: %s', $id));
}
// Be careful, you may need to overload the __clone method of your object
// to set its id to null !
$clonedObject = clone $object;
$clonedObject->setName($object->getName().' (Clone)');
$this->admin->create($clonedObject);
$this->addFlash('sonata_flash_success', 'Cloned successfully');
return new RedirectResponse($this->admin->generateUrl('list'));
// if you have a filtered list and want to keep your filters after the redirect
// return new RedirectResponse($this->admin->generateUrl('list', array('filter' => $this->admin->getFilterParameters())));
}
}
Here we first get the object, see if it exists then clone it and insert the clone
as a new object. Finally we set a flash message indicating success and redirect to the list view.
If you want to add the current filter parameters to the redirect url you can add them to the `generateUrl` method:
.. code-block:: php
return new RedirectResponse($this->admin->generateUrl('list', array('filter' => $this->admin->getFilterParameters())));
Using template in new controller
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you want to render something here you can create new template anywhere, extend sonata layout
and use `sonata_admin_content` block.
.. code-block:: html+jinja
{% extends 'SonataAdminBundle::standard_layout.html.twig' %}
{% block sonata_admin_content %}
Your content here
{% endblock %}
Create a template for the new action
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
You need to tell SonataAdmin how to render your new action. You do that by
creating a ``list__action_clone.html.twig`` in the namespace of your custom
Admin Controller.
.. code-block:: html+jinja
{# src/AppBundle/Resources/views/CRUD/list__action_clone.html.twig #}
<a class="btn btn-sm" href="{{ admin.generateObjectUrl('clone', object) }}">clone</a>
Right now ``clone`` is not a known route, we define it in the next step.
Bringing it all together
^^^^^^^^^^^^^^^^^^^^^^^^
What is left now is actually adding your custom action to the admin class.
You have to add the new route in ``configureRoutes``:
.. code-block:: php
// ...
use Sonata\AdminBundle\Route\RouteCollection;
protected function configureRoutes(RouteCollection $collection)
{
$collection->add('clone', $this->getRouterIdParameter().'/clone');
}
This gives us a route like ``../admin/app/car/1/clone``.
You could also just write ``$collection->add('clone');`` to get a route like ``../admin/app/car/clone?id=1``
Next we have to add the action in ``configureListFields`` specifying the template we created.
.. code-block:: php
protected function configureListFields(ListMapper $listMapper)
{
$listMapper
// other fields...
->add('_action', null, array(
'actions' => array(
// ...
'clone' => array(
'template' => 'AppBundle:CRUD:list__action_clone.html.twig'
)
)
))
;
}
The full ``CarAdmin.php`` example looks like this:
.. code-block:: php
<?php
// src/AppBundle/Admin/CarAdmin.php
namespace AppBundle\Admin;
use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Route\RouteCollection;
use Sonata\AdminBundle\Show\ShowMapper;
class CarAdmin extends AbstractAdmin
{
protected function configureRoutes(RouteCollection $collection)
{
$collection->add('clone', $this->getRouterIdParameter().'/clone');
}
protected function configureDatagridFilters(DatagridMapper $datagridMapper)
{
// ...
}
protected function configureFormFields(FormMapper $formMapper)
{
// ...
}
protected function configureListFields(ListMapper $listMapper)
{
$listMapper
->addIdentifier('name')
->add('engine')
->add('rescueEngine')
->add('createdAt')
->add('_action', null, array(
'actions' => array(
'show' => array(),
'edit' => array(),
'delete' => array(),
'clone' => array(
'template' => 'AppBundle:CRUD:list__action_clone.html.twig'
)
)
));
}
protected function configureShowFields(ShowMapper $showMapper)
{
// ...
}
}
.. note::
If you want to render a custom controller action in a template by using the
render function in twig you need to add ``_sonata_admin`` as an attribute. For
example; ``{{ render(controller('AppBundle:XxxxCRUD:comment', {'_sonata_admin':
'sonata.admin.xxxx' })) }}``. This has to be done because the moment the
rendering should happen the routing, which usually sets the value of this
parameter, is not involved at all, and then you will get an error "There is no
_sonata_admin defined for the controller
AppBundle\Controller\XxxxCRUDController and the current route ' '."

View File

@@ -0,0 +1,108 @@
Customizing a mosaic list
=========================
Since version 3.0, the AdminBundle now include a mosaic list mode in order to have a more visual representation.
.. figure:: ../images/list_mosaic_default.png
:align: center
:alt: Default view
:width: 700px
It is possible to configure the default view by creating a dedicated template.
First, configure the ``outer_list_rows_mosaic`` template key:
.. code-block:: xml
<service id="sonata.media.admin.media" class="%sonata.media.admin.media.class%">
<tag name="sonata.admin" manager_type="orm" group="sonata_media" label_catalogue="%sonata.media.admin.media.translation_domain%" label="media" label_translator_strategy="sonata.admin.label.strategy.underscore" />
<argument />
<argument>%sonata.media.admin.media.entity%</argument>
<argument>%sonata.media.admin.media.controller%</argument>
<call method="setTemplates">
<argument type="collection">
<argument key="outer_list_rows_mosaic">SonataMediaBundle:MediaAdmin:list_outer_rows_mosaic.html.twig</argument>
</argument>
</call>
The ``list_outer_rows_mosaic.html.twig`` is the name of one mosaic's tile. You should also extends the template and overwrite the default blocks availables.
.. code-block:: jinja
{% extends 'SonataAdminBundle:CRUD:list_outer_rows_mosaic.html.twig' %}
{% block sonata_mosaic_background %}{{ meta.image }}{% endblock %}
{% block sonata_mosaic_default_view %}
<span class="label label-primary pull-right">{{ object.providerName|trans({}, 'SonataMediaBundle') }}</span>
{% endblock %}
{% block sonata_mosaic_hover_view %}
<span class="label label-primary pull-right">{{ object.providerName|trans({}, 'SonataMediaBundle') }}</span>
{% if object.width %} {{ object.width }}{% if object.height %}x{{ object.height }}{% endif %}px{% endif %}
{% if object.length > 0 %}
({{ object.length }})
{% endif %}
<br />
{% if object.authorname is not empty %}
{{ object.authorname }}
{% endif %}
{% if object.copyright is not empty and object.authorname is not empty %}
~
{% endif %}
{% if object.copyright is not empty %}
&copy; {{ object.copyright }}
{% endif %}
{% endblock %}
{% block sonata_mosaic_description %}
{% if admin.hasAccess('edit', object) and admin.hasRoute('edit') %}
<a href="{{ admin.generateUrl('edit', {'id' : object|sonata_urlsafeid(admin) }) }}">{{ meta.title|truncate(40) }}</a>
{% elseif admin.hasAccess('show', object) and admin.hasRoute('show') %}
<a href="{{ admin.generateUrl('show', {'id' : object|sonata_urlsafeid(admin) }) }}">{{ meta.title|truncate(40) }}</a>
{% else %}
{{ meta.title|truncate(40) }}
{% endif %}
{% endblock %}
Block types:
- ``sonata_mosaic_background``: this block is the background value defined in the ObjectMetadata object.
- ``sonata_mosaic_default_view``: this block is used when the list is displayed.
- ``sonata_mosaic_hover_view``: this block is used when the mouse is over the tile.
- ``sonata_mosaic_description``: this block will be always on screen and should represent the entity's name.
The ``ObjectMetadata`` object is returned by the related admin class, for instance the MediaBundle defines the method as:
.. code-block:: jinja
<?php
class MediaAdmin extends AbstractAdmin
{
// [...] others methods
public function getObjectMetadata($object)
{
$provider = $this->pool->getProvider($object->getProviderName());
$url = $provider->generatePublicUrl($object, $provider->getFormatName($object, 'admin'));
return new Metadata($object->getName(), $object->getDescription(), $url);
}
}
The final view will look like:
.. figure:: ../images/list_mosaic_custom.png
:align: center
:alt: Customize view
:width: 700px

View File

@@ -0,0 +1,139 @@
Using DataMapper to work with domain entities without per field setters
=======================================================================
This is an example of using DataMapper with entities that avoid setters/getters for each field.
Pre-requisites
--------------
- You already have SonataAdmin and DoctrineORM up and running.
- You already have an Entity class, in this example that class will be called ``Example``.
- You already have an Admin set up, in this example it's called ``ExampleAdmin``.
The recipe
----------
If there is a requirement for the entity to have domain specific methods instead of getters/setters for each
entity field then it won't work with SonataAdmin out of the box. But Symfony Form component provides ``DataMapper``
that can be used to make it work. Symfony itself lacks examples of using ``DataMapper`` but there is an article by
webmozart that covers it - https://webmozart.io/blog/2015/09/09/value-objects-in-symfony-forms/
Example Entity
^^^^^^^^^^^^^^
.. code-block:: php
<?php
// src/AppBundle/Entity/Example.php
namespace AppBundle\Entity;
class Example
{
private $name;
private $description;
public function __construct($name, $description)
{
$this->name = $name;
$this->description = $description;
}
public function update($description)
{
$this->description = $description
}
// rest of the code goes here
}
DataMapper
^^^^^^^^^^
To be able to set entity data without the possibility to use setters a ``DataMapper`` should be created.
.. code-block:: php
<?php
// src/AppBundle/Form/DataMapper/ExampleDataMapper.php
namespace AppBundle\Form\DataMapper;
use Symfony\Component\Form\DataMapperInterface;
use AppBundle\Entity\Example;
class ExampleDataMapper implements DataMapperInterface
{
/**
* @param Example $data
* @param FormInterface[]|\Traversable $forms
*/
public function mapDataToForms($data, $forms)
{
if (null !== $data) {
$forms = iterator_to_array($forms);
$forms['name']->setData($data->getName());
$forms['description']->setData($data->getDescription());
}
}
/**
* @param FormInterface[]|\Traversable $forms
* @param Example $data
*/
public function mapFormsToData($forms, &$data)
{
$forms = iterator_to_array($forms);
if (null === $data->getId()) {
$name = $forms['name']->getData();
$description = $forms['description']->getData();
// New entity is created
$data = new Example(
$name,
$description
);
} else {
$data->update(
$forms['description']->getData()
);
}
}
}
Admin class
^^^^^^^^^^^
Now we need to configure the form to use our ``ExampleDataMapper``.
.. code-block:: php
<?php
// src/AppBundle/Admin/ExampleAdmin.php
namespace AppBundle\Admin;
use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Form\FormMapper;
use AppBundle\Form\DataMapper\ExampleDataMapper;
class ExampleAdmin extends AbstractAdmin
{
protected function configureFormFields(FormMapper $formMapper)
{
$formMapper
->add('name', null)
->add('description', null);
;
$builder = $formMapper->getFormBuilder();
$builder->setDataMapper(new ExampleDataMapper());
}
// ...
}

View File

@@ -0,0 +1,45 @@
Deleting a Group of Fields from an Admin
========================================
In some cases, when you extend existing Admins, you might want to delete fields from the admin, or make them not show.
You could delete every field by hand, using the ``FormMapper``s ``remove`` method.
.. code-block:: php
class UserAdmin extends \Sonata\UserBundle\Admin\Model\UserAdmin {
protected function configureFormFields(FormMapper $formMapper)
{
parent::configureFormFields($formMapper);
$formMapper->remove('facebookName');
$formMapper->remove('twitterUid');
$formMapper->remove('twitterName');
$formMapper->remove('gplusUid');
$formMapper->remove('gplusName');
}
}
This works, as long as the extended Admin does not use Groups to organize it's field. In the above example, we try to remove all fields from the User Admin, that comes with the SonataUserBundle.
However, since the fields we deleted, are all part of the 'Social' Group of the form, the fields will be deleted and the empty group will stay.
For this case, the FormMapper comes with a method, which allows you to get rid of a whole form group: ``removeGroup``
.. code-block:: php
class UserAdmin extends \Sonata\UserBundle\Admin\Model\UserAdmin {
protected function configureFormFields(FormMapper $formMapper)
{
parent::configureFormFields($formMapper);
$formMapper->removeGroup('Social', 'User');
}
}
This will remove the whole 'Social' group from the form, which happens to contain all the fields, we deleted manually in the first example. The second argument is the name of the tab, the group belongs to.
This is optional. However, when not provided, it will be assumed that you mean the 'default' tab. If the group is on another tab, it won't be removed, when this is not provided.
There is a third optional argument for the method, which let's you choose, whether or not, tabs are also removed, if you happen to remove all groups of a tab. This behaviour is disabled by default, but
can be enabled, by setting the third argument of ``removeGroup`` to ``true``

View File

@@ -0,0 +1,49 @@
Modifying form fields dynamically depending on edited object
============================================================
It is a quite common situation when you need to modify your form's fields because
of edited object's properties or structure. Let us assume you only want to display
an admin form field for new objects and you do not want it to be shown for those
objects that have already been saved to the database and now are being edited.
This is a way for you to accomplish this.
In your ``Admin`` class' ``configureFormFields`` method you are able to get the
current object by calling ``$this->getSubject()``. The value returned will be your
linked model. And another method ``isCurrentRoute`` for check the current request's route.
Then, you should be able to dynamically add needed fields to the form:
.. code-block:: php
<?php
// src/AppBundle/Admin/PostAdmin
namespace AppBundle\Admin;
use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Form\FormMapper;
class PostAdmin extends AbstractAdmin
{
// ...
protected function configureFormFields(FormMapper $formMapper)
{
// Description field will always be added to the form:
$formMapper
->add('description', 'textarea')
;
$subject = $this->getSubject();
if ($subject->isNew()) {
// The thumbnail field will only be added when the edited item is created
$formMapper->add('thumbnail', 'file');
}
// Name field will be added only when create an item
if ($this->isCurrentRoute('create')) {
$formMapper->add('name', 'text');
}
}
}

View File

@@ -0,0 +1,335 @@
Uploading and saving documents (including images) using DoctrineORM and SonataAdmin
===================================================================================
This is a full working example of a file upload management method using
SonataAdmin with the DoctrineORM persistence layer.
Pre-requisites
--------------
- you already have SonataAdmin and DoctrineORM up and running
- you already have an Entity class that you wish to be able to connect uploaded
documents to, in this example that class will be called ``Image``.
- you already have an Admin set up, in this example it's called ``ImageAdmin``
- you understand file permissions on your web server and can manage the permissions
needed to allow your web server to upload and update files in the relevant
folder(s)
The recipe
----------
First we will cover the basics of what your Entity needs to contain to enable document
management with Doctrine. There is a good cookbook entry about
`uploading files with Doctrine and Symfony`_ on the Symfony website, so I will show
code examples here without going into the details. It is strongly recommended that
you read that cookbook first.
To get file uploads working with SonataAdmin we need to:
- add a file upload field to our ImageAdmin
- 'touch' the Entity when a new file is uploaded so its lifecycle events are triggered
Basic configuration - the Entity
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Following the guidelines from the Symfony cookbook, we have an Entity definition
that looks something like the YAML below (of course, you can achieve something
similar with XML or Annotation based definitions too). In this example we are using
the ``updated`` field to trigger the lifecycle callbacks by setting it based on the
upload timestamp.
.. configuration-block::
.. code-block:: yaml
# src/AppBundle/Resources/config/Doctrine/Image.orm.yml
AppBundleBundle\Entity\Image:
type: entity
repositoryClass: AppBundle\Entity\Repositories\ImageRepository
table: images
id:
id:
type: integer
generator: { strategy: AUTO }
fields:
filename:
type: string
length: 100
# changed when files are uploaded, to force preUpdate and postUpdate to fire
updated:
type: datetime
nullable: true
# ... other fields ...
lifecycleCallbacks:
prePersist: [ lifecycleFileUpload ]
preUpdate: [ lifecycleFileUpload ]
We then have the following methods in our ``Image`` class to manage file uploads:
.. code-block:: php
<?php
// src/AppBundle/Bundle/Entity/Image.php
const SERVER_PATH_TO_IMAGE_FOLDER = '/server/path/to/images';
/**
* Unmapped property to handle file uploads
*/
private $file;
/**
* Sets file.
*
* @param UploadedFile $file
*/
public function setFile(UploadedFile $file = null)
{
$this->file = $file;
}
/**
* Get file.
*
* @return UploadedFile
*/
public function getFile()
{
return $this->file;
}
/**
* Manages the copying of the file to the relevant place on the server
*/
public function upload()
{
// the file property can be empty if the field is not required
if (null === $this->getFile()) {
return;
}
// we use the original file name here but you should
// sanitize it at least to avoid any security issues
// move takes the target directory and target filename as params
$this->getFile()->move(
self::SERVER_PATH_TO_IMAGE_FOLDER,
$this->getFile()->getClientOriginalName()
);
// set the path property to the filename where you've saved the file
$this->filename = $this->getFile()->getClientOriginalName();
// clean up the file property as you won't need it anymore
$this->setFile(null);
}
/**
* Lifecycle callback to upload the file to the server
*/
public function lifecycleFileUpload()
{
$this->upload();
}
/**
* Updates the hash value to force the preUpdate and postUpdate events to fire
*/
public function refreshUpdated()
{
$this->setUpdated(new \DateTime());
}
// ... the rest of your class lives under here, including the generated fields
// such as filename and updated
When we upload a file to our Image, the file itself is transient and not persisted
to our database (it is not part of our mapping). However, the lifecycle callbacks
trigger a call to ``Image::upload()`` which manages the actual copying of the
uploaded file to the filesystem and updates the ``filename`` property of our Image,
this filename field *is* persisted to the database.
Most of the above is simply from the `uploading files with Doctrine and Symfony`_ cookbook
entry. It is highly recommended reading!
Basic configuration - the Admin class
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
We need to do two things in Sonata to enable file uploads:
1. Add a file upload widget
2. Ensure that the Image class' lifecycle events fire when we upload a file
Both of these are straightforward when you know what to do:
.. code-block:: php
<?php
// src/AppBundle/Admin/ImageAdmin.php
class ImageAdmin extends AbstractAdmin
{
protected function configureFormFields(FormMapper $formMapper)
{
$formMapper
->add('file', 'file', array(
'required' => false
))
// ...
;
}
public function prePersist($image)
{
$this->manageFileUpload($image);
}
public function preUpdate($image)
{
$this->manageFileUpload($image);
}
private function manageFileUpload($image)
{
if ($image->getFile()) {
$image->refreshUpdated();
}
}
// ...
}
We mark the ``file`` field as not required since we do not need the user to upload a
new image every time the Image is updated. When a file is uploaded (and nothing else
is changed on the form) there is no change to the data which Doctrine needs to persist
so no ``preUpdate`` event would fire. To deal with this we hook into SonataAdmin's
``preUpdate`` event (which triggers every time the edit form is submitted) and use
that to update an Image field which is persisted. This then ensures that Doctrine's
lifecycle events are triggered and our Image manages the file upload as expected.
And that is all there is to it!
However, this method does not work when the ``ImageAdmin`` is embedded in other
Admins using the ``sonata_type_admin`` field type. For that we need something more...
Advanced example - works with embedded Admins
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
When one Admin is embedded in another Admin, the child Admin's ``preUpdate()`` method is
not triggered when the parent is submitted. To deal with this we need to use the parent
Admin's lifecycle events to trigger the file management when needed.
In this example we have a Page class which has three one-to-one Image relationships
defined, linkedImage1 to linkedImage3. The PostAdmin class' form field configuration
looks like this:
.. code-block:: php
<?php
// src/AppBundle/Admin/PostAdmin.php
class PostAdmin extends AbstractAdmin
{
protected function configureFormFields(FormMapper $formMapper)
{
$formMapper
->add('linkedImage1', 'sonata_type_admin', array(
'delete' => false
))
->add('linkedImage2', 'sonata_type_admin', array(
'delete' => false
))
->add('linkedImage3', 'sonata_type_admin', array(
'delete' => false
))
// ...
;
}
// ...
}
This is easy enough - we have embedded three fields, which will then use our ``ImageAdmin``
class to determine which fields to show.
In our PostAdmin we then have the following code to manage the relationships' lifecycles:
.. code-block:: php
<?php
// src/AppBundle/Admin/PostAdmin.php
class PostAdmin extends AbstractAdmin
{
// ...
public function prePersist($page)
{
$this->manageEmbeddedImageAdmins($page);
}
public function preUpdate($page)
{
$this->manageEmbeddedImageAdmins($page);
}
private function manageEmbeddedImageAdmins($page)
{
// Cycle through each field
foreach ($this->getFormFieldDescriptions() as $fieldName => $fieldDescription) {
// detect embedded Admins that manage Images
if ($fieldDescription->getType() === 'sonata_type_admin' &&
($associationMapping = $fieldDescription->getAssociationMapping()) &&
$associationMapping['targetEntity'] === 'AppBundle\Entity\Image'
) {
$getter = 'get'.$fieldName;
$setter = 'set'.$fieldName;
/** @var Image $image */
$image = $page->$getter();
if ($image) {
if ($image->getFile()) {
// update the Image to trigger file management
$image->refreshUpdated();
} elseif (!$image->getFile() && !$image->getFilename()) {
// prevent Sf/Sonata trying to create and persist an empty Image
$page->$setter(null);
}
}
}
}
}
// ...
}
Here we loop through the fields of our PageAdmin and look for ones which are ``sonata_type_admin``
fields which have embedded an Admin which manages an Image.
Once we have those fields we use the ``$fieldName`` to build strings which refer to our accessor
and mutator methods. For example we might end up with ``getlinkedImage1`` in ``$getter``. Using
this accessor we can get the actual Image object from the Page object under management by the
PageAdmin. Inspecting this object reveals whether it has a pending file upload - if it does we
trigger the same ``refreshUpdated()`` method as before.
The final check is to prevent a glitch where Symfony tries to create blank Images when nothing
has been entered in the form. We detect this case and null the relationship to stop this from
happening.
Notes
-----
If you are looking for richer media management functionality there is a complete SonataMediaBundle
which caters to this need. It is documented online and is created and maintained by the same team
as SonataAdmin.
To learn how to add an image preview to your ImageAdmin take a look at the related cookbook entry.
.. _`uploading files with Doctrine and Symfony`: http://symfony.com/doc/current/cookbook/doctrine/file_uploads.html

View File

@@ -0,0 +1,144 @@
Showing image previews
======================
This is a full working example of one way to add image previews to your create and
edit views in SonataAdmin.
Pre-requisites
--------------
- you have already got the image files on a server somewhere and have a helper
method to retrieve a publicly visible URL for that image, in this example that
method is called ``Image::getWebPath()``
- you have already set up an Admin to edit the object that contains the images,
now you just want to add the previews. In this example that class is called
``ImageAdmin``
.. note::
There is a separate cookbook recipe to demonstrate how to upload images
(and other files) using SonataAdmin.
The recipe
----------
SonataAdmin lets us put raw HTML into the 'help' option for any given form field.
We are going to use this functionality to embed an image tag when an image exists.
To do this we need to:
- get access to the ``Image`` instance from within ``ImageAdmin``
- create an image tag based on the Image's URL
- add a 'help' option to a field on the Image form to display the image tag
For the sake of this example we will use some basic CSS to restrict the size of
the preview image (we are not going to generate and save special thumbnails).
Basic example - for single layer Admins
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
If we are working directly with our ``ImageAdmin`` class then getting hold of
the ``Image`` instance is simply a case of calling ``$this->getSubject()``. Since
we are manipulating form fields we do this from within ``ImageAdmin::configureFormFields()``:
.. code-block:: php
class ImageAdmin extends AbstractAdmin
{
protected function configureFormFields(FormMapper $formMapper)
{
// get the current Image instance
$image = $this->getSubject();
// use $fileFieldOptions so we can add other options to the field
$fileFieldOptions = array('required' => false);
if ($image && ($webPath = $image->getWebPath())) {
// get the container so the full path to the image can be set
$container = $this->getConfigurationPool()->getContainer();
$fullPath = $container->get('request_stack')->getCurrentRequest()->getBasePath().'/'.$webPath;
// add a 'help' option containing the preview's img tag
$fileFieldOptions['help'] = '<img src="'.$fullPath.'" class="admin-preview" />';
}
$formMapper
// ... other fields ...
->add('file', 'file', $fileFieldOptions)
;
}
// ...
}
We then use CSS to restrict the max size of the image:
.. code-block:: css
img.admin-preview {
max-height: 200px;
max-width: 200px;
}
And that is all there is to it!
However, this method does not work when the ``ImageAdmin`` can be embedded in other
Admins using the ``sonata_type_admin`` field type. For that we need...
Advanced example - works with embedded Admins
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
When one Admin is embedded in another Admin, ``$this->getSubject()`` does not return the
instance under management by the embedded Admin. Instead we need to detect that our
Admin class is embedded and use a different method:
.. code-block:: php
class ImageAdmin extends AbstractAdmin
{
protected function configureFormFields(FormMapper $formMapper)
{
if($this->hasParentFieldDescription()) { // this Admin is embedded
// $getter will be something like 'getlogoImage'
$getter = 'get' . $this->getParentFieldDescription()->getFieldName();
// get hold of the parent object
$parent = $this->getParentFieldDescription()->getAdmin()->getSubject();
if ($parent) {
$image = $parent->$getter();
} else {
$image = null;
}
} else {
$image = $this->getSubject();
}
// use $fileFieldOptions so we can add other options to the field
$fileFieldOptions = array('required' => false);
if ($image && ($webPath = $image->getWebPath())) {
// add a 'help' option containing the preview's img tag
$fileFieldOptions['help'] = '<img src="'.$webPath.'" class="admin-preview" />';
}
$formMapper
// ... other fields ...
->add('file', 'file', $fileFieldOptions)
;
}
// ...
}
As you can see, the only change is how we retrieve set ``$image`` to the relevant Image instance.
When our ImageAdmin is embedded we need to get the parent object first then use a getter to
retrieve the Image. From there on, everything else is the same.
Notes
-----
If you have more than one level of embedding Admins this will (probably) not work. If you know of
a more generic solution, please fork and update this recipe on GitHub. Similarly, if there are any
errors or typos (or a much better way to do this) get involved and share your insights for the
benefit of everyone.

View File

@@ -0,0 +1,48 @@
Improve performance of large datasets
=====================================
If your database table contains thousands of records, the database queries generated
by SonataAdmin may become very slow. Here are tips how to improve the performance of your admin.
Change default Pager to SimplePager
-----------------------------------
Default `Pager` is counting all rows in the table, so user can easily navigate
to any page in the Datagrid. But counting thousands or millions of records
can be slow operation. If you don't need to know the number of all records,
you can use `SimplePager` instead. It doesn't count all rows, but gives user only
information if there is next page or not.
To use `SimplePager` in your admin just define ``pager_type`` inside the service definition:
.. configuration-block::
.. code-block:: xml
<!-- src/AppBundle/Resources/config/admin.xml -->
<service id="app.admin.post" class="AppBundle\Admin\PostAdmin">
<tag name="sonata.admin" manager_type="orm" group="Content" label="Post" pager_type="simple" />
<argument />
<argument>AppBundle\Entity\Post</argument>
<argument />
</service>
.. code-block:: yaml
# src/AppBundle/Resources/config/admin.yml
services:
app.admin.post:
class: AppBundle\Admin\PostAdmin
tags:
- { name: sonata.admin, manager_type: orm, group: "Content", label: "Post", pager_type: "simple" }
arguments:
- ~
- AppBundle\Entity\Post
- ~
public: true
.. note::
The ``pager_results`` template is automatically changed to ``SonataAdminBundle:Pager:simple_pager_results.html.twig`` if it's not already overloaded.

View File

@@ -0,0 +1,293 @@
KnpMenu
=======
The admin comes with `KnpMenu`_ integration.
It integrates a menu with the KnpMenu library. This menu can be a SonataAdmin service, a menu created with a Knp menu provider or a route of a custom controller.
Add a custom controller entry in the menu
-----------------------------------------
To add a custom controller entry in the admin menu:
Create your controller:
.. code-block:: php
class BlogController
{
/**
* @Route("/blog", name="blog_home")
*/
public function blogAction()
{
// ...
}
/**
* @Route("/blog/article/{articleId}", name="blog_article")
*/
public function ArticleAction($articleId)
{
// ...
}
}
Add the controller route as an item of the menu:
.. configuration-block::
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
dashboard:
groups:
news:
label: ~
label_catalogue: ~
items:
- sonata.news.admin.post
- route: blog_home
label: Blog
- route: blog_article
route_params: { articleId: 3 }
label: Article
If you want to show your route to user with different roles, you can configure this for each route. If this is not set,
group roles will be checked.
.. configuration-block::
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
dashboard:
groups:
news:
label: ~
label_catalogue: ~
items:
- sonata.news.admin.post
- route: blog_home
label: Blog
roles: [ ROLE_FOO, ROLE_BAR ]
- route: blog_article
route_params: { articleId: 3 }
label: Article
roles: [ ROLE_ADMIN, ROLE_SONATA_ADMIN]
You can also override the template of knp_menu used by sonata. The default one is `SonataAdminBundle:Menu:sonata_menu.html.twig`:
.. configuration-block::
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
templates:
knp_menu_template: ApplicationAdminBundle:Menu:custom_knp_menu.html.twig
And voilà, now you have a menu group which contains a link to a sonata admin via its id, to your blog and to a specific article.
Using a menu provider
---------------------
As seen above, the main way to declare your menu is by declaring items in your sonata admin config file. In some case you may have to create a more complex menu depending on your business logic. This is possible by using a menu provider to populate a whole menu group. This is done with the ``provider`` config value.
The following configuration uses a menu provider to populate the menu group ``my_group``:
.. configuration-block::
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
dashboard:
groups:
my_group:
provider: 'MyBundle:MyMenuProvider:getMyMenu'
icon: '<i class="fa fa-edit"></i>'
With KnpMenuBundle you can create a custom menu by using a builder class or by declaring it as a service. Please see the `Knp documentation`_ for further information.
In sonata, whatever the implementation you choose, you only have to provide the menu alias to the provider config key:
* If you are using a builder class, your menu alias should be something like ``MyBundle:MyMenuProvider:getMyMenu``.
* If you are using a service, your menu alias is the alias set in the ``knp_menu.menu`` tag. In the following example this is ``my_menu_alias``:
.. configuration-block::
.. code-block:: xml
<service id="my_menu_provider" class="MyBundle/MyDirectory/MyMenuProvider">
<tag name="knp_menu.menu" alias="my_menu_alias" />
</service>
Please note that when using the provider option, you can't set the menu label via the configuration. It is done in your custom menu.
Extending the menu
------------------
You can modify the menu via events easily. You can register as many listeners as you want for the event with name ``sonata.admin.event.configure.menu.sidebar``:
.. code-block:: php
<?php
// src/AppBundle/EventListener/MenuBuilderListener.php
namespace AppBundle\EventListener;
use Sonata\AdminBundle\Event\ConfigureMenuEvent;
class MenuBuilderListener
{
public function addMenuItems(ConfigureMenuEvent $event)
{
$menu = $event->getMenu();
$child = $menu->addChild('reports', [
'label' => 'Daily and monthly reports',
'route' => 'app_reports_index',
])->setExtras([
'icon' => '<i class="fa fa-bar-chart"></i>',
]);
}
}
.. configuration-block::
.. code-block:: yaml
# src/AppBundle/Resources/config/services.yml
services:
app.menu_listener:
class: AppBundle\EventListener\MenuBuilderListener
tags:
- { name: kernel.event_listener, event: sonata.admin.event.configure.menu.sidebar, method: addMenuItems }
Please see the `Using events to allow a menu to be extended`_ for further information.
Hiding menu items
-----------------
You can modify the menu to hide some menu items. You need to add the ``show_in_dashboard`` option in
your admin services or simply remove menu items from the ``sonata_admin`` dashboard group configuration:
.. code-block:: yaml
sonata_admin.admin.post:
class: Sonata\AdminBundle\Admin\PostAdmin
arguments: [~, Sonata\AdminBundle\Entity\Post, SonataAdminBundle:CRUD]
tags:
- {name: sonata.admin, manager_type: orm, group: admin, label: Post, show_in_dashboard: false}
public: true
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
dashboard:
groups:
news:
label: ~
label_catalogue: ~
items:
# just comment or remove the sonata.news.admin.post declaration to hide it from the menu.
# - sonata.news.admin.post
- route: blog_home
label: Blog
- sonata.news.admin.news
Keeping menu group open
-----------------------
You can add the ``keep_open`` option to menu group to keep that group always
open and ignore open/close effects:
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
dashboard:
groups:
sonata.admin.group.content:
keep_open: true
label: sonata_media
label_catalogue: SonataMediaBundle
icon: '<i class="fa fa-image"></i>'
items:
- sonata.media.admin.media
- sonata.media.admin.gallery
.. figure:: ../images/keep_open.png
:align: center
:alt: The navigation side bar with a group which uses "keep_open" option
Show menu item without treeview
-------------------------------
You can modify the menu to show menu item without treeview. You need to add option ``on_top`` in your admin services
or in sonata_admin dashboard group configuration:
.. code-block:: yaml
sonata_admin.admin.post:
class: Sonata\AdminBundle\Admin\PostAdmin
arguments: [~, Sonata\AdminBundle\Entity\Post, SonataAdminBundle:CRUD]
tags:
- {name: sonata.admin, manager_type: orm, group: admin, label: Post, on_top: true}
public: true
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
dashboard:
groups:
news:
on_top: true
label: ~
label_catalogue: ~
items:
- sonata.news.admin.post
.. figure:: ../images/demo_on_top.png
:align: center
:alt: on_top option
:width: 500
In this screenshot, we add ``on_top`` option to ``Tag`` and ``Blog Post`` admin services.
Your can't use this option for two or more items in the same time, for example:
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
dashboard:
groups:
news:
on_top: true
label: ~
label_catalogue: ~
items:
- sonata.news.admin.post
- route: blog_home
label: Blog
In this case you have an exception: "You can't use ``on_top`` option with multiple same name groups".
.. _KnpMenu: https://github.com/KnpLabs/KnpMenu
.. _Knp documentation: http://symfony.com/doc/current/bundles/KnpMenuBundle/index.html#create-your-first-menu
.. _Using events to allow a menu to be extended: http://symfony.com/doc/master/bundles/KnpMenuBundle/events.html

View File

@@ -0,0 +1,81 @@
Lock Protection
===============
Lock protection will prevent data corruption when multiple users edit an object at the same time.
Example
-------
1) Alice starts to edit the object
2) Bob starts to edit the object
3) Alice submits the form
4) Bob submits the form
In this case, a message will tell Bob that someone else has edited the object,
and that he must reload the page and apply the changes again.
Enable Lock Protection
----------------------
By default, lock protection is disabled.
You can enable it in your ``sonata_admin`` configuration :
.. configuration-block::
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
options:
lock_protection: true
You must also configure each entity that you want to support by adding a field called ``$version`` on which the Doctrine ``Version`` feature is activated.
Using Annotations:
.. code-block:: php
<?php
// src/AppBundle/Entity/Car.php
namespace AppBundle\Entity\Car;
use Doctrine\ORM\Mapping as ORM;
class Car
{
// ...
/**
* @ORM\Column(type="integer")
* @ORM\Version
*/
protected $version;
// ...
}
Using XML:
.. code-block:: xml
<?xml version="1.0" encoding="utf-8"?>
<!-- src/AppBundle/Resources/orm/Car.orm.xml -->
<doctrine-mapping>
<entity name="AppBundle\Entity\Car">
<!-- ... -->
<field name="version" type="integer" version="true" />
<!-- ... -->
</entity>
</doctrine-mapping>
For more information about this visit the `Doctrine docs <http://doctrine-orm.readthedocs.org/en/latest/reference/transactions-and-concurrency.html?highlight=optimistic#optimistic-locking>`_
.. note::
If the object model manager does not support object locking,
the lock protection will not be triggered for the object.
Currently, only the ``SonataDoctrineORMAdminBundle`` supports it.

View File

@@ -0,0 +1,38 @@
Overwrite Admin Configuration
=============================
Sometime you might want to overwrite some Admin settings from vendors. This recipe will explain how to achieve this operation. However, keep in mind this operation is quite dangerous and might break code.
From the configuration file, you can add a new section named ``admin_services`` with the following templates:
.. code-block:: yaml
sonata_admin:
admin_services:
id.of.admin.service:
# service configuration
model_manager: sonata.admin.manager.doctrine_orm
form_contractor: sonata.admin.builder.doctrine_orm
show_builder: sonata.admin.builder.doctrine_orm
list_builder: sonata.admin.builder.doctrine_orm
datagrid_builder: sonata.admin.builder.doctrine_orm
translator: translator
configuration_pool: sonata.admin.pool
route_generator: sonata.admin.route.default_generator
validator: validator
security_handler: sonata.admin.security.handler
menu_factory: knp_menu.factory
route_builder: sonata.admin.route.path_info
label_translator_strategy: sonata.admin.label.strategy.native
# templates configuration
templates:
# view templates
view:
user_block: mytemplate.twig.html
# form related theme templates => this feature need to be implemented by the Persistency layer of each Admin Bundle
form: [ 'MyTheme.twig.html', 'MySecondTheme.twig.html']
filter: [ 'MyTheme.twig.html', 'MySecondTheme.twig.html']
With these settings you will be able to change default services and templates used by the `id.of.admin.service`` admin instance.

View File

@@ -0,0 +1,84 @@
Row templates
=============
Since Sonata-2.2 it is possible to define a template per row for the list action.
The default template is a standard table but there are circumstances where this
type of layout might not be suitable. By defining a custom template for the row,
you can tweak the layout into something like this:
.. figure:: ./../images/sonata_inline_row.png
:align: center
:alt: Inline Row from the SonataNewsBundle
:width: 700px
The recipe
----------
Configure your Admin service
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The configuration takes place in the DIC by calling the ``setTemplates`` method.
Two template keys need to be set:
- ``inner_list_row``: The template for the row, which you will customize. Often
you will want this to extend ``SonataAdminBundle:CRUD:base_list_flat_inner_row.html.twig``
- ``base_list_field``: The base template for the cell, the default of
``SonataAdminBundle:CRUD:base_list_flat_field.html.twig`` is suitable for most
cases but it can be customized if required.
.. configuration-block::
.. code-block:: xml
<service id="sonata.admin.comment" class="%sonata.admin.comment.class%">
<tag name="sonata.admin" manager_type="orm" group="sonata_blog" label="comments"
label_catalogue="%sonata.admin.comment.translation_domain%"
label_translator_strategy="sonata.admin.label.strategy.underscore" />
<argument />
<argument>%sonata.admin.comment.entity%</argument>
<argument>%sonata.admin.comment.controller%</argument>
<call method="setTemplates">
<argument type="collection">
<argument key="inner_list_row">
AppBundle:Admin:inner_row_comment.html.twig
</argument>
<argument key="base_list_field">
SonataAdminBundle:CRUD:base_list_flat_field.html.twig
</argument>
</argument>
</call>
</service>
Create your customized template
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Once the templates are defined, create the template to render the row:
.. code-block:: jinja
{# AppBundle:Admin:inner_row_comment.html.twig #}
{# Extend the default template, which provides batch and action cells #}
{# as well as the valid colspan computation #}
{% extends 'SonataAdminBundle:CRUD:base_list_flat_inner_row.html.twig' %}
{% block row %}
{# you can use fields defined in the the Admin class #}
{{ object|render_list_element(admin.list['name']) }} -
{{ object|render_list_element(admin.list['url']) }} -
{{ object|render_list_element(admin.list['email']) }} <br />
<small>
{# or you can use the object variable to render a property #}
{{ object.message }}
</small>
{% endblock %}
While this feature is nice to generate a rich list, it is also very easy to
break the layout and admin features such as batch and object actions. It is
best to familiarise yourself with the default templates and extend them where
possible, only changing what you need to customize.

View File

@@ -0,0 +1,73 @@
Select2
=======
The admin comes with `select2 <http://ivaynberg.github.io/select2/>`_ integration
since version 2.2.6. Select2 is a jQuery based replacement for select boxes.
It supports searching, remote data sets, and infinite scrolling of results.
The select2 is enabled on all ``select`` form elements by default.
Disable select2
---------------
If you don't want to use select2 in your admin, you can disable it in ``config.yml``.
.. configuration-block::
.. code-block:: yaml
# app/config/config.yml
sonata_admin:
options:
use_select2: false # disable select2
.. note::
If you disable select2, autocomplete form types will stop working.
Disable select2 on some form elements
-------------------------------------
To disable select2 on some ``select`` form element, set data attribute ``data-sonata-select2 = "false"`` to this form element.
.. code-block:: php
public function configureFormFields(FormMapper $formMapper)
{
$formMaper
->add('category', 'sonata_type_model', array(
'attr' => array(
'data-sonata-select2' => 'false'
)
))
;
}
.. note::
You have to use false as string! ``"false"``!
AllowClear
----------
Select2 parameter ``allowClear`` is handled automatically by admin. But if you want
to overload the default functionality, you can set data attribute ``data-sonata-select2-allow-clear="true"``
to enable ``allowClear`` or ``data-sonata-select2-allow-clear = "false"`` to disable the ``allowClear`` parameter.
.. code-block:: php
public function configureFormFields(FormMapper $formMapper)
{
$formMapper
->add('category', 'sonata_type_model', array(
'attr' => array(
'data-sonata-select2-allow-clear' => 'false'
)
))
;
}
.. note::
You have to use false as string! ``"false"``!

View File

@@ -0,0 +1,172 @@
Sortable behavior in admin listing
==================================
This is a full working example of how to implement a sortable feature in your Sonata admin listing
Background
----------
A sortable behavior is already available for one-to-many relationships (https://sonata-project.org/bundles/doctrine-orm-admin/master/doc/reference/form_field_definition.html#advanced-usage-one-to-many).
However there is no packaged solution to have some up and down arrows to sort
your records such as showed in the following screen
.. figure:: ../images/admin_sortable_listing.png
:align: center
:alt: Sortable listing
:width: 700px
Pre-requisites
--------------
Configuration
^^^^^^^^^^^^^
- you already have SonataAdmin and DoctrineORM up and running
- you already have an Entity class for which you want to implement a sortable feature. For the purpose of the example we are going to call it ``Client``.
- you already have an Admin set up, in this example we will call it ``ClientAdmin``
Bundles
^^^^^^^
- install ``gedmo/doctrine-extensions`` bundle in your project (check ``stof/doctrine-extensions-bundle`` for easier integration in your project) and enable the sortable feature in your config
- install ``pixassociates/sortable-behavior-bundle`` at least version ^1.1 in your project
The recipe
----------
First of all we are going to add a position field in our ``Client`` entity.
.. code-block:: php
/**
* @Gedmo\SortablePosition
* @ORM\Column(name="position", type="integer")
*/
private $position;
Then we need to inject the Sortable listener. If you only have the Gedmo bundle enabled, you only have to add the listener to your config.yml and skip this step.
.. code-block:: yaml
services:
gedmo.listener.sortable:
class: Gedmo\Sortable\SortableListener
tags:
- { name: doctrine.event_subscriber, connection: default }
calls:
- [ setAnnotationReader, [ "@annotation_reader" ] ]
If you have the ``stof/doctrine-extensions-bundle``, you only need to enable the sortable
feature in your config.yml such as
.. code-block:: yaml
stof_doctrine_extensions:
orm:
default:
sortable: true
In our ``ClientAdmin`` we are going to add a custom action in the ``configureListFields`` method
and use the default twig template provided in the ``pixSortableBehaviorBundle``
.. code-block:: php
$listMapper
->add('_action', null, array(
'actions' => array(
'move' => array(
'template' => 'PixSortableBehaviorBundle:Default:_sort.html.twig'
),
)
)
);
In order to add new routes for these actions we are also adding the following method
.. code-block:: php
<?php
// src/AppBundle/Admin/ClientAdmin.php
namespace AppBundle/Admin;
use Sonata\AdminBundle\Route\RouteCollection;
// ...
protected function configureRoutes(RouteCollection $collection)
{
// ...
$collection->add('move', $this->getRouterIdParameter().'/move/{position}');
}
Now you can update your ``services.yml`` to use the handler provider by the ``pixSortableBehaviorBundle``
.. code-block:: yaml
services:
app.admin.client:
class: AppBundle\Admin\ClientAdmin
tags:
- { name: sonata.admin, manager_type: orm, label: "Clients" }
arguments:
- ~
- AppBundle\Entity\Client
- 'PixSortableBehaviorBundle:SortableAdmin' # define the new controller via the third argument
public: true
Now we need to define the sort by field to be ``$position``:
.. code-block:: php
<?php
// src/AppBundle/Admin/ClientAdmin.php
namespace AppBundle\Admin;
use Sonata\AdminBundle\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Route\RouteCollection;
class ClientAdmin extends AbstractAdmin
{
protected $datagridValues = array(
'_page' => 1,
'_sort_order' => 'ASC',
'_sort_by' => 'position',
);
protected function configureRoutes(RouteCollection $collection)
{
// ...
$collection->add('move', $this->getRouterIdParameter().'/move/{position}');
}
// ...
protected function configureListFields(ListMapper $listMapper)
{
$listMapper
->addIdentifier('name')
->add('enabled')
->add('_action', null, array(
'actions' => array(
'move' => array(
'template' => 'AppBundle:Admin:_sort.html.twig'
),
),
))
;
}
}
Enjoy ;)
Further work
------------
* handle ajax request
* interface for SonataAdminBundle

View File

@@ -0,0 +1,470 @@
Sortable Sonata Type Model in Admin
===================================
This is a full working example on how to implement a sortable feature in your Sonata admin form between two entities.
Background
----------
The sortable function is already available inside Sonata for the ``ChoiceType``. But the ``ModelType`` (or sonata_type_model) extends from choice, so this function is already available in our form type.
We just need some configuration to make it work.
The goal here is to fully configure a working example to handle the following need :
User got some expectations, but some are more relevant than the others.
Pre-requisites
--------------
Configuration
^^^^^^^^^^^^^
- you already have SonataAdmin and DoctrineORM up and running.
- you already have a ``UserBundle``.
- you already have ``User`` and ``Expectation`` Entities classes.
- you already have an ``UserAdmin`` and ``ExpectationAdmin`` set up.
The recipe
----------
Part 1 : Update the data model configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The first thing to do is to update the Doctrine ORM configuration and create the join entity between ``User`` and ``Expectation``.
We are going to call this join entity ``UserHasExpectations``.
.. note::
We can't use a ``Many-To-Many`` relation here because the joined entity will required an extra field to handle ordering.
So we start by updating ``UserBundle/Resources/config/doctrine/User.orm.xml`` and adding a ``One-To-Many`` relation.
.. code-block:: xml
<one-to-many field="userHasExpectations" target-entity="UserBundle\Entity\UserHasExpectations" mapped-by="user" orphan-removal="true">
<cascade>
<cascade-persist />
</cascade>
<order-by>
<order-by-field name="position" direction="ASC" />
</order-by>
</one-to-many>
Then update ``UserBundle/Resources/config/doctrine/Expectation.orm.xml`` and also adding a ``One-To-Many`` relation.
.. code-block:: xml
<one-to-many field="userHasExpectations" target-entity="UserBundle\Entity\UserHasExpectations" mapped-by="expectation" orphan-removal="false">
<cascade>
<cascade-persist />
</cascade>
</one-to-many>
We now need to create the join entity configuration, create the following file in ``UserBundle/Resources/config/doctrine/UserHasExpectations.orm.xml``.
.. code-block:: xml
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="UserBundle\Entity\UserHasExpectations" table="user__expectations">
<id name="id" type="integer">
<generator strategy="AUTO" />
</id>
<field name="position" column="position" type="integer">
<options>
<option name="default">0</option>
</options>
</field>
<many-to-one field="user" target-entity="UserBundle\Entity\User" inversed-by="userHasExpectations" orphan-removal="false">
<join-column name="user_id" referenced-column-name="id" on-delete="CASCADE"/>
</many-to-one>
<many-to-one field="expectation" target-entity="UserBundle\Entity\Expectation" inversed-by="userHasExpectations" orphan-removal="false">
<join-column name="expectation_id" referenced-column-name="id" on-delete="CASCADE"/>
</many-to-one>
</entity>
</doctrine-mapping>
Part 2 : Update the data model entities
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Update the ``UserBundle\Entity\User.php`` entity with the following :
.. code-block:: php
// ...
/**
* @var UserHasExpectations[]
*/
protected $userHasExpectations;
/**
* {@inheritdoc}
*/
public function __construct()
{
$this->userHasExpectations = new ArrayCollection;
}
/**
* @param ArrayCollection $userHasExpectations
*/
public function setUserHasExpectations(ArrayCollection $userHasExpectations)
{
$this->userHasExpectations = new ArrayCollection;
foreach ($userHasExpectations as $one) {
$this->addUserHasExpectations($one);
}
}
/**
* @return ArrayCollection
*/
public function getUserHasExpectations()
{
return $this->userHasExpectations;
}
/**
* @param UserHasExpectations $userHasExpectations
*/
public function addUserHasExpectations(UserHasExpectations $userHasExpectations)
{
$userHasExpectations->setUser($this);
$this->userHasExpectations[] = $userHasExpectations;
}
/**
* @param UserHasExpectations $userHasExpectations
*
* @return $this
*/
public function removeUserHasExpectations(UserHasExpectations $userHasExpectations)
{
$this->userHasExpectations->removeElement($userHasExpectations);
return $this;
}
// ...
Update the ``UserBundle\Entity\Expectation.php`` entity with the following :
.. code-block:: php
// ...
/**
* @var UserHasExpectations[]
*/
protected $userHasExpectations;
/**
* @param UserHasExpectations[] $userHasExpectations
*/
public function setUserHasExpectations($userHasExpectations)
{
$this->userHasExpectations = $userHasExpectations;
}
/**
* @return UserHasExpectations[]
*/
public function getUserHasExpectations()
{
return $this->userHasExpectations;
}
/**
* @return string
*/
public function __toString()
{
return $this->getLabel();
}
// ...
Create the ``UserBundle\Entity\UserHasExpectations.php`` entity with the following :
.. code-block:: php
<?php
namespace UserBundle\Entity;
class UserHasExpectations
{
/**
* @var integer $id
*/
protected $id;
/**
* @var User $user
*/
protected $user;
/**
* @var Expectation $expectation
*/
protected $expectation;
/**
* @var integer $position
*/
protected $position;
/**
* Get id
*
* @return integer $id
*/
public function getId()
{
return $this->id;
}
/**
* @return User
*/
public function getUser()
{
return $this->user;
}
/**
* @param User $user
*
* @return $this
*/
public function setUser(User $user)
{
$this->user = $user;
return $this;
}
/**
* @return Expectation
*/
public function getExpectation()
{
return $this->expectation;
}
/**
* @param Expectation $expectation
*
* @return $this
*/
public function setExpectation(Expectation $expectation)
{
$this->expectation = $expectation;
return $this;
}
/**
* @return int
*/
public function getPosition()
{
return $this->position;
}
/**
* @param int $position
*
* @return $this
*/
public function setPosition($position)
{
$this->position = $position;
return $this;
}
/**
* @return string
*/
public function __toString()
{
return (string) $this->getExpectation();
}
}
Part 3 : Update admin classes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is a very important part, the admin class **should** be created for the join entity. If you don't do that, the field will never display properly.
So we are going to start by creating this ``UserBundle\Admin\UserHasExpectationsAdmin.php`` ...
.. code-block:: php
<?php
namespace UserBundle\Admin;
use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Form\FormMapper;
class UserHasExpectationsAdmin extends AbstractAdmin
{
/**
* @param \Sonata\AdminBundle\Form\FormMapper $formMapper
*/
protected function configureFormFields(FormMapper $formMapper)
{
$formMapper
->add('expectation', 'sonata_type_model', array('required' => false))
->add('position', 'hidden')
;
}
/**
* @param \Sonata\AdminBundle\Datagrid\ListMapper $listMapper
*/
protected function configureListFields(ListMapper $listMapper)
{
$listMapper
->add('expectation')
->add('user')
->add('position')
;
}
}
... and define the service in ``UserBundle\Resources\config\admin.xml``.
.. code-block:: xml
<service id="user.admin.user_has_expectations" class="UserBundle\Admin\UserHasExpectationsAdmin">
<tag name="sonata.admin" manager_type="orm" group="UserHasExpectations" label="UserHasExpectations" />
<argument />
<argument>UserBundle\Entity\UserHasExpectations</argument>
<argument />
</service>
Now update the ``UserBundle\Admin\UserAdmin.php`` by adding the ``sonata_type_model`` field.
.. code-block:: php
/**
* {@inheritdoc}
*/
protected function configureFormFields(FormMapper $formMapper)
{
// ...
$formMapper
->add('userHasExpectations', 'sonata_type_model', array(
'label' => 'User\'s expectations',
'query' => $this->modelManager->createQuery('UserBundle\Entity\Expectation'),
'required' => false,
'multiple' => true,
'by_reference' => false,
'sortable' => true,
))
;
$formMapper->get('userHasExpectations')->addModelTransformer(new ExpectationDataTransformer($this->getSubject(), $this->modelManager));
}
There is two important things that we need to show here :
* We use the field ``userHasExpectations`` of the user, but we need a list of ``Expectation`` to be displayed, that's explain the use of ``query``.
* We want to persist ``UserHasExpectations``Entities, but we manage ``Expectation``, so we need to use a custom `ModelTransformer <http://symfony.com/doc/current/cookbook/form/data_transformers.html>`_ to deal with it.
Part 4 : Data Transformer
^^^^^^^^^^^^^^^^^^^^^^^^^
The last (but not least) step is create the ``UserBundle\Form\DataTransformer\ExpectationDataTransformer.php`` to handle the conversion of ``Expectation`` to ``UserHasExpectations``.
.. code-block:: php
<?php
namespace UserBundle\Form\DataTransformer;
class ExpectationDataTransformer implements Symfony\Component\Form\DataTransformerInterface
{
/**
* @var User $user
*/
private $user;
/**
* @var ModelManager $modelManager
*/
private $modelManager;
/**
* @param User $user
* @param ModelManager $modelManager
*/
public function __construct(User $user, ModelManager $modelManager)
{
$this->user = $user;
$this->modelManager = $modelManager;
}
/**
* {@inheritdoc}
*/
public function transform($data)
{
if (!is_null($data)) {
$results = [];
/** @var UserHasExpectations $userHasExpectations */
foreach ($data as $userHasExpectations) {
$results[] = $userHasExpectations->getExpectation();
}
return $results;
}
return $data;
}
/**
* {@inheritdoc}
*/
public function reverseTransform($expectations)
{
$results = new ArrayCollection;
$position = 0;
/** @var Expectation $expectation */
foreach ($expectations as $expectation) {
$userHasExpectations = $this->create();
$userHasExpectations->setExpectation($expectation);
$userHasExpectations->setPosition($position++);
$results->add($userHasExpectations);
}
// Remove Old values
$qb = $this->modelManager->getEntityManager()->createQueryBuilder();
$expr = $this->modelManager->getEntityManager()->getExpressionBuilder();
$userHasExpectationsToRemove = $qb->select('entity')
->from($this->getClass(), 'entity')
->where($expr->eq('entity.user', $this->user->getId()))
->getQuery()
->getResult();
foreach ($userHasExpectationsToRemove as $userHasExpectations) {
$this->modelManager->delete($userHasExpectations, false);
}
$this->modelManager->getEntityManager()->flush();
return $results;
}
}
Hope this will work for you :)

View File

@@ -0,0 +1,12 @@
Virtual Field Descriptions
==========================
Some fields including in the various Mappers don't rely on any actual field in
the Model (e.g., ``_action`` or ``batch``).
In order to prevent any side-effects when trying to retrieve the value of this
field (which doesn't exist), the option ``virtual_field`` is specified for these
fields.
When the template is instantiated, or whenever the value of the field is
required, ``null`` will simply be returned without prying on the Model itself.