Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Form] Ability to add/remove rows in a Collection without Javascript #5231

Closed
henrikbjorn opened this issue Aug 10, 2012 · 12 comments
Closed

Comments

@henrikbjorn
Copy link
Contributor

No description provided.

@Tobion
Copy link
Member

Tobion commented Aug 10, 2012

It is possible to support that without javascript. Simply render some additional empty inputs for the extra collection items and write a form listener that removes items that were empty. This way users can also delete items by deleting the corresponding text inside the field.

How else would you image it to work?

@henrikbjorn
Copy link
Contributor Author

Well should work like the javascript versions where you have buttons, would think with some clever naming of thoose buttons and their positions it could be possible to have working out the box.

@Tobion
Copy link
Member

Tobion commented Aug 10, 2012

Huh? Any button that does something dynamically requires javascript. I don't see how your buttons work without javascript.

@henrikbjorn
Copy link
Contributor Author

You have a submit button with a cleaver name like _newRow or something and then it would add an empty row in ResizeListener and just stop there. The form when redisplayed would then have an empty row.

@Tobion
Copy link
Member

Tobion commented Aug 10, 2012

Well, that is basically what I suggested in my first comment only that you do it upon submitting with the new button. It can easily be implemented in your own controller. I don't see how the framework can handle that automatically in a standard way.

@michelsalib
Copy link

I agree with @henrikbjorn, we should see if we can provide a more "out of the box" way of implementing this feature without JS.

@wouterj
Copy link
Member

wouterj commented Mar 20, 2015

I would like to reconsider this issue, as I believe this can be another huge DX improvement. This is one of the few features where Symfony provides a back-end mechanism, but still requires a developer to come up with quite a lot of custom code to get things working.

Of course, solving this with JavaScript would make things a lot easier. But I believe Symfony has to provide a built-in solution too. Using the form buttons might seem obvious, but there are quite some "hacks" to do before making it work:

{# app/Resources/views/form/collection.html.twig #}

{# disable HTML5 validation, as it should be possible to add fields to a not yet validate form #}
{{ form(form, { attr: { novalidate: 'novalidate' } }) }}
// src/AppBundle/Form/TestCollectionType.php

// ...
$builder
    ->add('some_field', 'textarea')
    ->add('texts', 'collection', array(
        'type'      => 'text',
        'allow_add' => true,
    ))
    // it is confusing to make add_text a submit button
    ->add('add_text', 'submit')
    ->add('submit', 'submit')
;
// src/AppBundle/Form/FormController.php

// ...
public function collectionAction(Request $request)
{
    $entity = new TestEntity();
    $form = $this->createForm(new TestCollectionType(), $entity);

    $form->handleRequest($request);

    // do only check for submitted, as the form might not be validate.
    if ($form->isSubmitted()) {
        if ($form->get('add_text')->isClicked()) {
            // create a new form, as submitted forms can't be changed
            // for no obvious reason, already added fields are also added to this new form instance
            $form = $this->createForm(new TestCollectionType(), $entity);

            $form->get('texts')->add(uniqid(), 'text');
        } elseif ($form->isValid()) {
            // ... form is submitted and valid
        }
    }

    return $this->render('form/collection.html.twig', ['form' => $form->createView()]);
}

I can see however, that's also not that easy to built into the Form component itself. It can be built into Form#handleRequest(), but (a) this means relying on conventions (about button name) and (b) there is no way to stop the controller from executing within this method. That means that the form is just handled as if it's submitted in the controller.

@evillemez
Copy link

I agree, this would be a nice feature. At the moment I'm doing more or less what you described in your code sample:

  • check for the alternate submit button to signal adding a new item into a collection
  • get the model from the submitted form, add a new empty item into the collection
  • return a new form instance bound to the modified model

I don't know if this is really "gross" or not, and it's arguably inefficient, but it certainly feels like a lot less work than the recommended javascript approach... and I'm under a severe deadline, so this approach is a huge win for that reason alone. I may regret it, but... that's for another day.

Unless there's a clear reason to not do it this way, I think it may be worth describing this approach in the docs on handling collections in forms.

@xabbuh
Copy link
Member

xabbuh commented Sep 23, 2018

I am closing this old issue. I don't see a way to implement this easily inside the Form component in a generic way. If someone still wants to work on this feature, I suggest to do it in a third-party bundle first. If such an implementation proves to be stable, we can still consider to integrate it into the core component.

@xabbuh xabbuh closed this as completed Sep 23, 2018
@leevigraham
Copy link
Contributor

leevigraham commented Apr 22, 2024

Sorry to reply to an old closed ticket but I've had a crack at this. My rough implementation is as follows:

First we need to override the form theme. This adds a Add Row button to the collection widget and a Delete Row button to each row. Each button has a formnovalidate to avoid form validation.

{%- block collection_widget -%}
    {% if prototype is defined and not prototype.rendered %}
        {%- set attr = attr|merge({'data-prototype': form_row(prototype) }) -%}
    {% endif %}
    {{- block('form_widget') -}}
    {% if form.vars.allow_add %}
    <button type="submit" value="_addRow" name="{{ form.vars.full_name }}[_addRow]" formnovalidate>Add Row</button>
    {% endif %}
{%- endblock -%}

{%- block collection_entry_row -%}
    {{- block('form_row') -}}
    {% if form.parent.vars.allow_delete %}
        <button type="submit" value="_deleteRow" name="{{ form.vars.full_name }}" formnovalidate>Delete Row</button>
    {% endif %}
{% endblock %}

Here's the CollectionTypeListener. It uses the FormEvents::PRE_SUBMIT event to modify the data before the CollectionType ResizeListener kicks in.

There's probably an issue appending a new item to the data without checking the previous keys. This could kick in when the user has deleted a row. This needs more testing.

It also adds a fake error to mark the form as invalid and stop processing in the controller.

<?php

namespace App\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Event\SubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvents;

final class CollectionTypeListener implements EventSubscriberInterface
{
    private $allowAdd = false;
    private $allowDelete = false;
    private $isAdding = false;
    private $isDeleting = false;

    public function __construct(
        bool $allowAdd = false,
        bool $allowDelete = false
    )
    {
        $this->allowAdd = $allowAdd;
        $this->allowDelete = $allowDelete;
    }

    public static function getSubscribedEvents(): array
    {
        return [
            FormEvents::PRE_SUBMIT => ['preSubmit', 1],
            FormEvents::SUBMIT => ['onSubmit', 51],
        ];
    }

    public function preSubmit(PreSubmitEvent $event): void
    {
        $data = $event->getData();

        // Remove the dynamic error field if submitted
        unset($data['__dynamic_error']);

        if ($this->allowAdd) {
            // If `_addRow` is found, we add a new empty value to the data.
            // `_addRow` is the "Add Row" button value.
            if (isset($data['_addRow'])) {
                unset($data['_addRow']);
                $index = [] !== $data ? max(array_keys($data)) + 1 : 0;
                $data[$index] = null;
                $this->isAdding = true;
            }
        }

        if ($this->allowDelete) {
            // Loop over all the data and check for `_deleteRow`.
            // `_deleteRow` is the "Delete Row" button value.
            // If `_deleteRow` is found, we unset the data.
            foreach ($data as $key => $value) {
                if ($value === "_deleteRow") {
                    unset($data[$key]);
                    $this->isDeleting = true;
                }
            }
        }

        $event->setData($data);
    }

    public function onSubmit(SubmitEvent $event): void
    {
        // If the collection was updated, we add a fake error to the form.
        // This will trigger the form to be invalid
        // The error message will not be shown because the field is hidden
        // See: https://github.com/SymfonyCasts/dynamic-forms/blob/main/src/DynamicFormBuilder.php
        if ($this->isAdding || $this->isDeleting) {
            // Get the parent form
            $form = $event->getForm();
            // A fake hidden field where we can "store" an error
            if (!$form->has('__dynamic_error')) {
                $form->add('__dynamic_error', HiddenType::class, [
                    'mapped' => false,
                    'error_bubbling' => false,
                ]);
                $form->get('__dynamic_error')->addError(new FormError('Collection updated'));
            }
        }
    }
}

I've created a form extension to apply it to all CollectionType fields:

<?php

namespace App\Form\Extension;

use App\EventListener\CollectionTypeListener;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;

class CollectionExtension extends AbstractTypeExtension
{

    public static function getExtendedTypes(): iterable
    {
        // Extend the CollectionType
        return [CollectionType::class];
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->addEventSubscriber(new CollectionTypeListener(
            $options['allow_add'],
            $options['allow_delete'],
        ));
    }
}

@leevigraham
Copy link
Contributor

@wouterj @xabbuh Is there a better place than a closed ticket to discuss my rough idea: #5231 (comment)

@xabbuh
Copy link
Member

xabbuh commented Apr 24, 2024

Can you create a pull request with the changes you have in mind?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants