Home » Php » php – Sonata Admin one-to-many relationship with file upload (appendFormFieldElement)

php – Sonata Admin one-to-many relationship with file upload (appendFormFieldElement)

Posted by: admin July 12, 2020 Leave a comment

Questions:

I’m currently facing a challenge with SonataAdminBundle, one-to-many relationships and file uploads. I have an Entity called Client and one called ExchangeFile. One Client can have several ExchangeFiles, so we have a one-to-many relationship here. I’m using the VichUploaderBundle for file uploads.

This is the Client class:

/**
 * @ORM\Table(name="client")
 * @ORM\Entity()
 * @ORM\HasLifecycleCallbacks
 */
class Client extends BaseUser
{    
    // SNIP

    /**
     * @ORM\OneToMany(targetEntity="ExchangeFile", mappedBy="client", orphanRemoval=true, cascade={"persist", "remove"})
     */
    protected $exchangeFiles;

    // SNIP
}

and this is the ExchangeFile class:

/**
 * @ORM\Table(name="exchange_file")
 * @ORM\Entity
 * @Vich\Uploadable
 */
class ExchangeFile
{
    // SNIP

    /**
     * @Assert\File(
     *     maxSize="20M"
     * )
     * @Vich\UploadableField(mapping="exchange_file", fileNameProperty="fileName")
     */
    protected $file;

    /**
     * @ORM\Column(name="file_name", type="string", nullable=true)
     */
    protected $fileName;

    /**
     * @ORM\ManyToOne(targetEntity="Client", inversedBy="exchangeFiles")
     * @ORM\JoinColumn(name="client_id", referencedColumnName="id")
     */
    protected $client;

    // SNIP
}

In my ClientAdmin class, i added the exchangeFiles field the following way:

protected function configureFormFields(FormMapper $formMapper)
{
    $formMapper
        // SNIP
        ->with('Files')
            ->add('exchangeFiles', 'sonata_type_collection', array('by_reference' => false), array(
                    'edit' => 'inline',
                    'inline' => 'table',
                ))
        // SNIP
}

This allows for inline editing of various exchange files in the Client edit form. And it works well so far: Sonata Admin with one-to-many relationship and file uploads.

The Problem

But there’s one ceveat: When i hit the green “+” sign once (add a new exchange file form row), then select a file in my filesystem, then hit the “+” sign again (a new form row is appended via Ajax), select another file, and then hit “Update” (save the current Client), then the first file is not persisted. Only the second file can be found in the database and the file system.

As far as I could find out, this has the following reason: When the green “+” sign is clicked the second time, the current form is post to the web server, including the data currently in the form (Client and all exchange files). A new form is created and the request is bound into the form (this happens in the AdminHelper class located in Sonata\AdminBundle\Admin):

public function appendFormFieldElement(AdminInterface $admin, $subject, $elementId)
{
    // retrieve the subject
    $formBuilder = $admin->getFormBuilder();

    $form = $formBuilder->getForm();
    $form->setData($subject);
    $form->bind($admin->getRequest()); // <-- here
    // SNIP
}

So the entire form is bound, a form row is appended, the form is sent back to the browser and the entire form is overwritten by the new one. But since file inputs (<input type="file" />) cannot be pre-populated for security reasons, the first file is lost. The file is only stored on the filesystem when the entity is persisted (I think VichUploaderBundle uses Doctrine’s prePersist for this), but this does not yet happen when a form field row is appended.

My first question is: How can i solve this problem, or which direction should i go? I would like the following use case to work: I want to create a new Client and I know I’ll upload three files. I click “New Client”, enter the Client data, hit the green “+” button once, select the first file. Then i hit the “+” sign again, and select the second file. Same for the third file. All three files should be persisted.

Second question: Why does Sonata Admin post the entire form when I only want to add a single form row in a one-to-many relationship? Is this really necessary? This means that if I have file inputs, all files present in the form are uploaded every time a new form row is added.

Thanks in advance for your help. If you need any details, let me know.

How to&Answers:

Further to my comment about SonataMediaBundle

If you do go this route, then you’d want to create a new entity similar to the following:

/**
 * @ORM\Table
 * @ORM\Entity
 */
class ClientHasFile
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var Client $client
     *
     * @ORM\ManyToOne(targetEntity="Story", inversedBy="clientHasFiles")
     */
    private $client;

    /**
     * @var Media $media
     *
     * @ORM\ManyToOne(targetEntity="Application\Sonata\MediaBundle\Entity\Media")
     */
    private $media;

    // SNIP
}

Then, in your Client entity:

class Client
{
    // SNIP

    /**
     * @var \Doctrine\Common\Collections\ArrayCollection
     *
     * @ORM\OneToMany(targetEntity="ClientHasFile", mappedBy="client", cascade={"persist", "remove"}, orphanRemoval=true)
     */
    protected $clientHasFiles;


    public function __construct()
    {
        $this->clientHasFiles = new ArrayCollection();
    }

    // SNIP
}

… and your ClientAdmin’s configureFormFields:

protected function configureFormFields(FormMapper $form)
{
    $form

    // SNIP

    ->add('clientHasFiles', 'sonata_type_collection', array(
        'required' => false,
        'by_reference' => false,
        'label' => 'Media items'
    ), array(
        'edit' => 'inline',
        'inline' => 'table'
    )
    )
;
}

… and last but not least, your ClientHasFileAdmin class:

class ClientHasFileAdmin extends Admin
{
    /**
     * @param \Sonata\AdminBundle\Form\FormMapper $form
     */
    protected function configureFormFields(FormMapper $form)
    {
        $form
            ->add('media', 'sonata_type_model_list', array(), array(
                'link_parameters' => array('context' => 'default')
            ))
        ;
    }

    /**
     * {@inheritdoc}
     */
    protected function configureListFields(ListMapper $list)
    {
        $list
            ->add('client')
            ->add('media')
        ;
    }
}

Answer:

I tried many different approaches and workaround and in the end I found out that the best solution in the one described here https://stackoverflow.com/a/25154867/4249725

You just have to hide all the unnecessary list/delete buttons around the file selection if they are not needed.

In all other cases with file selection directly inside the form you will face some other problems sooner or later – with form validation, form preview etc. In all these case input fields will be cleared.

So using media bundle and sonata_type_model_list is probably the safest option despite quite a lot of overhead.

I’m posting it in case someone is searching for the solution the way I was searching.

I’ve found also some java-script workaround for this exact problem. It worked basically changing names of file inputs when you hit “+” button and then reverting it back.

Still in this case you are still left with the problem of re-displaying the form if some validation fails etc. so I definitely suggest media bundle approach.

Answer:

I’ve figured out, that it could be possible to solve this problem by remembering the file inputs content before the AJAX call for adding a new row. It’s a bit hacky, but it’s working as I’m testing it right now.

We are able to override a template for editing – base_edit.html.twig. I’ve added my javascript to detect the click event on the add button and also a javascript after the row is added.

My sonata_type_collection field is called galleryImages.

The full script is here:

$(function(){
      handleCollectionType('galleryImages');
});

function handleCollectionType(entityClass){

        let clonedFileInputs = [];
        let isButtonHandled = false;
        let addButton = $('#field_actions_{{ admin.uniqid }}_' + entityClass + ' a.btn-success');

        if(addButton.length > 0){
            $('#field_actions_{{ admin.uniqid }}_' + entityClass + ' a.btn-success')[0].onclick = null;
            $('#field_actions_{{ admin.uniqid }}_' + entityClass + ' a.btn-success').off('click').on('click', function(e){

                if(!isButtonHandled){
                    e.preventDefault();

                    clonedFileInputs = cloneFileInputs(entityClass);

                    isButtonHandled = true;

                    return window['start_field_retrieve_{{ admin.uniqid }}_'+entityClass]($('#field_actions_{{ admin.uniqid }}_' + entityClass + ' a.btn-success')[0]);
                }
            });

            $(document).on('sonata.add_element', '#field_container_{{ admin.uniqid }}_' + entityClass, function() {
                refillFileInputs(clonedFileInputs);

                isButtonHandled = false;
                clonedFileInputs = [];

                handleCollectionType(entityClass);
            });
        }


}

function cloneFileInputs(entityClass){
        let clonedFileInputs = [];
        let originalFileInputs = document.querySelectorAll('input[type="file"][id^="{{ admin.uniqid }}_' + entityClass + '"]');

        for(let i = 0; i < originalFileInputs.length; i++){
            clonedFileInputs.push(originalFileInputs[i].cloneNode(true));
        }

        return clonedFileInputs;
}

function refillFileInputs(clonedFileInputs){
        for(let i = 0; i < clonedFileInputs.length; i++){
            let originalFileInput = document.getElementById(clonedFileInputs[i].id);
            originalFileInput.replaceWith(clonedFileInputs[i]);
        }
}