formssymfonydatamapper

Using a Pagerfanta/ non ArrayAccess list in a bulk form


I'm adding checkboxes for bulk actions to a CRUD list, using the solution provided here.

However, my results are paged with Pagerfanta, so it seems I need to use a DataMapper in my form.

I have tried various solutions, but cannot get the selected fields to be available in my form data:

class ModelEntitySelectionType extends AbstractType  implements DataMapperInterface
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('model_id', EntityType::class, [
            'required' => false,
            'class' => ModelFile::class,
            'choice_label' => 'id',
            'property_path' => '[id]', # in square brackets!
            'multiple' => true,
            'expanded' => true
        ])
            ->add('action', ChoiceType::class, [
                'choices' => [
                    'Delete' => 'delete'
                ]
            ])
            ->add('submit', SubmitType::class, [
                'label' => 'Process'
            ])

        ->setDataMapper($this)
        ;


    }

    public function setDefaultOptions(ExceptionInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => null,
            'csrf_protection' => false
        ));
    }

    public function mapDataToForms($data, $forms)
    {
        // there is no data yet, so nothing to prepopulate
        if (null === $data) {
            return;
        }

        $formData = [];

        /** @var FormInterface[] $forms */
        $forms = iterator_to_array($forms);
        $forms['model_id']->setData($formData);

    }

    public function mapFormsToData($forms, &$data)
    {
        //$forms = iterator_to_array($forms);
        $data = [
            'model_id' => iterator_to_array($data)
            ];
    }

The missing piece is when I investigate mapFormsToData with a debugger:

I understand how I have to "loop" through the PagerFanta object, because it doesn't have ArrayAccess, but where is the data of which checkboxes have actually been ticked? Also, my other form fields (action) are not accessible here


Solution

  • I think your approach is problematic. The Form component is meant to modify the object passed to it, which is - as far as I can tell - not what you want. You don't want to modify a Pagerfanta object, you want to select entities for bulk actions.

    So to solve your problem, the very very raw things that have to happen: A <form> must be displayed on the page with a checkbox for every entry that's a candidate for the bulk action, with some button(s) to trigger the bulk action(s).

    Your form - besides the entry for checkboxes - is alright I guess and not really your problem, as far as I can tell. You're not even interested in editing the Pagerfanta object (I hope) and just want the selection. To do that, we provide the collection of objects that are queued to be displayed on the page to the form via an option, and then use that option to build the field (read: pass the collection to the EntityType field).

    Adding the collection to the form (call) as an option:

    Somewhere in your controller, you should have something like:

    $form = $this->createForm(ModelEntitySelectionType::class, $pagerfanta);
    

    Change this to:

    $form = $this->createForm(ModelEntitySelectionType::class, [], [
        'model_choices' => $pagerfanta->getCurrentPageResults(),
    ]);
    

    the method getCurrentPageResults return the collection of entities for the current page (obviously). The empty array [] as the second parameter is ultimately the object/array you're trying to edit/create. I've chosen an array here, but you can also make it a new action class (like a DTO) e.g. ModelBulkAction with properties: model and action:

    class ModelBulkAction {
        public $model;
        public $action;
    }
    

    Note these kinds of objects only make sense if used in more than one place - then the call would be:

    $form = $this->createForm(ModelEntitySelectionType::class, new ModelBulkAction(), [
        'model_choices' => $pagerfanta->getCurrentPageResults(),
    ]);
    

    Pass the choices to the sub form:

    The Form component will complain, if you provide an option to a form, which doesn't expect that option. That's the purpose of AbstractType::configureOptions(OptionsResolver $resolver). (side note: I don't know, what your setDefaultOptions is supposed to achieve, tbh, with an ExceptionInterface nonetheless. No clue, really).

    public function configureOptions(OptionsResolver $resolver) {
        $resolver->setRequired([
             'model_choices', // adds model_choices as a REQUIRED option!
        ]);
        $resolver->setDefaults([
             // change null to ModelBulkAction::class, if applicable
             'data_class' => null, 
        ]);
    }
    

    and finally actually passing the collection to the entity type sub form:

        // in ModelEntitySelectionType::buildForm($builder, $options)
    
        $builder->add('model', EntityType::class, [
            'required' => false,
            'class' => ModelFile::class,
            'choice_label' => 'id',
            'choices' => $options['model_choices'], // set the choices explicitly
            'multiple' => true,
            'expanded' => true,
        ])
        // ...
        ;
    

    Also, your data mapping is not needed any more and should be removed.

    Adding the form widgets to the output

    This is pretty much similar to the Stack Overflow question and answer you linked. However, the keys in the form are different, because my approach is slightly different:

    {{ form_start(form) }}
    {% for entity in pagerfanta %}
    
        {# stuff before the checkbox #}
        {{ form_widget(form.model[entity.id]) }}
        {# stuff after the checkbox #}
    
    {% endfor %}
    {# place the selection of action somewhere! and possibly the "submit" button #}
    {{ form_widget(form.action) }} 
    {{ form_end(form) }}
    

    (note: this will probably show the id of the entry next to the checkbox, since that's your choice_label, I believe this can be removed by: {{ form_widget(form.model[index], {label:false}) }} or alternatively by setting the choice_label to false instead of 'id').

    Getting your bulk entities

    After $form->handleRequest($request); you can check for submission and the form values:

    if($form->isSubmitted() && $form->isValid()) {
        $data = $form->getData();
        // $data['model'] contains an array of entities, that were selected
        // $data['action'] contains the selection of the action field
    
        // do the bulk action ...
    }
    

    If you implemented the ModelBulkAction approach, $data is an object of that kind and has $data->model as well as $data->action for you to use (or pass on to a repository).

    More stuff

    Obviously the model_choices option can be named almost any way you like (but should not clash with existing options the AbstractType may have).

    To make an entity selectable (besides the checkbox), you can for example use <label for="{{ form.model[index].vars.id }}"><!-- something to click --></label> as a non-javascript approach (may add styling). With js it's pretty much irrelevant because you probably just need to select the first checkbox in the row.

    Alternatives

    Alternative to providing the collection of objects to the form, you could theoretically also provide a list of ids and use the ChoiceType instead of the EntityType. There is nothing to be gained from this though.