I’m working on a Symfony project where I need to populate two dropdowns dynamically based on user selections. The process involves selecting a "brand" which then updates the "model" dropdown with relevant options. I’m using AJAX to handle the dynamic updates and the PRE_SUBMIT event to modify the form before submission.
Problem:
I’m encountering an issue where Symfony’s validation fails when submitting the form, throwing an error: The selected choice is invalid.
Code
AnnonceFormType :
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('category', ChoiceType::class, [
'label' => $this->translator->trans('Sélectioner la catégorie *'),
'choices' => [
$this->translator->trans('Auto') => $this->translator->trans('auto'),
$this->translator->trans('Moto') => $this->translator->trans('moto'),
],
'expanded' => true,
'multiple' => false,
'attr' => [
'class' => 'form-check-input',
'name' => 'category',
],
])
->add('marques', ChoiceType::class, [
'label' => false,
'choices' => [],
// 'choice_label' => function ($marque) {
// return $marque->getTitle(); // Afficher le titre de la marque
// },
// 'choice_value' => function ($marque) {
// return $marque ? $marque->getTitle() : ''; // Persister le titre
// },
'placeholder' => $this->translator->trans('Sélectionner la marque'),
])
->add('modeles', ChoiceType::class, [
'label' => false,
'choices' => [],
// 'choice_label' => function ($modele) {
// return $modele->getTitleModele(); // Afficher le titre du modèle
// },
// 'choice_value' => function ($modele) {
// return $modele ? $modele->getTitleModele() : ''; // Persister le titre
// },
'placeholder' => $this->translator->trans('Sélectionner le modéle'),
]);
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$data = $event->getData();
$form = $event->getForm();
if (isset($data['category'])) {
$marques = $this->getMarques($data['category']);
$form->add('marques', ChoiceType::class, [
'choices' => array_combine(array_map(function($marque) {
return $marque;
}, $marques), array_map(function($marque) {
return $marque;
}, $marques)),
'choice_label' => function ($marque) {
return $marque; // Afficher le titre
},
'choice_value' => function ($marque) {
return $marque; // Persister le titre
},
'placeholder' => $this->translator->trans('Sélectionner une marque'),
'label' => false,
]);
}
if (isset($data['marques'])) {
dump($data['marques']);
$marqueId = array_search($data['marques'], array_flip($this->getMarques($data['category'])));
dump($marqueId);
if($marqueId !== false){
$modeles = $this->getModeles($data['marques']);
$form->add('modeles', ChoiceType::class, [
'choices' => array_combine(array_map(function($modele) {
return $modele;
}, $modeles), array_map(function($modele) {
return $modele;
}, $modeles)),
'choice_label' => function ($modele) {
return $modele;
},
'choice_value' => function ($modele) {
return $modele;
},
'placeholder' => $this->translator->trans('Sélectionner un modèle'),
'label' => false,
]);
}
}
});
private function getMarques(?string $category): array
{
if (!$category) {
return [];
}
$marques = $this->marqueRepository->findMarques($category);
$choices = [];
foreach ($marques as $marque) {
$choices[$marque->getId()] = $marque->getTitle();
}
return $choices;
}
private function getModeles(?int $marqueId): array
{
if (!$marqueId) {
return [];
}
$modeles = $this->modeleRepository->findModeles($marqueId);
$choices = [];
foreach ($modeles as $modele) {
$choices[$modele->getId()] = $modele->getTitleModele();
}
return $choices;
}
controllers:
#[Route('/{_locale}/getmarques/{type}', name: 'app_getmarque')]
/**
* @return Response
*/
public function getMarques(string $type)
{
$marques = $this->marqueRepository->findMarques($type);
dump($marques);
return $this->render('addannonce/getmarques.html.twig', [
'marques' => $marques,
]);
}
#[Route('/{_locale}/getmodeles/{marque}', name: 'app_getmodele')]
/**
* @return Response
*/
public function getModeles(int $marque)
{
dump($marque);
$modeles = $this->modeleRepository->findModeles($marque);
dump($modeles);
return $this->render('addannonce/getmodeles.html.twig', [
'modeles' => $modeles,
]);
}
Ajax:
if (jQuery) {
// Fonction pour récupérer les marques en fonction du type sélectionné
function getMarques(type) {
let path = $("div.s-submit").data("path-getmarque");
if (path) {
path = path.replace("__type__", type);
$.ajax({
url: path,
dataType: "html",
type: "get",
cache: false,
beforeSend: function () {
// Affiche un indicateur de chargement
$("#AnnonceType1_marques").html("<option>Chargement des données...</option>");
},
success: function (data) {
console.log("getMarques", data);
$("#AnnonceType1_marques").html(data);
},
error: function (xhr, status, error) {
console.log("Erreur AJAX:", status, error);
alert(
"Une erreur est survenue lors de la récupération des marques. Veuillez réessayer."
);
},
});
} else {
console.log("Erreur: attribut de données non trouvé");
}
}
// Lorsque la sélection de marque change, récupérer les modèles correspondants
$("#AnnonceType1_marques").on("change", function () {
let marqueId = $(this).val();
let marqueText = $(this).find("option:selected").text();
console.log("Marque sélectionnée:", marqueText);
console.log("ID de la marque sélectionnée:", marqueId);
if (marqueId) {
let path = $("div.s-submit").data("path-getmodele");
if (path) {
path = path.replace("__id__", marqueId);
$.ajax({
url: path,
data: { marque: marqueId },
dataType: "html",
type: "get",
cache: false,
beforeSend: function () {
// Affiche un indicateur de chargement
$("#AnnonceType1_modeles").html("<option>Chargement des données...</option>");
},
success: function (data) {
console.log("getModeles", data);
$("#AnnonceType1_modeles").html(data);
},
error: function (xhr, status, error) {
console.log("Erreur AJAX:", status, error);
alert(
"Une erreur est survenue lors de la récupération des modèles. Veuillez réessayer."
);
},
});
}
} else {
$("#AnnonceType1_modeles").html(
'<option value="">Sélectionner la marque</option>'
);
}
});
// Log du modèle sélectionné
$("#AnnonceType1_modeles").on("change", function () {
let modeleId = $(this).val();
let modeleTitle = $(this).find("option:selected").text();
console.log("ID du modèle sélectionné:", modeleId);
console.log("Texte du modèle sélectionné:", modeleTitle);
});
} else {
console.log("Erreur: jQuery n'est pas chargé");
}
templates :
{% if(marques) %}
<option value="">--{{"Sélectionner"|trans }}--</option>
{% for marque in marques %}
<option value="{{ marque.id}}">{{ marque.title }}</option>
{% endfor %}
{% else %}
{{"pas de marque"|trans }}
{% endif %}
{% if(modeles) %}
<option value="">--{{"Sélectionner"|trans }}--</option>
{% for modele in modeles %}
<option value="{{ modele.id }}">
{{ modele.titleModele }}
</option>
{% endfor %}
{% else %}
{{"pas de modéle"|trans }}
{% endif %}
What I Tried:
- AJAX Requests: I used AJAX to fetch the available brands and models dynamically when the user selects a type or brand.
- Symfony PRE_SUBMIT Event Listener: I added an event listener to modify the form’s choices before submission. The listener updates the form fields with the correct options based on the submitted data.
- ChoiceType with Dynamic Data: I tried to reconfigure the ChoiceType fields with the appropriate titles as choices during the PRE_SUBMIT event.
What I Expected:
I expected the form to update dynamically with the correct models based on the selected brand, allowing the form to submit the titles (not the IDs) for both the brand and the model. The data should be correctly persisted as titles in the database without validation errors.
2
Answers
Finaly I have figured out after updating :
and in the controller :
Take a look at the live components from symfony UX bundle. There is create way, to make dynamic forms, without writing custom javascript.
https://ux.symfony.com/demos/live-component/dependent-form-fields
https://github.com/SymfonyCasts/dynamic-forms