Тип поля Symfony 2 Entity с выбором и / или добавлением нового

Контекст:

Пусть есть две сущности (правильно сопоставленные для Доктрины).

  1. Post со свойствами { $id (integer, autoinc), $name (string), $tags (коллекция Tag )}
  2. Tag со свойствами { $id (integer, autoinc), $name (строка), $posts (коллекция Post )}

Связь между этими двумя – это Many-To-Many .

Проблема:

При создании нового Post я хочу сразу добавить к нему теги.

Если бы я хотел добавить Tags которые уже существуют, я бы создал тип поля сущности , без проблем.

Но что мне делать, если бы я хотел добавить абсолютно новые Tags ? (Проверьте некоторые из уже существующих тегов, введите имя для нового тега, возможно, добавьте еще один новый тег, а затем после отправки назначить правильное назначение Post entity)

     Создать новое сообщение:
      Имя: [__________]

     Добавить теги
     |
     | [x] альфа
     | [] бета
     | [x] гамма
     |
     | Мой тег не существует, создайте новое:
     |
     | Название: [__________]
     |
     | + Добавить еще один новый тег

Есть какой-либо способ сделать это? Я знаю основы Symfony 2, но понятия не имею, как с этим бороться. Также удивил меня, что я нигде не нашел свой ответ, кажется для меня общей проблемой. Что мне не хватает?

У моего объекта тега есть уникальное поле для имени тега. Для добавления тегов я использую новый тип формы и трансформатор.

Тип формы:

 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'; } } 

Трансформатор:

 <?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; } } 

Конфигурация config.yml

 recipe.tags.type: class: Sg\RecipeBundle\Form\Type\TagType arguments: [@doctrine, @security.context] tags: - { name: form.type, alias: tag } 

используйте новый тип:

  ->add('tags', 'tag', array( 'label' => 'Tags', 'required' => false )) 

Сходства, такие как «symfony» и «smfony», могут быть предотвращены с помощью функции автозаполнения:

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> 

Я использовал несколько иной подход с использованием ввода тега Select2 :

Входной сигнал Select2

Преимущество заключается в том, что он предотвращает дублирование на стороне клиента и выглядит красиво.

Чтобы создать вновь добавленные объекты, я использую EventSubscriber, а не DataTransformer.

Еще несколько деталей см. В моей статье . Ниже приведены TagType и 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); } }