Symfony 7嵌套下拉动态表单报错及优化咨询
问题与解决方案
问题背景
我尝试遵循Symfony官方文档《How to Dynamically Modify Forms Using Form Events》实现嵌套下拉动态表单,仅将示例中的Meetup实体替换为Event实体,但遇到了以下问题:
相关代码
Event表单(src/Form/EventType.php)
<?php namespace App\Form; use App\Entity\Event; use App\Entity\Position; use App\Entity\Sport; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class EventType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('name', TextType::class, ['label' => 'Nom de l\'évènement']) ->add('sport', EntityType::class, [ 'class' => Sport::class, 'placeholder' => '--- Sélectionner un sport ---', 'label' => 'Sport', 'choice_label' => 'name' ]) ; // $formModifier = function (FormInterface $form, Sport $sport = null): void { $positions = null === $sport ? [] : $sport->getPositions(); $form->add('position', EntityType::class, [ 'class' => Position::class, 'placeholder' => '--- Sélectionner un poste ---', 'choices' => $positions, ]); }; $builder->addEventListener( FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($formModifier): void { // this would be your entity, i.e. SportMeetup $data = $event->getData(); $formModifier($event->getForm(), $data->getSport()); } ); $builder->get('sport')->addEventListener( /* /!\ il y a 2 classes à importer : * 'FormEvents' AVEC un 's' * et 'FormEvent' sans 's' * "c'est génial !!!" (Patrick S.) */ FormEvents::POST_SUBMIT, function (FormEvent $event) use ($formModifier): void { // It's important here to fetch $event->getForm()->getData(), as // $event->getData() will get you the client data (that is, the ID) $sport = $event->getForm()->getData(); // since we've added the listener to the child, we'll have to pass on // the parent to the callback function! $formModifier($event->getForm()->getParent(), $sport); } ); // by default, action does not appear in the <form> tag // you can set this value by passing the controller route $builder->setAction($options['action']); } // -- buildForm() public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Event::class, // Ajout d'un atrribut HTML 'id' au formulaire pour le manipuler en JS 'attr' => [ 'id' => 'form_event_new'] ]); } }
Ajax请求(templates/event/new.html.twig)
{% extends 'base.html.twig' %} {% block title %}New Event{% endblock %} {% block body %} <h1>Create new Event</h1> {{ include('event/_form.html.twig') }} <a href="{{ path('app_event_index') }}">back to list</a> <script> {# S'assurer d'avoir les bons noms de route et de champs #} const form = document.getElementById('form_event_new'); const form_select_sport = document.getElementById('event_sport'); const form_select_position = document.getElementById('event_position'); const updateForm = async (data, url, method) => { const req = await fetch(url, { method: method, body: data, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'charset': 'utf-8' } }); const text = await req.text(); return text; }; const parseTextToHtml = (text) => { const parser = new DOMParser(); const html = parser.parseFromString(text, 'text/html'); return html; }; const changeOptions = async (e) => { // target = 'event_sport' // getAttribute('name') = const requestBody = e.target.getAttribute('name') + '=' + e.target.value; console.log('-------------------------------------------'); console.log('requestBody : '+requestBody); console.log('-------------------------------------------'); const updateFormResponse = await updateForm(requestBody, form.getAttribute('action'), form.getAttribute('method')); const html = parseTextToHtml(updateFormResponse); const new_form_select_position = html.getElementById('event_position'); console.log(new_form_select_position); form_select_position.innerHTML = new_form_select_position.innerHTML; }; form_select_sport.addEventListener('change', (e) => changeOptions(e)); </script> {% endblock %}
Event控制器new方法
#[Route('/event/new', name: 'app_event_new', methods: ['GET', 'POST'])] public function new(Request $request, EntityManagerInterface $entityManager): Response { $event = new Event(); /* * Il faut spécifier un 3ème argument à la méthode createForm() : un attribut 'action' avec comme valeur * la route où poster le formulaire (c'est bien la même qua sans l'attribut, c'est-à-dire cette méthode * mais il faut la préciser pour que le script JS/Ajax puisse la récupérer * cf. https://symfony.com/doc/current/form/dynamic_form_modification.html#dynamic-generation-for-submitted-forms */ $form = $this->createForm(EventType::class, $event, ['action' => $this->generateUrl('app_event_new')]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $entityManager->persist($event); $entityManager->flush(); return $this->redirectToRoute('app_event_index', [], Response::HTTP_SEE_OTHER); } return $this->render('event/new.html.twig', [ 'event' => $event, 'form' => $form, ]); }
表单HTML代码
<form name="event" method="post" action="/event/new" id="form_event_new"> <div id="event" id="form_event_new"> <div class="mb-3 row"> <label class="col-form-label col-sm-2 required" for="event_name">Nom de l&#039;évènement</label> <div class="col-sm-10"><input type="text" id="event_name" name="event[name]" required="required" class="form-control" /> </div> </div> <div class="mb-3 row"> <label class="col-form-label col-sm-2 required" for="event_sport">Sport</label> <div class="col-sm-10"> <select id="event_sport" name="event[sport]" required="required" class="form-select"> <option value="" selected="selected">--- Sélectionner un sport ---</option> <option value="1">Football</option> <option value="2">Baseball</option> <option value="3">Rallye WRC</option> </select> </div> </div> <div class="mb-3 row"> <label class="col-form-label col-sm-2 required" for="event_position">Position</label> <div class="col-sm-10"> <select id="event_position" name="event[position]" required="required" class="form-select"> <option value="" selected="selected">--- Sélectionner un poste ---</option> </select> </div> </div> <input type="hidden" id="event__token" name="event[_token]" value="893083a7e1e0f0cabbf87843.t9skvnXm-GNKNpPmCrcSKh5kQhKr7wiENTyC0bme-40.4aoVjTi_zikORuatbNxCTSw2Nnn010G1ak_EncDXiNnxiHGHItW3N2d73g" /></div> <button class="btn">Save</button> </form>
问题解答
1. Ajax请求报错:Uncaught (in promise) TypeError: new_form_select_position is null
这个错误是因为Ajax提交后,返回的HTML里找不到event_position元素,核心原因有两个:
- 表单验证失败:仅提交
sport字段时,其他必填字段(如name)为空,导致表单验证不通过,Symfony重新渲染表单时可能未正确生成position字段。 - CSRF令牌缺失:Ajax请求未携带CSRF令牌,Symfony拒绝处理请求,返回的页面未正确渲染表单。
解决方案:
- 带上CSRF令牌:修改Ajax请求的请求体,添加令牌参数:
const csrfToken = document.getElementById('event__token').value; const requestBody = `${e.target.getAttribute('name')}=${e.target.value}&event[_token]=${csrfToken}`; - 区分Ajax与完整表单提交:在控制器中判断是否为Ajax请求,仅处理Sport字段并返回表单片段:
if ($request->isXmlHttpRequest()) { $form->handleRequest($request); return $this->render('event/_form.html.twig', [ 'form' => $form, ]); }
2. $sport->getAvailablePosition()是否应改为$sport->getPositions()
是的,完全正确。官方示例中的getAvailablePosition()是对应示例实体的方法,你的实体中与Position的关联是positions,因此必须使用$sport->getPositions()来获取当前Sport对应的Position集合,否则会调用不存在的方法导致错误。
3. 是否有更简单的实现方式,仅用Ajax不借助表单事件?
有,你可以单独创建一个JSON接口返回对应Sport的Position选项,无需依赖Symfony表单事件:
步骤:
添加控制器路由:
#[Route('/positions-by-sport', name: 'app_positions_by_sport', methods: ['GET'])] public function getPositionsBySport(Request $request, EntityManagerInterface $em): JsonResponse { $sportId = $request->query->get('sportId'); if (!$sportId) { return new JsonResponse([]); } $sport = $em->getRepository(Sport::class)->find($sportId); if (!$sport) { return new JsonResponse([]); } $positions = []; foreach ($sport->getPositions() as $position) { $positions[] = [ 'id' => $position->getId(), 'name' => $position->getName() ]; } return new JsonResponse($positions); }修改前端JS:
直接请求接口获取选项并更新下拉框:const changeOptions = async (e) => { const sportId = e.target.value; const response = await fetch(`/positions-by-sport?sportId=${sportId}`); const positions = await response.json(); // 清空现有选项 form_select_position.innerHTML = '<option value="" selected="selected">--- Sélectionner un poste ---</option>'; // 添加新选项 positions.forEach(pos => { const option = document.createElement('option'); option.value = pos.id; option.textContent = pos.name; form_select_position.appendChild(option); }); };
这种方式更轻量,逻辑更清晰,避免了表单验证带来的问题。
内容的提问来源于stack exchange,提问作者user3049922
相关产品推荐
相关产品推荐

