Skip to content

[Form] Respect order of submitted data when dealing with sorted collection models #21595

@backbone87

Description

@backbone87
Q A
Bug report? no
Feature request? yes
BC Break report? no
RFC? n/a
Symfony version all

Use cases (few select ones):

  • expanded/multiple ChoiceType (checkbox list) sortable via JS
  • CollectionType sortable via JS without reassigning indexes (to avoid breaking entity identity/content mismatch)

Can this be solved with custom FormTypeExtension? Yes, but its somewhat bloated and requires deep knowledge of the form component to get it working correctly and consistently, definitely far beyond "entry" level experience for such common use cases.

Was this issue brought up before? Yes, but declined for various reason. One of the major arguments was, that browsers are not required to maintain a specific order of submitted form values. This isnt true: https://www.w3.org/TR/html5/forms.html#constructing-form-data-set requires tree order.
Furthermore the data format that the form component is expecting (in compound forms) are PHP arrays (FormInterface::submit(null|string|array $submittedData, ...), null treated as empty array) and PHP arrays are ordered hash maps and therefore exhibit an explicit ordering of its key-value pairs.

ref #10575, #4492, #8315, #8987

Here are some classes i use in my projects to achieve this:

// this one has the problem that it always clears the default data and completed replaces it with default data
class RestoreInputOrderTypeExtension extends AbstractTypeExtension {

	public function buildForm(FormBuilderInterface $builder, array $options) {
		if(!$options['restore_input_order'] || !$options['compound']) {
			return;
		}

		$builder->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $fe) {
			$data = $fe->getData();

			if($data === null) {
				return;
			}

			if(!is_array($data)) {
				throw new TransformationFailedException('expected array', 1);
			}

			$form = $fe->getForm();
			foreach(array_keys($data) as $name) {
				if($form->has($name)) {
					$child = $form->get($name);
					$form->remove($name);
					$form->add($child);
				}
			}
		}, -100);

		$builder->addEventListener(FormEvents::SUBMIT, function(FormEvent $fe) {
			$data = $fe->getData();

			if($data === null) {
				return;
			}

			if(!is_array($data)) {
				throw new TransformationFailedException('expected array', 1);
			}

			$orderedData = array();
			foreach(array_keys($fe->getForm()->all()) as $name) {
				$orderedData[$name] = $data[$name];
			}

			$fe->setData($orderedData);

		}, -100);
	}

	/**
	 * @see \Symfony\Component\Form\AbstractTypeExtension::setDefaultOptions()
	 */
	public function setDefaultOptions(OptionsResolverInterface $resolver) {
		$resolver->setDefaults(array(
			'restore_input_order' => false,
		));
	}

	/**
	 * @see \Symfony\Component\Form\FormTypeExtensionInterface::getExtendedType()
	 */
	public function getExtendedType() {
		return 'form';
	}

}
class RestoreCheckboxInputOrderListener implements EventSubscriberInterface {

	private $inputData;

	private $choiceList;

	public function __construct(ChoiceListInterface $choiceList) {
		$this->choiceList = $choiceList;
	}

	public function preSubmit(FormEvent $event) {
		$this->inputData = $event->getData();
	}

	public function submit(FormEvent $event) {
		$data = array();
		foreach($this->choiceList->getChoicesForValues($this->inputData) as $inputChoice) {
			foreach($event->getData() as $i => $choice) {
				if($choice === $inputChoice) {
					$data[$i] = $choice;
				}
			}
		}
		$event->setData($data);
	}

	public static function getSubscribedEvents() {
		return array(
			FormEvents::PRE_SUBMIT => array('preSubmit', 100),
			FormEvents::SUBMIT => 'submit'
		);
	}

}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions