How to add an autocomplete field in a Symfony2 form for a collection and using Propel?

RayOnAir picture RayOnAir · Aug 15, 2012 · Viewed 16k times · Source

I'm using Symfony 2.1 forms with PropelBundle and I'm trying to refactor a form that had a drop-down list of objects (to select from) to instead use a jquery autocomplete field (working with AJAX). For the dropdown list I was using the following code (which worked perfectly for the drop-down) in my form type:

$builder->add('books', 'collection', array(
    'type'          => 'model',
    'options'       => array(
        'class'     => 'MyVendor\MyBundle\Model\Book',
        'property'  => 'title',
    ),
    'allow_add'     => true,
    'allow_delete'  => true,
    'by_reference'  => false,
    'required'      => false,
));

For the sake of giving a little context, let's say we are creating a new "Reader" object and that we would like to select the Reader's favorite books from a list of available "Book" objects. A collection type is used so that many "favorite books" can be selected in the new "Reader" form. Now, I would like to change the above to use autocomplete. For doing so, I tried to implement a Data Transformer to be able to get a Book object from a simple text field that could be used for the Autocomplete function to pass the Book ID as indicated in the answer to this Question. However, I was not able to figure out how to make the Data Transformer work with a collection type and Propel classes. I created a BookToIdTransformer class as indicated in the Symfony Cookbook and tried the following in the "ReaderType" file:

$transformer = new BookToIdTransformer();
$builder->add(
        $builder->create('books', 'collection', array(
            'type'          => 'text',
            'allow_add'     => true,
            'allow_delete'  => true,
            'by_reference'  => false,
            'required'      => false,
        ))->addModelTransformer($transformer)
);

With the above, I get a "Call to undefined method: getId" exception (apparently the Transformer expects a PropelCollection of Books, not a single Book object..). Does anyone know how to go about it? or let me know if there are other ways to implement the autocomplete in Symfony using Propel and allowing for selecting multiple objects (e.g. a collection of books)?

Answer

RayOnAir picture RayOnAir · Aug 20, 2012

The solution I ultimately went for is slightly different from my previous answer. I ended up using a "text" field type instead of a "collection" field type in my "ReaderType" form, since I ended up using the Loopj Tokeninput jQuery plugin which allows for selecting multiple objects (e.g. "Favorite Book") to associate to my main object (e.g. "Reader" object) in the form. Considering that, I created a "Data Transformer" for transforming the objects' ids passed (in a comma separated list in the text field) into a Propel Object Collection. The code is shared as follows, including a sample ajax object controller.

The key part of the "ReaderType" form looks as follows:

$transformer = new BooksToIdsTransformer();
$builder->add(
    $builder->create('books', 'text', array(
        'required' => false,
    ))->addModelTransformer($transformer)
);

The "Data Transformer" file looks like this:

// src/MyVendor/MyBundle/Form/DataTransformer/BooksToIdsTransformer.php
namespace MyVendor\MyBundle\Form\DataTransformer;

use \PropelObjectCollection;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use MyVendor\MyBundle\Model\BookQuery;

class BooksToIdsTransformer implements DataTransformerInterface
{
    public function transform($books)
    {
        if (null === $books) {
            return "";
        }

        if (!$books instanceof PropelObjectCollection) {
            throw new UnexpectedTypeException($books, '\PropelObjectCollection');
        }
        $idsArray = array();
        foreach ($books as $book) {
            $idsArray[] = $book->getId();
        }
        $ids = implode(",", $idsArray);
        return $ids;
    }

    public function reverseTransform($ids)
    {
        $books = new PropelObjectCollection();

        if ('' === $ids || null === $ids) {
            return $books;
        }

        if (!is_string($ids)) {
            throw new UnexpectedTypeException($ids, 'string');
        }
        $idsArray = explode(",", $ids);
        $idsArray = array_filter ($idsArray, 'is_numeric');
        foreach ($idsArray as $id) {
            $books->append(BookQuery::create()->findOneById($id));
        }
        return $books;
    }
}

The ajax controller that returns a json collection of "books" to the Tokeninput autocomplete function is as follows:

namespace MyVendor\MyBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use MyVendor\MyBundle\Model\BookQuery;


class ClassAjaxController extends Controller
{
    public function bookAction(Request $request)
    {
        $value = $request->get('q');

        $books = BookQuery::create()->findByName('%'.$value.'%');

        $json = array();
        foreach ($books as $book) {
            $json[] = array(
                'id' => $book->getId(),
                'name' => $book->getName()
            );
        }

        $response = new Response();
        $response->setContent(json_encode($json));

        return $response;
    }
}

And finally, the router in the "routing.yml" file:

ajax_book:
    pattern:  /ajax_book
    defaults: { _controller: MySiteBundle:ClassAjax:book }