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 ExchangeFile
s, 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: .
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.
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')
;
}
}
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]);
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With