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>
.
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? 😉