Let there be two entities (correctly mapped for Doctrine).
Post
with properties {$id
(integer, autoinc), $name
My Tag entity has a unique field for the tag name. For add Tags I use a new form type and a transformer.
The Form Type:
namespace Sg\RecipeBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Sg\RecipeBundle\Form\DataTransformer\TagsDataTransformer;
class TagType extends AbstractType
{
/**
* @var RegistryInterface
*/
private $registry;
/**
* @var SecurityContextInterface
*/
private $securityContext;
/**
* Ctor.
*
* @param RegistryInterface $registry A RegistryInterface instance
* @param SecurityContextInterface $securityContext A SecurityContextInterface instance
*/
public function __construct(RegistryInterface $registry, SecurityContextInterface $securityContext)
{
$this->registry = $registry;
$this->securityContext = $securityContext;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addViewTransformer(
new TagsDataTransformer(
$this->registry,
$this->securityContext
),
true
);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return 'text';
}
/**
* {@inheritdoc}
*/
public function getName()
{
return 'tag';
}
}
The Transformer:
<?php
/*
* Stepan Tanasiychuk is the author of the original implementation
* see: https://github.com/stfalcon/BlogBundle/blob/master/Bridge/Doctrine/Form/DataTransformer/EntitiesToStringTransformer.php
*/
namespace Sg\RecipeBundle\Form\DataTransformer;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Doctrine\ORM\EntityManager;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ArrayCollection;
use Sg\RecipeBundle\Entity\Tag;
/**
* Tags DataTransformer.
*/
class TagsDataTransformer implements DataTransformerInterface
{
/**
* @var EntityManager
*/
private $em;
/**
* @var SecurityContextInterface
*/
private $securityContext;
/**
* Ctor.
*
* @param RegistryInterface $registry A RegistryInterface instance
* @param SecurityContextInterface $securityContext A SecurityContextInterface instance
*/
public function __construct(RegistryInterface $registry, SecurityContextInterface $securityContext)
{
$this->em = $registry->getEntityManager();
$this->securityContext = $securityContext;
}
/**
* Convert string of tags to array.
*
* @param string $string
*
* @return array
*/
private function stringToArray($string)
{
$tags = explode(',', $string);
// strip whitespaces from beginning and end of a tag text
foreach ($tags as &$text) {
$text = trim($text);
}
// removes duplicates
return array_unique($tags);
}
/**
* Transforms tags entities into string (separated by comma).
*
* @param Collection | null $tagCollection A collection of entities or NULL
*
* @return string | null An string of tags or NULL
* @throws UnexpectedTypeException
*/
public function transform($tagCollection)
{
if (null === $tagCollection) {
return null;
}
if (!($tagCollection instanceof Collection)) {
throw new UnexpectedTypeException($tagCollection, 'Doctrine\Common\Collections\Collection');
}
$tags = array();
/**
* @var \Sg\RecipeBundle\Entity\Tag $tag
*/
foreach ($tagCollection as $tag) {
array_push($tags, $tag->getName());
}
return implode(', ', $tags);
}
/**
* Transforms string into tags entities.
*
* @param string | null $data Input string data
*
* @return Collection | null
* @throws UnexpectedTypeException
* @throws AccessDeniedException
*/
public function reverseTransform($data)
{
if (!$this->securityContext->isGranted('ROLE_AUTHOR')) {
throw new AccessDeniedException('Für das Speichern von Tags ist die Autorenrolle notwendig.');
}
$tagCollection = new ArrayCollection();
if ('' === $data || null === $data) {
return $tagCollection;
}
if (!is_string($data)) {
throw new UnexpectedTypeException($data, 'string');
}
foreach ($this->stringToArray($data) as $name) {
$tag = $this->em->getRepository('SgRecipeBundle:Tag')
->findOneBy(array('name' => $name));
if (null === $tag) {
$tag = new Tag();
$tag->setName($name);
$this->em->persist($tag);
}
$tagCollection->add($tag);
}
return $tagCollection;
}
}
The config.yml
recipe.tags.type:
class: Sg\RecipeBundle\Form\Type\TagType
arguments: [@doctrine, @security.context]
tags:
- { name: form.type, alias: tag }
use the new Type:
->add('tags', 'tag', array(
'label' => 'Tags',
'required' => false
))
Similarities, like "symfony" and "smfony" can be prevented with an autocomplete function:
TagController:
<?php
namespace Sg\RecipeBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
/**
* Tag controller.
*
* @Route("/tag")
*/
class TagController extends Controller
{
/**
* Get all Tag entities.
*
* @Route("/tags", name="tag_tags")
* @Method("GET")
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function getTagsAction()
{
$request = $this->getRequest();
$isAjax = $request->isXmlHttpRequest();
if ($isAjax) {
$em = $this->getDoctrine()->getManager();
$search = $request->query->get('term');
/**
* @var \Sg\RecipeBundle\Entity\Repositories\TagRepository $repository
*/
$repository = $em->getRepository('SgRecipeBundle:Tag');
$qb = $repository->createQueryBuilder('t');
$qb->select('t.name');
$qb->add('where', $qb->expr()->like('t.name', ':search'));
$qb->setMaxResults(5);
$qb->orderBy('t.name', 'ASC');
$qb->setParameter('search', '%' . $search . '%');
$results = $qb->getQuery()->getScalarResult();
$json = array();
foreach ($results as $member) {
$json[] = $member['name'];
};
return new Response(json_encode($json));
}
return new Response('This is not ajax.', 400);
}
}
form.html.twig:
<script type="text/javascript">
$(document).ready(function() {
function split(val) {
return val.split( /,\s*/ );
}
function extractLast(term) {
return split(term).pop();
}
$("#sg_recipebundle_recipetype_tags").autocomplete({
source: function( request, response ) {
$.getJSON( "{{ path('tag_tags') }}", {
term: extractLast( request.term )
}, response );
},
search: function() {
// custom minLength
var term = extractLast( this.value );
if ( term.length < 2 ) {
return false;
}
},
focus: function() {
// prevent value inserted on focus
return false;
},
select: function( event, ui ) {
var terms = split( this.value );
// remove the current input
terms.pop();
// add the selected item
terms.push( ui.item.value );
// add placeholder to get the comma-and-space at the end
terms.push( "" );
this.value = terms.join( ", " );
return false;
}
});
});
</script>
I took a slightly different approach using Select2's tag input:
It has the advantage that it prevents duplicates on the client side and looks pretty.
To create the newly added entities, I am using a EventSubscriber rather than a DataTransformer.
For a few more details, see my gist. Below are the TagType and the AddEntityChoiceSubscriber.
AppBundle/Form/Type/TagType:
<?php
namespace AppBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use AppBundle\Form\EventListener\AddEntityChoiceSubscriber;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
class TagType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$subscriber = new AddEntityChoiceSubscriber($options['em'], $options['class']);
$builder->addEventSubscriber($subscriber);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return EntityType::class;
}
/**
* {@inheritdoc}
*/
public function getName()
{
return 'tag';
}
}
AppBundle/Form/EventListener/AddEntityChoiceSubscriber:
<?php
namespace TriprHqBundle\Form\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;
class AddEntityChoiceSubscriber implements EventSubscriberInterface
{
/**
* @var EntityManager
*/
protected $em;
/**
* The name of the entity
*
* @var string
*/
protected $entityName;
public function __construct(EntityManager $em, string $entityName)
{
$this->em = $em;
$this->entityName = $entityName;
}
public static function getSubscribedEvents()
{
return [
FormEvents::PRE_SUBMIT => 'preSubmit',
];
}
public function preSubmit(FormEvent $event)
{
$data = $event->getData();
if (!is_array($data) && !($data instanceof \Traversable && $data instanceof \ArrayAccess)) {
$data = [];
}
// loop through all values
$repository = $this->em->getRepository($this->entityName);
$choices = array_map('strval', $repository->findAll());
$className = $repository->getClassName();
$newChoices = [];
foreach($data as $key => $choice) {
// if it's numeric we consider it the primary key of an existing choice
if(is_numeric($choice) || in_array($choice, $choices)) {
continue;
}
$entity = new $className($choice);
$newChoices[] = $entity;
$this->em->persist($entity);
}
$this->em->flush();
// now we need to replace the text values with their new primary key
// otherwise, the newly added choice won't be marked as selected
foreach($newChoices as $newChoice) {
$key = array_search($newChoice->__toString(), $data);
$data[$key] = $newChoice->getId();
}
$event->setData($data);
}
}