📜 ⬆️ ⬇️

The implementation of the tag system in the admin with the SonataAdminBundle bundle

Many use the SonataAdminBundle bundle when developing on Symfony2. This bundle allows you to quickly create a CRUD admin panel for Doctrine and Mongo entities. In particular, it allows you to quickly and easily make pages for adding entities, including one-to-many and many-to-many links. Here I have problems with the last point. In this article, I will show you how to organize the installation of tags for several entities, using just one intermediate table, using the FPNTagBundle bundle, and what I had to do to make this bundle work in SonataAdmin. But first we will look at how to implement entity editing (including tags) on a simple SonataAdmin

Simple tag implementation


There are several entities in the current project (let's call them Article and News, although there are seven of them in this project), which should be given the opportunity to tag, moreover, one entity can have multiple tags, that is, many-to-many relationships are realized.
First, let's look at how to edit tags in the admin panel without the FPNTagBundle bundle. I made a parent entity, from which all the others are inherited:
Entity Base Entity
namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; //   ORM\Entity -             class Entity { /** * @var integer * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ protected $id; /** * @var boolean * @ORM\Column(type="boolean", options={"default":false}) */ protected $published = false; /** * @var string * @ORM\Column(type="string", length=255) */ protected $title; /** * @var string * @ORM\Column(type="text") */ protected $content; //   /** * Get id * @return integer */ public function getId() { return $this->id; } /** * Set published * @param boolean $published * @return Entity */ public function setPublished($published) { $this->published = $published; return $this; } /** * Toggle published * @return Entity */ public function togglePublished() { $this->published = !$this->published; return $this; } /** * Get published * @return boolean */ public function getPublished() { return $this->published; } /** * Set title * @param string $title * @return Entity */ public function setTitle($title) { $this->title = $title; return $this; } /** * Get title * @return string */ public function getTitle() { return $this->title; } /** * Set content * @param string $content * @return Entity */ public function setContent($content) { $this->content = $content; return $this; } /** * Get content * @return string */ public function getContent() { return $this->content; } } 


Two editable entities:
Entity Article
 namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\ArrayCollection; /** * @ORM\Table() * @ORM\Entity() */ class Article extends Entity { /** * @var ArrayCollection * @ORM\ManyToMany(targetEntity="Tag", inversedBy="articles") * @ORM\JoinTable(name="article_tags") */ protected $tags; /** * @return ArrayCollection */ public function getTags() { return $this->tags ?: $this->tags = new ArrayCollection(); } public function addTag(Tag $tag) { $tag->addArticle($this); $this->tags[] = $tag; } public function removeTag(Tag $tag) { return $this->tags->removeElement($tag); } } 


Essence News
 namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\ArrayCollection; /** * @ORM\Table() * @ORM\Entity() */ class News extends Entity { /** * @var \DateTime * @ORM\Column(type="datetime", nullable=true) */ protected $publishedAt; /** * @var ArrayCollection * @ORM\ManyToMany(targetEntity="Tag", inversedBy="news") * @ORM\JoinTable(name="news_tags") */ protected $tags; /** * Set publishedAt * @param \DateTime $publishedAt * @return News */ public function setPublishedAt($publishedAt) { $this->publishedAt = $publishedAt; return $this; } /** * Get publishedAt * @return \DateTime */ public function getPublishedAt() { return $this->publishedAt; } /** * @return ArrayCollection */ public function getTags() { return $this->tags ?: $this->tags = new ArrayCollection(); } public function addTag(Tag $tag) { $tag->addArticle($this); $this->tags[] = $tag; } public function removeTag(Tag $tag) { return $this->tags->removeElement($tag); } } 


And the essence of the tags:
Essence Tag
 namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\ArrayCollection; /** * @ORM\Table() * @ORM\Entity() */ class Tag { public function __construct() { $this->articles = new ArrayCollection(); $this->news = new ArrayCollection(); } /** * @var integer $id * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") * @ORM\Id */ protected $id; /** * @var string * @ORM\Column(type="string", length=100) */ protected $name; /** * @ORM\ManyToMany(targetEntity="Article", mappedBy="tags") */ private $articles; /** * @ORM\ManyToMany(targetEntity="News", mappedBy="tags") */ private $news; public function addArticle(Article $article) { $this->articles[] = $article; } public function addNews(News $news) { $this->news[] = $news; } public function getArticles() { $this->articles; } public function getNews() { $this->news; } /** * @return integer */ public function getId() { return $this->id; } /** * @param string $name * @return Tag */ public function setName($name) { $this->name = $name; return $this; } /** * @return string */ public function getName() { return $this->name; } } 


You can see that the two entities Article and News differ only in the name of the table in the Many-to-Many relationship. And the presence of an additional field in the News, which at the moment is not significant.

In the Doctrine, the Many-to-Many relationship is established very easily, at the level of a couple of lines in the annotation. Those who have worked with Doctrine have already seen this simplicity. An intermediate table is automatically created. Having established such a link for each entity, it is easy to configure adding tags for entities in the Sonata admin panel:
Basic admin for entities
 namespace App\AppBundle\Admin; use Sonata\AdminBundle\Admin\Admin; use Sonata\AdminBundle\Datagrid\ListMapper; use Sonata\AdminBundle\Datagrid\DatagridMapper; use Sonata\AdminBundle\Form\FormMapper; //      Admin,  Sonata       class EntityAdminBase extends Admin { protected function configureFormFields(FormMapper $formMapper) { $formMapper ->add('title', 'text') ->add('content', 'ckeditor') ->add('tags', 'entity', array( 'class'=>'AppBundle:Tag', 'multiple' => true, 'attr'=>array('style'=>'width: 100%;')) ) //  width: 100%      Select2-, //    ,      ; } protected function configureDatagridFilters(DatagridMapper $datagridMapper) { $datagridMapper ->add('title') ->add('tags', null, array(), null, array('multiple' => true)) ; } protected function configureListFields(ListMapper $listMapper) { $listMapper ->addIdentifier('title') ->add('published') ; } } 


Admin Article
 namespace App\AppBundle\Admin; use Sonata\AdminBundle\Admin\Admin; use Sonata\AdminBundle\Datagrid\ListMapper; use Sonata\AdminBundle\Datagrid\DatagridMapper; use Sonata\AdminBundle\Form\FormMapper; class ArticleAdmin extends EntityAdminBase { } 


Admin News
 namespace App\AppBundle\Admin; use Sonata\AdminBundle\Admin\Admin; use Sonata\AdminBundle\Datagrid\ListMapper; use Sonata\AdminBundle\Datagrid\DatagridMapper; use Sonata\AdminBundle\Form\FormMapper; class NewsAdmin extends EntityAdminBase { protected function configureFormFields(FormMapper $formMapper) { parent::configureFormFields($formMapper); $formMapper ->add('publishedAt', 'datetime') } protected function configureListFields(ListMapper $listMapper) { parent::configureListFields($listMapper); $listMapper ->add('publishedAt') ; } } 


As you can see, the fields common to entities are moved to the parent class, and specific to the specific entity are added in each admin area. It remains only to register services for admins:
Setting up admin services
 # /src/App/AppBundle/Resources/config/admin.yml services: sonata.admin.article: class: App\AppBundle\Admin\ArticleAdmin tags: - { name: sonata.admin, manager_type: orm, group: "Content", label: "Articles" } arguments: - ~ - App\AppBundle\Entity\Article - ~ calls: - [ setTranslationDomain, [admin]] sonata.admin.news: class: App\AppBundle\Admin\NewsAdmin tags: - { name: sonata.admin, manager_type: orm, group: "Content", label: "News" } arguments: - ~ - App\AppBundle\Entity\News - ~ calls: - [ setTranslationDomain, [admin]] #         # /app/config/config.yml imports: - { resource: parameters.yml } - { resource: security.yml } - { resource: @AppBundle/Resources/config/admin.yml } 


That's all, the sonata will automatically create everything you need to edit lists of articles and news.

Store tag associations and entities in a single table


And everything worked fine until I noticed that a separate table is created for each entity for organizing the Many-to-Many connection with tags. (If I had only a couple of such entities, I would probably not be steamed with this, but in this case I did not want to create seven different tables, and then also organize a search on these tables.) To solve, I found the FPNTagBundle bundle , which breaks a many-to-many relationship into two many-to-one and one-to-many connections by introducing the intermediate entity Tagging. In general, this separation is implemented in DoctrineExtentions, and the bundle adds their integration into Symfony and implements the TagManager class. An excellent bundle that does the obvious thing is to make one table with an additional field ResourceType - the type of record to which the tag is bound. The problem is that Sonata does not support such connections, and it is also just impossible to implement the admin panel.
')
But let's consider what changes have been made to the entities:
Entity Base Entity
 namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\ArrayCollection; class Entity { //   //     //   -   ! protected $tags; public function getTags() { return $this->tags ?: $this->tags = new ArrayCollection(); } public function getTaggableType() { //        ( ) return substr(strrchr(get_class($this), "\\"), 1); } public function getTaggableId() { return $this->getId(); } } 


Entity Article
 namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Table() * @ORM\Entity() */ class Article extends Entity { } 


Essence News
 namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Table() * @ORM\Entity() */ class News extends Entity { /** * @var \DateTime * @ORM\Column(type="datetime", nullable=true) */ protected $publishedAt; /** * Set publishedAt * @param \DateTime $publishedAt * @return News */ public function setPublishedAt($publishedAt) { $this->publishedAt = $publishedAt; return $this; } /** * Get publishedAt * @return \DateTime */ public function getPublishedAt() { return $this->publishedAt; } } 


Changed Tag Entity
 namespace App\AppBundle\Entity; use \Doctrine\ORM\Mapping as ORM; use \FPN\TagBundle\Entity\Tag as BaseTag; /** * @ORM\Table() * @ORM\Entity() */ class Tag extends BaseTag { /** * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ protected $id; /** * @ORM\OneToMany(targetEntity="Tagging", mappedBy="tag", fetch="EAGER") **/ protected $tagging; /** * @return integer */ public function getId() { return $this->id; } } 


Essence Tagging
 namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\UniqueConstraint; use \FPN\TagBundle\Entity\Tagging as BaseTagging; /** * @ORM\Table(uniqueConstraints={@UniqueConstraint(name="tagging_idx", columns={"tag_id", "resource_type", "resource_id"})}) * @ORM\Entity */ class Tagging extends BaseTagging { /** * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ protected $id; /** * @ORM\ManyToOne(targetEntity="Tag", inversedBy="tagging") * @ORM\JoinColumn(name="tag_id", referencedColumnName="id") **/ protected $tag; } 


Tags are rendered to the base entity, the classes of the entities themselves do not contain anything extra.

I started digging SonataAdminBundle code in search of a solution on how to teach it to work with such tags, I first hit Saving hooks , discarded them and began to look for how to implement my own type of field in which I could implement TagManager launch. But I didn’t master it, the code is rather complicated there. And here I noticed that when the old tags are configured in the admin on the edit page of the record, the list of tags continues to be displayed, and when the tags are saved, they fall into the $ tags property of the entity. True, the sonata does not save them to the database (this property does not have doctrine annotations, and it cannot, even if it were), but finding tags in the essence tag collection is exactly what is needed for TagManager to work! It remains to run the tag manager when changing the entity, and here it was Saving hooks that came in handy.

In the admin class, I did not change the description of the tag field, and the sonata enters the tags into the collection property when it is saved. With the help of postPersist and postUpdate hooks, the preservation of linking tags to the database is called:
  /** * @return FPN\TagBundle\Entity\TagManager */ protected function getTagManager() { return $this->getConfigurationPool()->getContainer() ->get('fpn_tag.tag_manager'); } public function postPersist($object) { $this->getTagManager()->saveTagging($object); } public function postUpdate($object) { $this->getTagManager()->saveTagging($object); } public function preRemove($object) { $this->getTagManager()->deleteTagging($object); $this->getDoctrine()->getManager()->flush(); } 

There is another ambush here - a bug in the Sonata , which leads to the fact that the preRemove and postRemove hooks are not called in a batch deletion (in the list). The solution to expanding the standard CRUD controller sonata:
Custom CRUD controller
 namespace App\AppBundle\Controller; use Sonata\AdminBundle\Controller\CRUDController as Controller; use Symfony\Component\HttpFoundation\RedirectResponse; use Sonata\AdminBundle\Datagrid\ProxyQueryInterface; class CRUDController extends Controller { public function publishAction() { $id = $this->get('request')->get($this->admin->getIdParameter()); $object = $this->admin->getObject($id); if (!$object) { throw new NotFoundHttpException(sprintf('unable to find the object with id : %s', $id)); } $object->togglePublished(); $this->admin->getModelManager()->update($object); $message = $object->getPublished() ? 'Publish successfully' : 'Unpublish successfully'; $trans = $this->get('translator.default'); $this->addFlash('sonata_flash_success', $trans->trans($message, array(), 'admin')); return new RedirectResponse($this->admin->generateUrl('list')); } public function batchActionDelete(ProxyQueryInterface $query) { if (method_exists($this->admin, 'preRemove')) { foreach ($query->getQuery()->iterate() as $object) { $this->admin->preRemove($object[0]); } } $response = parent::batchActionDelete($query); if (method_exists($this->admin, 'postRemove')) { foreach ($query->getQuery()->iterate() as $object) { $this->admin->postRemove($object[0]); } } return $response; } } 


The method for the publish button in the entity list has been added to the same controller. For this button, you need another twig-template and the addition of the configureListFields setting in the admin class:
Custom action template in the list
 {# src/App/AppBundle/Resources/views/CRUD/list__action_publish.html.twig #} {% if object.published %} <a class="btn btn-sm btn-danger" href="{{ admin.generateObjectUrl('publish', object) }}"> {% trans from 'admin' %}Unpublish{% endtrans %} </a> {% else %} <a class="btn btn-sm btn-success" href="{{ admin.generateObjectUrl('publish', object) }}"> {% trans from 'admin' %}Publish{% endtrans %} </a> {% endif %} 


Customizing custom action in the list
 protected function configureListFields(ListMapper $listMapper) { $listMapper //   ->add('_action', 'actions', array( 'actions' => array( 'Publish' => array( 'template' => 'AppBundle:CRUD:list__action_publish.html.twig' ) ) )) ; } 


To enable the extended controller, you need to pass its name (AppBundle: CRUD) as the third argument in the service setting.

The next task is to display already assigned tags when editing an entity. It is solved quite simply - you need to pass a list of tags in the tags field of the entity type. This is exactly the part that could not be put into the AdminExtension extension, otherwise I would have done just that.
Output assigned tags
 protected function configureFormFields(FormMapper $formMapper) { $tags = $this->hasSubject() ? $this->getTagManager()->loadTagging($this->getSubject()) : array(); $formMapper //   ->add('tags', 'entity', array( 'class'=>'AppBundle:Tag', 'choices' => $tags, 'multiple' => true, 'attr'=>array('style'=>'width: 100%;')) ) ; } 



Conclusion


Thus, it turned out to introduce a good convenient FPNTagBundle bundle into the SonataAdminBundle admin panel, ensure that all links are saved in one common table, and also study the insides of the Sonata.

Bonus - requests to work with tags


Some time ago, I promised in comments to post an article with a set of SQL queries for working with tags. I did not make a separate article, I will give them here.

Given:

Task: Find all articles and news containing the specified tags, first display the records containing all three specified tags, then print the records containing at least two of any entered tags, and at the end display the records containing at least one tag.

The first query displays the id of the records found (and the type of record)
 SELECT resource_id, resource_type, count(*) as weight FROM Tagging WHERE tag_id IN (1,2,3) GROUP BY resource_id ORDER BY weight DESC 

The second query lists the articles found:
 SELECT Article.id, Article.title FROM Tagging, Article WHERE Tagging.resource_id=Article.id AND Tagging.tag_id IN (1,2,3) GROUP BY Tagging.resource_id ORDER BY count(*) DESC 

Habrapheme Nashev proposed query option with the exclusion of tags, that is, display all records containing tags (1, 2, 3) and not containing (4, 5, 6):
 SELECT resource_id, resource_type FROM Tagging WHERE tag_id IN (1,2,3) AND resource_id NOT IN (SELECT resource_id FROM Tagging WHERE tag_id IN (4,5,6)) GROUP BY resource_id ORDER BY count(*) DESC 

Source: https://habr.com/ru/post/233695/


All Articles