Подтвердить что ты не робот

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

Контекст:

Пусть есть две сущности (правильно отображаемые для Doctrine).

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

Отношения между этими двумя являются Many-To-Many.

Проблема:

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

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

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

    Create new Post:
     Name: [__________]

    Add tags
    |
    |[x] alpha
    |[ ] beta
    |[x] gamma
    |
    |My tag doesnt exist, create new:
    |
    |Name: [__________]
    |
    |+Add another new tag

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

4b9b3361

Ответ 1

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

Тип формы:

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>

Ответ 2

Я использовал несколько иной подход, используя Select2 tag input:

Select2 tag input

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

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

Для получения более подробной информации см. мою статью. Ниже приведены TagType и AddEntityChoiceSubscriber.

AppBundle/Форма/тип/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/Форма/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 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);
    }
}