Symfony 可重用 AJAX select / ChoiceType
Symfony reusable AJAX select / ChoiceType
我想使用 Symfony 表单类型创建一个可重复使用的 AJAX-based select (select2),我已经花了很多时间,但无法让它工作如我所愿。
据我所知,您无法在添加表单字段后覆盖它们的选项,因此您必须使用新配置 re-add 它们。 Symfony 文档还提供了一些关于如何使用事件动态添加或修改表单的示例。 https://symfony.com/doc/current/form/dynamic_form_modification.html
我已经成功地在表单中创建了基于 AJAX 的元素,它可以正常工作,但还不能完全重用:
- 表单字段扩展了 Doctrine EntityType 以完全支持数据映射器等
- 表单字段使用
'choices' => [],
初始化,因此 Doctrine 不会从 db 加载任何实体
- 在
FormEvents::PRE_SET_DATA
期间添加了现有的编辑选项
- 在
FormEvents::PRE_SUBMIT
期间添加了发布的选择
当前设置有效但仅适用于parent形式。我想让我的 AjaxNodeType
完全可重用,这样 parent 表单就不需要关心数据处理和事件。
按照这两个例子,parent 和 child 形成。这里有两个事件侦听器,但当然它们应该只有一个。
在单个元素类型中是否根本不可能替换 parent 形式中的“您自己”,还是我做错了什么?还有其他方法可以动态更改选项吗?
Parent形式
这有效!
class MyResultElementType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add(
'fixedNode',
AjaxNodeType::class,
[
'label' => 'Fixed node',
'required' => false,
]
);
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) {
if ($event->getData()) {
$fixedNode = $event->getData()->getFixedNode();
//this works here but not in child???
if ($fixedNode) {
$name = 'fixedNode';
$parentForm = $event->getForm();
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$fixedNode];
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
},
1000
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (PreSubmitEvent $event) {
$data = $event->getData()['fixedNode'];
if ($data) {
$name = 'fixedNode';
$parentForm = $event->getForm();
// we have to add the POST-ed data/node here to the choice list
// otherwise the submitted value is not valid
$node = $this->entityManager->find(Node::class, $data);
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$node];
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => MyResultElement::class,
'method' => 'POST',
]
);
}
}
Child形式/单select这行不通。在 POST 上,fixedNode
字段未设置为 data-entity.
形式
class AjaxNodeType extends AbstractType
{
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
//this does not work here but in parent???
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) {
if ($event->getData()) {
$fixedNode = $event->getData();
$name = $event->getForm()->getName();
$parentForm = $event->getForm()->getParent();
$options = $parentForm->get($name)->getConfig()->getOptions();
$newChoices = [$fixedNode];
// check if the choices already match, otherwise we'll end up in an endless loop ???
if ($options['choices'] !== $newChoices) {
$options['choices'] = $newChoices;
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
},
1000
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (PreSubmitEvent $event) {
if ($event->getData()) {
$name = $event->getForm()->getName();
$data = $event->getData();
$parentForm = $event->getForm()->getParent();
// we have to add the POST-ed data/node here to the choice list
// otherwise the submitted value is not valid
$node = $this->entityManager->find(Node::class, $data);
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$node];
$parentForm->add($name, self::class, $options);
}
},
1000
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'class' => Node::class,
// prevent doctrine from loading ALL nodes
'choices' => [],
]
);
}
public function getParent(): string
{
return EntityType::class;
}
}
再次回答我自己的问题:)
在花了更多时间之后,我得到了一个可行的解决方案。也许它会对某人有所帮助。
- 我已经从 EntityType 切换到 ChoiceType,因为它只会让事情变得更复杂而且实际上并不需要
- 多选和单选需要不同的settings/workarrounds(比如by_reference),见下面的行注释
- re-adding自己给parent form就行了,之前不知道为什么不行...
- 当 re-adding / re-submitting 值
时注意 endless-loops
执行所有逻辑的主要 re-usable AjaxEntityType
不引用任何特定实体:
<?php
namespace Tool\Form\SingleInputs;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManager;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AjaxEntityType extends AbstractType
{
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
// shamelessly copied from DoctrineType - this is needed to support Doctrine Collections in multi-selects
if ($options['multiple'] && interface_exists(Collection::class)) {
$builder
->addEventSubscriber(new MergeDoctrineCollectionListener())
->addViewTransformer(new CollectionToArrayTransformer(), true);
}
// PRE_SET_DATA is the entrypoint on form creation where we need to populate existing choices
// we process current data and set it as choices so it will be rendered correctly
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) use($options) {
$data = $event->getData();
$hasData = ($options['multiple'] && count($data) > 0) || (!$options['multiple'] && $data !== null);
if ($hasData) {
$entityOrList = $event->getData();
$name = $event->getForm()->getName();
$parentForm = $event->getForm()->getParent();
$options = $parentForm->get($name)->getConfig()->getOptions();
// ONLY do this if the choices are empty, otherwise readding PRE_SUBMIT will not work because this is called again!
if(empty($options['choices'])) {
if($options['multiple']) {
$newChoices = [];
foreach ($entityOrList as $item) {
$newChoices[$item->getId()] = $item;
}
} else {
$newChoices = [$entityOrList->getId() => $entityOrList];
}
$options['choices'] = $newChoices;
$parentForm->add($name, self::class, $options);
}
}
},
1000
);
// PRE_SUBMIT is the entrypoint where we need to process the submitted values
// we have to add the POST-ed choices, otherwise this field won't be valid
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) use($options) {
$entityIdOrList = $event->getData();
$entityClass = $options['class'];
// new choices constructed from POST
$newChoices = [];
if ($options['multiple']) {
foreach ($entityIdOrList as $id) {
if ($id) {
$newChoices[$id] = $this->entityManager->find($entityClass, $id);
}
}
} elseif ($entityIdOrList) {
$newChoices = [$entityIdOrList => $this->entityManager->find($entityClass, $entityIdOrList)];
}
$name = $event->getForm()->getName();
$parentform = $event->getForm()->getParent();
$currentChoices = $event->getForm()->getConfig()->getOptions()['choices'];
// if the user selected/posted new choices that have not been in the existing list, add them all
if ($newChoices && count(array_diff($newChoices, $currentChoices)) > 0) {
$options = $event->getForm()->getParent()->get($name)->getConfig()->getOptions();
$options['choices'] = $newChoices;
// re-add ourselves to the parent form with updated / POST-ed options
$parentform->add($name, self::class, $options);
if(!$parentform->get($name)->isSubmitted()) {
// after re-adding we also need to re-submit ourselves
$parentform->get($name)->submit($entityIdOrList);
}
}
}, 1000);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'mapped' => true,
'choice_value' => 'id',
'choice_label' => 'selectLabel',
'choices' => [],
'attr' => [
'class' => 'select2-ajax',
],
'ajax_url' => null,
]
);
// AJAX endpoint that is select2 compatible
// https://select2.org/data-sources/ajax
$resolver->setRequired('ajax_url');
$resolver->setAllowedTypes('ajax_url', ['string']);
// entity class to process
$resolver->setRequired('class');
// by_reference needs to be true for single-selects, otherwise our entities will be cloned!
// by_reference needs to be false for multi-selects, otherwise the setters wont be called for doctrine collections!
$resolver->setDefault('by_reference', function (Options $options) {
return !$options['multiple'];
});
// adds the ajax_url as attribute
$resolver->setNormalizer('attr', function (Options $options, $value) {
$value['data-custom-ajax-url'] = $options['ajax_url'];
return $value;
});
}
public function getParent(): string
{
return ChoiceType::class;
}
}
特定实体和ajax端点的实际使用情况:
<?php
namespace Tool\Form\SingleInputs;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Tool\Entities\User\Node;
class AjaxNodeType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'class' => Node::class,
'ajax_url' => 's/ajax-select/nodes',
]
);
}
public function getParent(): string
{
return AjaxEntityType::class;
}
}
我想使用 Symfony 表单类型创建一个可重复使用的 AJAX-based select (select2),我已经花了很多时间,但无法让它工作如我所愿。
据我所知,您无法在添加表单字段后覆盖它们的选项,因此您必须使用新配置 re-add 它们。 Symfony 文档还提供了一些关于如何使用事件动态添加或修改表单的示例。 https://symfony.com/doc/current/form/dynamic_form_modification.html
我已经成功地在表单中创建了基于 AJAX 的元素,它可以正常工作,但还不能完全重用:
- 表单字段扩展了 Doctrine EntityType 以完全支持数据映射器等
- 表单字段使用
'choices' => [],
初始化,因此 Doctrine 不会从 db 加载任何实体
- 在
FormEvents::PRE_SET_DATA
期间添加了现有的编辑选项
- 在
FormEvents::PRE_SUBMIT
期间添加了发布的选择
当前设置有效但仅适用于parent形式。我想让我的 AjaxNodeType
完全可重用,这样 parent 表单就不需要关心数据处理和事件。
按照这两个例子,parent 和 child 形成。这里有两个事件侦听器,但当然它们应该只有一个。
在单个元素类型中是否根本不可能替换 parent 形式中的“您自己”,还是我做错了什么?还有其他方法可以动态更改选项吗?
Parent形式 这有效!
class MyResultElementType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add(
'fixedNode',
AjaxNodeType::class,
[
'label' => 'Fixed node',
'required' => false,
]
);
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) {
if ($event->getData()) {
$fixedNode = $event->getData()->getFixedNode();
//this works here but not in child???
if ($fixedNode) {
$name = 'fixedNode';
$parentForm = $event->getForm();
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$fixedNode];
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
},
1000
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (PreSubmitEvent $event) {
$data = $event->getData()['fixedNode'];
if ($data) {
$name = 'fixedNode';
$parentForm = $event->getForm();
// we have to add the POST-ed data/node here to the choice list
// otherwise the submitted value is not valid
$node = $this->entityManager->find(Node::class, $data);
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$node];
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => MyResultElement::class,
'method' => 'POST',
]
);
}
}
Child形式/单select这行不通。在 POST 上,fixedNode
字段未设置为 data-entity.
class AjaxNodeType extends AbstractType
{
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
//this does not work here but in parent???
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) {
if ($event->getData()) {
$fixedNode = $event->getData();
$name = $event->getForm()->getName();
$parentForm = $event->getForm()->getParent();
$options = $parentForm->get($name)->getConfig()->getOptions();
$newChoices = [$fixedNode];
// check if the choices already match, otherwise we'll end up in an endless loop ???
if ($options['choices'] !== $newChoices) {
$options['choices'] = $newChoices;
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
},
1000
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (PreSubmitEvent $event) {
if ($event->getData()) {
$name = $event->getForm()->getName();
$data = $event->getData();
$parentForm = $event->getForm()->getParent();
// we have to add the POST-ed data/node here to the choice list
// otherwise the submitted value is not valid
$node = $this->entityManager->find(Node::class, $data);
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$node];
$parentForm->add($name, self::class, $options);
}
},
1000
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'class' => Node::class,
// prevent doctrine from loading ALL nodes
'choices' => [],
]
);
}
public function getParent(): string
{
return EntityType::class;
}
}
再次回答我自己的问题:) 在花了更多时间之后,我得到了一个可行的解决方案。也许它会对某人有所帮助。
- 我已经从 EntityType 切换到 ChoiceType,因为它只会让事情变得更复杂而且实际上并不需要
- 多选和单选需要不同的settings/workarrounds(比如by_reference),见下面的行注释
- re-adding自己给parent form就行了,之前不知道为什么不行...
- 当 re-adding / re-submitting 值 时注意 endless-loops
执行所有逻辑的主要 re-usable AjaxEntityType
不引用任何特定实体:
<?php
namespace Tool\Form\SingleInputs;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManager;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AjaxEntityType extends AbstractType
{
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
// shamelessly copied from DoctrineType - this is needed to support Doctrine Collections in multi-selects
if ($options['multiple'] && interface_exists(Collection::class)) {
$builder
->addEventSubscriber(new MergeDoctrineCollectionListener())
->addViewTransformer(new CollectionToArrayTransformer(), true);
}
// PRE_SET_DATA is the entrypoint on form creation where we need to populate existing choices
// we process current data and set it as choices so it will be rendered correctly
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) use($options) {
$data = $event->getData();
$hasData = ($options['multiple'] && count($data) > 0) || (!$options['multiple'] && $data !== null);
if ($hasData) {
$entityOrList = $event->getData();
$name = $event->getForm()->getName();
$parentForm = $event->getForm()->getParent();
$options = $parentForm->get($name)->getConfig()->getOptions();
// ONLY do this if the choices are empty, otherwise readding PRE_SUBMIT will not work because this is called again!
if(empty($options['choices'])) {
if($options['multiple']) {
$newChoices = [];
foreach ($entityOrList as $item) {
$newChoices[$item->getId()] = $item;
}
} else {
$newChoices = [$entityOrList->getId() => $entityOrList];
}
$options['choices'] = $newChoices;
$parentForm->add($name, self::class, $options);
}
}
},
1000
);
// PRE_SUBMIT is the entrypoint where we need to process the submitted values
// we have to add the POST-ed choices, otherwise this field won't be valid
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) use($options) {
$entityIdOrList = $event->getData();
$entityClass = $options['class'];
// new choices constructed from POST
$newChoices = [];
if ($options['multiple']) {
foreach ($entityIdOrList as $id) {
if ($id) {
$newChoices[$id] = $this->entityManager->find($entityClass, $id);
}
}
} elseif ($entityIdOrList) {
$newChoices = [$entityIdOrList => $this->entityManager->find($entityClass, $entityIdOrList)];
}
$name = $event->getForm()->getName();
$parentform = $event->getForm()->getParent();
$currentChoices = $event->getForm()->getConfig()->getOptions()['choices'];
// if the user selected/posted new choices that have not been in the existing list, add them all
if ($newChoices && count(array_diff($newChoices, $currentChoices)) > 0) {
$options = $event->getForm()->getParent()->get($name)->getConfig()->getOptions();
$options['choices'] = $newChoices;
// re-add ourselves to the parent form with updated / POST-ed options
$parentform->add($name, self::class, $options);
if(!$parentform->get($name)->isSubmitted()) {
// after re-adding we also need to re-submit ourselves
$parentform->get($name)->submit($entityIdOrList);
}
}
}, 1000);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'mapped' => true,
'choice_value' => 'id',
'choice_label' => 'selectLabel',
'choices' => [],
'attr' => [
'class' => 'select2-ajax',
],
'ajax_url' => null,
]
);
// AJAX endpoint that is select2 compatible
// https://select2.org/data-sources/ajax
$resolver->setRequired('ajax_url');
$resolver->setAllowedTypes('ajax_url', ['string']);
// entity class to process
$resolver->setRequired('class');
// by_reference needs to be true for single-selects, otherwise our entities will be cloned!
// by_reference needs to be false for multi-selects, otherwise the setters wont be called for doctrine collections!
$resolver->setDefault('by_reference', function (Options $options) {
return !$options['multiple'];
});
// adds the ajax_url as attribute
$resolver->setNormalizer('attr', function (Options $options, $value) {
$value['data-custom-ajax-url'] = $options['ajax_url'];
return $value;
});
}
public function getParent(): string
{
return ChoiceType::class;
}
}
特定实体和ajax端点的实际使用情况:
<?php
namespace Tool\Form\SingleInputs;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Tool\Entities\User\Node;
class AjaxNodeType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'class' => Node::class,
'ajax_url' => 's/ajax-select/nodes',
]
);
}
public function getParent(): string
{
return AjaxEntityType::class;
}
}