Florent Destremau

Using Symfony Autocomplete to create entities on-the-fly in a form

version française ici

I recently needed to create a user creation form, where I needed to fill in attributes. These attributes are entities in their own right and should be able to be created on the fly when entering the form.

Being already a user of the Aucomplete component on our forms, which uses the Tom Select library, I thought that we could naturally use the create property which allows you to enter new entries within an existing <select>.

Screenshot of Tom Select usage

Unfortunately, this was not natively supported. The solution I found was therefore to make use of a particular application case of the create option: we can provide it with a function which takes a callback as an argument, which allows us to...do everything , in short.

create: function(input,callback){
callback({value:input,text:input});
}

So, what I basically wanted was to provide a callback like this:

create: function (input, callback) {
     fetch('/api/attributes', {
         method: 'POST',
         body: JSON.stringify({name: input}),
     })
         .then(response => response.json())
         .then(data => callback({value: data.id, text: data.name}));
}

Well, at this stage it works in theory, but I don't really like the idea of leaving a clear URL in my part of JS, and then I don't really have a clear place where I can activate this function . The "tom_select_options" option in my form does not allow me to pass a javascript function.

     // src/Form/UserType.php

     public function buildForm(FormBuilderInterface $builder, array $options): void
     {
         $builder
             ->add('name')
             ->add('email')
             ->add('attributes', EntityType::class, [
                 'multiple' => true,
                 'required' => false,
                 'autocomplete' => true,
                 'class' => Attribute::class,
                 'by_reference' => false,
                 'tom_select_options' => ['create' => true],
             ]);
     }

So my solution is to follow the doc and create a dedicated stimulus controller.

In a first attempt, I will continue to hard-code the url:

//assets/controllers/custom-autocomplete_controller.js
import {Controller} from '@hotwired/stimulus';

export default class extends Controller {
     connect() {
         this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect);
     }

     disconnect() {
         this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect());
     }

     _onPreConnect = (event) => {
         event.detail.options.create = function (input, callback) {
             const data = new FormData();
             data.append('attribute[name]', input);
             fetch('/attribute/new?ajax=1', {
                 method: 'POST',
                 body:data,
             })
                 .then(response => response.json())
                 .then(data => callback({value: data.id, text: data.name}));
         }
     }
}
     ->add('attributes', EntityType::class, [
         'multiple' => true,
         'required' => false,
         'autocomplete' => true,
         'class' => Attribute::class,
         'by_reference' => false,
         'attr' => [
             'data-controller' => 'custom-autocomplete',
         ],
     ]);

At this point, I have my route doing its job, and I do have some attribute creation on the fly, and then I submit my form. Last step: configure the URL to keep the stimulus controller anonymous.

// src/Form/UserType

     'attr' => [
         'data-controller' => 'custom-autocomplete',
         'data-custom-autocomplete-url-value' => '/attribute/new',
     ],
//assets/controllers/custom-autocomplete_controller.js

export default class extends Controller {
     static values = {url:String}

     // [...]

     _onPreConnect = (event) => {
         const url = this.urlValue;
         event.detail.options.create = function (input, callback) {
             const data = new FormData();
             data.append('name', input);
             fetch(url, {
                 method: 'POST',
                 body:data,
             })
                 .then(response => response.json())
                 .then(data => callback({value: data.id, text: data.name}));
         }
     }
}

You can also use the UrlGenerator service to optimize. The Symfony controller is very ordinary.

     #[Route('/new', name: 'app_attribute_new', methods: ['GET', 'POST'])]
     public function new(Request $request, EntityManagerInterface $entityManager): Response
     {
         $attribute = new Attribute();
         $form = $this->createForm(AttributeType::class, $attribute);
         $form->handleRequest($request);

         if ($form->isSubmitted() && $form->isValid()) {
             $entityManager->persist($attribute);
             $entityManager->flush();

             return $this->json([
                 'id' => $attribute->getId(),
                 'name' => $attribute->getName(),
             ]);
         }

         // [...]
      }

To test, I made a quick project which brings together all these ideas, with the different steps in commit, available here:

Next step: make a PR on Symfony UX to integrate this natively? 😉