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

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: .
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]);
}
}

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.

Related

Save new Entity with relations on existed Entity (Symfony/Doctrine2)

I want to create new Entity SlideTranslation, and assign existed slide.
But every time entity had created without id of Slide. I can create SlideTranslation and than assign Slide to it, but it seems bad solution for me.
$slide = $em->getRepository('Model:Slide')->find($id);
if(isset($slide)) {
try {
$slideTranslation = new SlideTranslation();
$slideTranslation->setTranstable($slide);
$slideTranslation->setLocale('uk');
$slideTranslation->setAltText('Alt text');
$em->persist($slideTranslation);
$em->flush();
} catch (Exception $e) {
dump($e->getMessage());
}
}
Relations.
/**
* #ORM\ManyToOne(targetEntity="Model\Entity\Slide", inversedBy="tranlations")
* #ORM\JoinColumn(name="translatable_id", referencedColumnName="id")
*/
private $transtable;
I have tried method with getReference, but no result. Maybe I am breaking some patterns or principles and It's not possible in Doctrine2.
You will have to probably do it in the other way around
$slide = $em->getRepository('Model:Slide')->find($id);
$slideTranslation = new SlideTranslation();
$slideTranslation->setLocale('uk');
$slideTranslation->setAltText('Alt text');
$slide->addTranslation($slideTranslation);
$em->flush();
Then add cascade to the Slide entity, and you don't even need to persist the entity Translation
/**
* #ORM\OneToMany(targetEntity="Model\Entity\SlideTranslation", mappedBy="transtable", cascade={"persist", "remove"})
*/
private $translations;

How to implement dynamic filters (linked select box ) with Sonata?

I'm trying to implement some dynamic filters with Sonata. And I pull my hair.
In fact, I have three objects (Engine > Motor > Sensor).
class Engine {
/**
* #ORM\OneToMany(targetEntity=Motor, mappedBy=engine)
*/
private $motors;
}
class Motor {
/**
* #ORM\OneToMany(targetEntity=Sensor, mappedBy=motor)
*/
private $sensors;
/**
* #ORM\ManyToOne(targetEntity=Engine, inversedBy=motors)
*/
private $engine;
}
class Sensor {
/**
* #ORM\ManyToOne(targetEntity=Motor, inversedBy=sensors)
*/
private $motor;
}
This code is just to have an idea of the model.
In admin part, I would like to filter sensor by engine and by motor. So when I choose engine, I would like motor's filter change automatically.
After some research, my research leads me to the callbacks. But I never used it and documentation about it is very small. So if somebody could help me.
Here is my code for the admin side:
use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
class SensorAdmin extends Admin {
/**
* #param DatagridMapper $prmDatagridMapper
*/
protected function configureDatagridFilters(DatagridMapper $prmDatagridMapper)
{
$prmDatagridMapper
->add("motor.engine", null, array("label" => "Engine"))
->add('motor', null, array(
"callback" => array($this, "callbackMotorFilter")
))
;
}
public function callbackMotorFilter ($queryBuilder, $alias, $field, $value)
{
if (!$value) {
return;
}
$queryBuilder->leftJoin(sprintf('%s.motor', $alias), 'm');
$queryBuilder->leftJoin("m.engine", "e");
$queryBuilder->andWhere('e.id = :engine_id');
$queryBuilder->setParameter('engine_id', $value['value']);
return true;
}
}
Thank you in advance.
I'd better use any kind of API here. Sending tons of entities to the front-end is not a good idea. Selecting from a thousands entities could hang the browser.
For example you could use any kind of jQuery autocomplete inputs with simple JSON API.
I've implemented one a long time ago.
Form type
https://github.com/scaytrase/form-extensions/blob/master/src/ScayTrase/Forms/ExtensionsBundle/Form/Type/EntityAutocompleteType.php
jQuery plugin
https://github.com/scaytrase/form-extensions/blob/master/src/ScayTrase/Forms/ExtensionsBundle/Resources/public/js/jquery.entity_autocomplete.js
Twig template
https://github.com/scaytrase/form-extensions/blob/master/src/ScayTrase/Forms/ExtensionsBundle/Resources/views/Form/entity_autocomplete.html.twig
Not sure it is compatible with latest 3.0 Forms, but modification should be easy

Zend Framework 2 Form, regex validation not being applied

I am using a stand alone Zend Form (I'm not using the full blown ZF2 MVC) and I have specified the following class to define the form:
use Zend\Form\Annotation;
/**
* #Annotation\Hydrator("Zend\Stdlib\Hydrator\ObjectProperty")
*/
class Student
{
/**
* #Annotation\Type("Zend\Form\Element\Text")
* #Annotation\Options({"label":"Student code"})
* #Annotations\Validator({"name":"Regex", "options":{"pattern":"/^[0-9]+$/"}})
* #Annotation\Required({"required":"true"})
*/
public $student_code;
}
This is the relevant code in my controller (simplified to only show the relevant parts)
public function createAction()
{
$request = $this->getRequest();
$student = new Student();
$builder = new AnnotationBuilder();
$form = $builder->createForm($student);
$form->bind($student);
$form->setData($request->getPost());
if ($form->isValid()) {
var_dump($form->getPost());
}
}
The problem is that when I submitt the form and with 'abc' as the value for student_code, the form is returning as valid. According to the Regex it should only accept numbers.
The required part works; the form is invalid if student_code is empty. My question is, what am I missing that the Regex is not working?
replace with:
#Annotation\Validator({"name":"Regex", "options":{"pattern":"/^[0-9]+$/"}})
Note that I have removed the s at the end from #Annotation

Joomla3 custom server side form validation rule

I am new to joomla component development(J3 , MVC) and i am trying to create a custom server side form validation rule.
I added validate="machinename" to my forms field and created a the file models\rules\machinename.php
defined('_JEXEC') or die('Restricted access');
jimport('joomla.form.formrule');
class JFormRuleMachinename extends JFormRule
{
protected $regex = '/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/';
}
I have a empty controller in controllers\field.php
defined('_JEXEC') or die('Restricted access');
// import Joomla controllerform library
jimport('joomla.application.component.controllerform');
class SampleControllerField extends JControllerForm
{
}
and the model is in models\field.php
defined('_JEXEC') or die('Restricted access');
// import Joomla modelform library
jimport('joomla.application.component.modeladmin');
/**
* HelloWorld Model
*/
class SampleModelField extends JModelAdmin
{
public function getTable($type = 'Field', $prefix = 'SampleTable', $config = array())
{
return JTable::getInstance($type, $prefix, $config);
}
/**
* Method to get the record form.
*
* #param array $data Data for the form.
* #param boolean $loadData True if the form is to load its own data (default case), false if not.
* #return mixed A JForm object on success, false on failure
* #since 2.5
*/
public function getForm($data = array(), $loadData = true)
{
// Get the form.
$form = $this->loadForm('com_sample.field', 'field',
array('control' => 'jform', 'load_data' => $loadData));
if (empty($form))
{
return false;
}
return $form;
}
/**
* Method to get the data that should be injected in the form.
*
* #return mixed The data for the form.
* #since 2.5
*/
protected function loadFormData()
{
// Check the session for previously entered form data.
$data = JFactory::getApplication()->getUserState('com_sample.edit.field.data', array());
if (empty($data))
{
$data = $this->getItem();
}
return $data;
}
}
my components name is com_sample and everything was working fine (new,edit,delete) but then i added the validation rule to the form's field and now i am getting a error when submitting the form :
JForm::validateField() rule `machinename` missing.
my best guess is that i have a mistake in naming or the file location but i am not sure and can't find anything with googleing .
so help me pliz ...
Find the solution myself, it seems that you need to add the rules folder pathto the form definition so :
<form addrulepath="/administrator/components/com_sample/models/rules">
this solved my problem .
I was struggling with this problem. I read the error as meaning that Joomla couldn't find the rule file, but when I single-stepped through the core I realised that after loading the rule file, Jommla checks that an appropriately named class is within the rule. I'd introduced a typo to the class name. So my advice to anyone struggling with server-side validation is to check the rule file is where you'd expect, AND that the class name is correct. Obvious I know, but it took me ages to figure.

How to retrieve the current page from Zend_Navigation within a Controller Plugin

I am working on an Authentication Plugin using a Controller Plugin. I define my navigation config within the application.ini file, and then use that and the Database user records to dynamically load the ACL and apply it to Zend_Navigation. This bit works, as it successfully loads the menu and only displays the pages the user is allowed to see.
However, this doesn't stop the user from going to the page directly. What I want to do is identify when the user is going to a page they don't have access to within the Controller Plugin so I can redirect their request to the Authentication page.
I was thinking there must be a function to retrieve the current page from Zend_Navigation, but I can't find it... so maybe it doesn't exist.
Anyway, this is my full Controller Plugin. Anyone see a solution?
<?php
class Pog_Model_AuthPlugin extends Zend_Controller_Plugin_Abstract
{
public function preDispatch(Zend_Controller_Request_Abstract $oRequest)
{
/**
* Load user
*/
$oAuth = Zend_Auth::getInstance();
$oDbUsers = new Pog_Model_DbTable_Users();
if (!$oAuth->hasIdentity())
{
$oUser = $oDbUsers->createRow();
$oUser->name = "guest";
$oUser->setReadOnly(true);
}
else
{
$oUser = $oAuth->getIdentity();
$oUser->setTable($oDbUsers);
}
/**
* Load ACL
*/
$oAcl = new Zend_Acl();
$oAcl->addRole($oUser->name);
/**
* Add current user privileges
*/
$oPrivileges = $oUser->getPrivileges();
foreach ($oPrivileges as $oPrivilege)
{
if (!$oAcl->has($oPrivilege->resource))
$oAcl->addResource($oPrivilege->resource);
$oAcl->allow($oUser->name, $oPrivilege->resource, $oPrivilege->privilege);
}
/**
* Load Navigation view helper
*/
$oViewRenderer = Zend_Controller_Action_HelperBroker::getStaticHelper('ViewRenderer');
$oNavigation = $oViewRenderer->view->navigation();
/**
* Add remaining Navigation resources
*/
foreach ($oNavigation->getPages() as $oPage)
{
if (!is_null($oPage->getResource()) && !$oAcl->has($oPage->getResource()))
$oAcl->addResource($oPage->getResource());
}
/**
* Set ACL and Role
*/
$oNavigation->setAcl($oAcl)->setRole($oUser->name);
/**
* Check if use is allowed to be here
*/
...MAGIC GOES HERE...
}
}
I think that you should be able to get current navigation page as follows:
/**
* Load Navigation view helper
*/
$oViewRenderer = Zend_Controller_Action_HelperBroker::getStaticHelper('ViewRenderer');
$oNavigation = $oViewRenderer->view->navigation();
/*#var $active array */
$active = $oNavigation->findActive($oNavigation->getContainer());
/*#var $activePage Zend_Navigation_Page_Mvc */
$activePage = $active['page'];
// example of getting page info
var_dump($activePage->getLabel(), $activePage->getController(), $activePage->getAction());
Hope this helps.
This is the solution I used, since I can't get Marcin's solution working in my setup for some reason.
I did some more thinking and thought of a nice simple solution to the problem. Rather than use the Navigation module to find the Active page, I find it myself. Since I am already iterating through the pages, it's a piece of cake to compare the Controller and Action - if these both match I have my Active page!
The new getPages() foreach loop looks like this:
$oCurrentPage = null;
foreach ($oNavigation->getPages() as $oPage)
{
/**
* Check for Current Page
*/
if ($oPage->getController() == $oRequest->getControllerName()
&& $oPage->getAction() == $oRequest->getActionName())
$oCurrentPage = $oPage;
/**
* Add Resource, if missing
*/
if (!is_null($oPage->getResource()) && !$oAcl->has($oPage->getResource()))
$oAcl->addResource($oPage->getResource());
}

Categories