You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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&amp;#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表单事件:

步骤:

  1. 添加控制器路由:

    #[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);
    }
    
  2. 修改前端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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.28 15:49:51