I have a collection form called Paragraphe with a text and a file (an Image):
class ParagrapheType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('text')
->add('image', FileType::class, ['mapped'=>false, 'required'=>false])
And it's inside another form called Article.
Here, how I add a Paragraphe inside formulaire.html.twig of Article (I just followed How to Embed a Collection of Forms from the official Symfony doc) :
<h3>Paragraphes:</h3>
{# the data-index attribute is required for the JavaScript code below #}
<ul class="paragraphes" data-index="{{ (formArticle.paragraphes|length) > 0 ? formArticle.paragraphes|last.vars.name + 1 : 0 }}" data-prototype="{{ form_widget(formArticle.paragraphes.vars.prototype)|e( 'html_attr' ) }}"></ul>
<button type="button" class="add_item_link" data-collection-holder-class="paragraphes">Add an paragraphe</button>
<script>
//event listener sur le bouton document.querySelectorAll('.add_item_link') ➡️ adds a new Paragraphe form on the Article form when push the button⤵️
const addFormToCollection = (e) => { const collectionHolder =
document.querySelector('.' + e.currentTarget.dataset.collectionHolderClass);
const item = document.createElement('li');
item.innerHTML = collectionHolder .dataset .prototype .replace( /__name__/g, collectionHolder.dataset.index );
collectionHolder.appendChild(item);
collectionHolder.dataset.index++; };
document
.querySelectorAll('.add_item_link')
.forEach(btn => { btn.addEventListener("click", addFormToCollection) });
Here what I try to save my Image in my ArticleControler.php:
foreach ($form->get('paragraphes')->getData() as $paragraphe) {
$imageParagraphe= $paragraphe->get('image')->getData(); //get() method doesn't exist so I don't know how to get the file.
if($imageParagraphe){
$fileNameImageParagraphe = $articleSlug . uniqid() . '.' . $imageParagraphe->guessExtension();
//move the image as parametred in service.yaml
try {
$imageParagraphe->move($this->getParameter('article_images'), $fileNameImageParagraphe);
} catch (FileException $e) {
$this->addFlash('erreur', "Image cannot be uploaded.");
}
$paragraphe->setImage($fileNameImageParagraphe);
} else $paragraphe->setImage($imageParagraphe);
}
As my comment says, I didn't find a way to get the file inside the second form. Did I need to do it from my script inside my twig?
Thanks to this question, I was able to find that I needed to loop through $formParagraphe and not through $formParagraphe->getData().
Here my updated contoller:
foreach ($form->get('paragraphes') as $formParagraphe) {
$imageParagraphe= $formParagraphe->get('image')->getData();
$paragraphe= $formParagraphe->getData();
if($imageParagraphe){
$fileNameImageParagraphe = $articleSlug . uniqid() . '.' . $imageParagraphe->guessExtension();
//déplace l'image dans le dossier paramétré dans service.yaml
try {
$imageParagraphe->move($this->getParameter('article_images'), $fileNameImageParagraphe );
} catch (FileException $e) {
$this->addFlash('erreur', "L'image n'a pas pu être uploadé.");
}
$paragraphe->setImage($fileNameImageParagraphe);
} else $paragraphe->setImage($imageParagraphe);
}
if ($article->getSlug() !== $articleSlug) {
$article->setSlug($articleSlug);
}
PS: I'm gonna update this when I find out how to keep the old Image in the form...
Related
In my update form, I want to add a data attribute on the inputs that will contains the initial value of the entity. This way, I will be able to highlight the input when the user will modify it.
In the end, only the input modified by the users will be highlighted.
I want to use this only in update, not in creation.
To do so, I created a form extension like this:
class IFormTypeExtension extends AbstractTypeExtension
{
...
public static function getExtendedTypes()
{
//I want to be able to extend any form type
return [FormType::class];
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'is_iform' => false,
'is_iform_modification' => function (Options $options) {
return $options['is_iform'] ? null : false;
},
]);
$resolver->setAllowedTypes('is_iform', 'bool');
$resolver->setAllowedTypes('is_iform_modification', ['bool', 'null']);
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
if (!$options['is_iform'] && !$this->isParentIForm($form)) {
return;
}
//We need to add the original value in the input as data-attributes
if (is_string($form->getViewData()) || is_int($form->getViewData())) {
$originValue = $form->getViewData();
} elseif (is_array($form->getViewData())) {
if (is_object($form->getNormData())) {
$originValue = implode('###', array_keys($form->getViewData()));
} elseif (is_array($form->getNormData()) && count($form->getNormData()) > 0 && is_object($form->getNormData()[0])) {
$originValue = implode('###', array_keys($form->getViewData()));
} else {
$originValue = implode('###', $form->getViewData());
}
} else {
//There's no value yet
$originValue = '';
}
$view->vars['attr'] = array_merge($view->vars['attr'], ['data-orig-value' => $originValue]);
}
private function isParentIForm(FormInterface $form)
{
if (null === $form->getParent()) {
return $form->getConfig()->getOption('is_iform');
}
return $this->isParentIForm($form->getParent());
}
}
As you can see in the buildView method, I get the originValue from the ViewData.
In a lot of cases, this works well.
But if I have any validation error in my form OR if I reload my form through AJAX, the ViewData contains the new information and not the values of the entity I want to update.
How can I get the values of the original entity?
I don't want to make a DB request in here.
I think I can use the FormEvents::POST_SET_DATA event, then save the entity values in session and use these in the buildView.
I could also give a new Option in my OptionResolver to ask for the initial entity.
Is it possible to have the original data of the entity directly form the buildView? (If I'm not wrong, this means the form before we call the handleRequest method).
Someone wanted to have an example with a controller. I don't think it's really interresting, because with the FormExtension, the code will be added automatically. But anyway, here is how I create a form in my controller :
$form = $this->createForm(CustomerType::class, $customer)->handleRequest($request);
And in the CustomerType, I will add the 'is_iform' key with configureOptions() :
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
"translation_domain" => "customer",
"data_class" => Customer::class,
'is_iform' => true //This line will activate the extension
]);
}
It's probably an opinionated answer. There also might be better approaches.
I'm not a big fan of your form extension, since it's really convoluted and unclear what's happening, at least to my eyes.
What I'm proposing: When the form submit happened, in your controller you should do the following
// ((*)) maybe store customer, see below
$form = $this->createForm(CustomerType::class, $customer);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()) {
// easy case, you got this.
$em->flush();
return $this->redirect(); // or another response
} elseif($form->isSubmitted()) {
// form was submitted with errors, have to refresh entity!
// REFRESH - see discussion below for alternatives
$em->refresh($customer);
// then create form again with original values:
$form = $this->createForm(CustomerType::class, $customer);
}
// other stuff
return $this->render(..., ['form' => $form->createView(), ...]);
so, essentially, when the form validation fails, you refresh the entity and recreate the form, avoiding the problem with the changed state of your entity. I believe this approach ultimately is easier then hacking the form to magically not update values or re-set older values.
Now the question remains: how to refresh an entity? Simplest approach: reload from database:
$em->refresh($customer); // easiest approach, will likely run another query.
Alternatives:
Instead of giving $customer to the form, you create a customer DTO that contains the same values but on change doesn't automatically change the original object. If the form validation fails, you can just re-generate the DTO.
Instead of refresh($customer), which will most likely run another query (except maybe not, if you have a cache), you could cache the customer yourself via a DefaultCacheEntityHydrator, you would have to create your own EntityCacheKey object (not really hard), generate a cache entry (DefaultCacheEntityHydrator::buildCacheEntry() at the ((*)) above) and restore the entry for when you need to restore it. Disclaimer: I don't know if/how this works with collections (i.e. collection properties, the entity might have).
That being said ... if you really really want a form extension for whatever reason, you might want to form event with a PRE_SET_DATA handler that stores the data in the form type object, then on buildView uses those values. I wouldn't store something in the session for I don't see the necessity ... your aversion to db queries is baffling though, if that's your main reason for all the shenanigans
In the end, I managed to make it work BUT I'm not fully convinced by what I did.
It was not possible to get the original data from the form OR add a new property (the form is read only in the form extension).
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addEventListener(
FormEvents::POST_SET_DATA,
function (FormEvent $event) {
$form = $event->getForm();
if ('_token' === $form->getName()) {
return;
}
$data = $event->getData();
$this->session->set('iform_'.$form->getName(), is_object($data) ? clone $data : $data);
}
);
}
What I do here, is simply register the form values by its name in the session.
If it's an object, I need to clone it, because the form will modify it later in the process and I want to work with the original state of the form.
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'is_iform' => false,
'is_iform_modification' => function (Options $options) {
return $options['is_iform'] ? null : false;
},
]);
$resolver->setAllowedTypes('is_iform', 'bool');
$resolver->setAllowedTypes('is_iform_modification', ['bool', 'null']);
}
The configure options did not change.
And then, depending on the value type, I create my "data-orig-value" :
public function buildView(FormView $view, FormInterface $form, array $options)
{
if (!$options['is_iform'] && !$this->isParentIForm($form)) {
return;
}
$propertyValue = $this->session->get('iform_'.$form->getName());
$originValue = '';
try {
if (null !== $propertyValue) {
//We need to add the original value in the input as data-attributes
if (is_bool($propertyValue)) {
$originValue = $propertyValue ? 1 : 0;
} elseif (is_string($propertyValue) || is_int($propertyValue)) {
$originValue = $propertyValue;
} elseif (is_array($propertyValue) || $propertyValue instanceof Collection) {
if (is_object($propertyValue)) {
$originValue = implode('###', array_map(function ($object) {
return $object->getId();
}, $propertyValue->toArray()));
} elseif (is_array($propertyValue) && count($propertyValue) > 0 && is_object(array_values($propertyValue)[0])) {
$originValue = implode('###', array_map(function ($object) {
return $object->getId();
}, $propertyValue));
} else {
$originValue = implode('###', $propertyValue);
}
} elseif ($propertyValue instanceof DateTimeInterface) {
$originValue = \IntlDateFormatter::formatObject($propertyValue, $form->getConfig()->getOption('format', 'dd/mm/yyyy'));
} elseif (is_object($propertyValue)) {
$originValue = $propertyValue->getId();
} else {
$originValue = $propertyValue;
}
}
} catch (NoSuchPropertyException $e) {
if (null !== $propertyValue = $this->session->get('iform_'.$form->getName())) {
$originValue = $propertyValue;
$this->session->remove('iform_'.$form->getName());
} else {
$originValue = '';
}
} finally {
//We remove the value from the session, to not overload the memory
$this->session->remove('iform_'.$form->getName());
}
$view->vars['attr'] = array_merge($view->vars['attr'], ['data-orig-value' => $originValue]);
}
private function isParentIForm(FormInterface $form)
{
if (null === $form->getParent()) {
return $form->getConfig()->getOption('is_iform');
}
return $this->isParentIForm($form->getParent());
}
Maybe the code sample will help anyone !
If anyone have a better option, don't hesitate to post it !
I'm searching for a way to create a custom action button which allows me to make a new DataObject with pre-filled content from another DataObject. As a simple example: When I have an email and click the "answer"-button in my email-client, I get a new window with pre-filled content from the email before. I need exactly this functionality for my button. This button should appear next to each DataObject in the GridField.
So I know how to make a button and add it to my GridField (--> https://docs.silverstripe.org/en/3.2/developer_guides/forms/how_tos/create_a_gridfield_actionprovider/) and I know how to go to a new DataObject:
Controller::curr()->redirect($gridField->Link('item/new'));
I also found out that there is a duplicate function for DataObjects:
public function duplicate($doWrite = true) {
$className = $this->class;
$clone = new $className( $this->toMap(), false, $this->model );
$clone->ID = 0;
$clone->invokeWithExtensions('onBeforeDuplicate', $this, $doWrite);
if($doWrite) {
$clone->write();
$this->duplicateManyManyRelations($this, $clone);
}
$clone->invokeWithExtensions('onAfterDuplicate', $this, $doWrite);
return $clone;
}
Perhaps it's easier than I think but at the moment I just don't get how to rewrite this to get what I need. Can somebody give me a hint?
That's for sure not the cleanest solution but I think it should do the trick.
At first let's create the custom gridfield action. Here we will save all accessible records in a session and add a query string to the url so that we'll know which object we want to "clone"
public function getColumnContent($gridField, $record, $columnName) {
if(!$record->canEdit()) return;
$field = GridField_FormAction::create(
$gridField,
'clone'.$record->ID,
'Clone',
'clone',
array('RecordID' => $record->ID)
);
$values = Session::get('ClonedData');
$data = $record->data()->toMap();
if($arr = $values) {
$arr[$record->ID] = $data;
} else {
$arr = array(
$record->ID => $data
);
}
Session::set('ClonedData', $arr);
return $field->Field();
}
public function getActions($gridField) {
return array('clone');
}
public function handleAction(GridField $gridField, $actionName, $arguments, $data) {
if($actionName == 'clone') {
$id = $arguments['RecordID'];
Controller::curr()->redirect($gridField->Link("item/new/?cloneID=$id"));
}
}
after adding this new component to our gridfield,
$gridField->getConfig()->addComponent(new GridFieldCustomAction());
we'll need to bring the data into the new form. To do so, add this code directly above "return $fields" on your getCMSFields function so it will be executed every time we'll open this kind of object.
$values = Session::get('ClonedData');
if($values) {
Session::clear('ClonedData');
$json = json_encode($values);
$fields->push(LiteralField::create('ClonedData', "<div id='cloned-data' style='display:none;'>$json</div>"));
}
At the end we need to bring the content back into the fields. We'll do that with a little bit of javascript so at first you need to create a new script.js file and include it in the ss backend (or just use an existing one).
(function($) {
$('#cloned-data').entwine({
onmatch: function() {
var data = JSON.parse($(this).text()),
id = getParameterByName('cloneID');
if(id && data) {
var obj = data[id];
if(obj) {
$.each(obj, function(i, val) {
$('[name=' + i + ']').val(val);
});
}
}
}
});
// http://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript#answer-901144
function getParameterByName(name) {
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
})(jQuery);
And that's it ... quite tricky. Hope it will solve your problem.
Apparently the GridFieldExportButton only exports the currently visible data-set (paginated). Is there a way to make it export all the rows from a model?
Or alternatively: Is there a way to show all rows (eg. bypass pagination), so that the user can perform an export after showing all the rows? I don't want to show all rows all the time (which would probably be possible by setting ModelAdmin::set_page_length(<ridiculouslyHighNumber>);) but only on demand.
You can override ModelAdmin::getExportFields() to define the columns you want to export.
The method needs to return an array with column name as the key, and the db field as the value.
For example:
class MyCustomModelAdmin extends ModelAdmin {
....
public function getExportFields() {
return array(
'FirstName' => 'FirstName',
'Surname' => 'Surname',
'Age' => 'Age'
);
}
}
Solved it by creating a custom subclass of the GridFieldExportButton and using this for my models. The key is to use $gridField->getList(); instead of $gridField->getManipulatedList(); in the generateExportFileData method.
Here's the complete class for anybody interested:
class GridFieldExportAllButton extends GridFieldExportButton {
/**
* Generate export fields for CSV.
*
* #param GridField $gridField
* #return array
*/
public function generateExportFileData($gridField) {
$separator = $this->csvSeparator;
$csvColumns = ($this->exportColumns)
? $this->exportColumns
: singleton($gridField->getModelClass())->summaryFields();
$fileData = '';
$columnData = array();
$fieldItems = new ArrayList();
if($this->csvHasHeader) {
$headers = array();
// determine the CSV headers. If a field is callable (e.g. anonymous function) then use the
// source name as the header instead
foreach($csvColumns as $columnSource => $columnHeader) {
$headers[] = (!is_string($columnHeader) && is_callable($columnHeader)) ? $columnSource : $columnHeader;
}
$fileData .= "\"" . implode("\"{$separator}\"", array_values($headers)) . "\"";
$fileData .= "\n";
}
$items = $gridField->getList();
foreach($items as $item) {
$columnData = array();
foreach($csvColumns as $columnSource => $columnHeader) {
if(!is_string($columnHeader) && is_callable($columnHeader)) {
if($item->hasMethod($columnSource)) {
$relObj = $item->{$columnSource}();
} else {
$relObj = $item->relObject($columnSource);
}
$value = $columnHeader($relObj);
} else {
$value = $gridField->getDataFieldValue($item, $columnSource);
}
$value = str_replace(array("\r", "\n"), "\n", $value);
$columnData[] = '"' . str_replace('"', '\"', $value) . '"';
}
$fileData .= implode($separator, $columnData);
$fileData .= "\n";
$item->destroy();
}
return $fileData;
}
}
Thanks for this!
I had to use this for Members GF in Security Admin.
Created an extension for anyone interested.
class SecurityAdminExtension extends Extension{
function updateEditForm($form){
$gf = $form->Fields()->fieldByName('Root.Users.Members');
$gfConfig = $gf->getConfig();
$gfConfig->removeComponentsByType('GridFieldExportButton');
$gfConfig->addComponent(new GridFieldExportAllButton());
}
}
I while back, I created a little plugin to make it easy to export DataObjects to CSV or Excel files.
https://github.com/firebrandhq/excel-export
It comes with a button you can add to a grid field.
It's got a dependency on PHP-Excel.
Added toolbar in joomla 3.0 html.php file. When the addNew button is clicked it displays
An error has occurred. 0 Invalid controller:
name='Comboscategories', format=''
html.php file as follows.
<?php
defined( '_JEXEC' ) or die( 'Restricted access' );
class ComboscategoriesViewsStatisticsHtml extends JViewHtml
{
function render()
{
$app = JFactory::getApplication();
//retrieve task list from model
$model = new ComboscategoriesModelsStatistics();
$this->stats = $model->getStats();
$this->addToolbar();
/*$this->displayComboslist();*/
//display
return parent::render();
}
protected function addToolbar()
{
$canDo = ComboscategoriesHelpersLendr::getActions();
// Get the toolbar object instance
$bar = JToolBar::getInstance('toolbar');
JToolbarHelper::title(JText::_('Combos Category'));
JToolBarHelper::addNew('Comboscategories.add');
/* JToolbarHelper::preferences('com_comboscategories');*/
JToolBarHelper::save();
JToolBarHelper::cancel();
JToolBarHelper::deleteList();
JToolBarHelper::publishList();
JToolBarHelper::unpublishList();
}
}
controller.php(display.php)
<?php
defined( '_JEXEC' ) or die( 'Restricted access' );
class ComboscategoriesControllersDisplay extends JControllerBase
{
public function execute()
{
// Get the application
$app = $this->getApplication();
// Get the document object.
$document = JFactory::getDocument();
$viewName = $app->input->getWord('view', 'statistics');
$viewFormat = $document->getType();
$layoutName = $app->input->getWord('layout', 'default');
$app->input->set('view', $viewName);
// Register the layout paths for the view
$paths = new SplPriorityQueue;
$paths->insert(JPATH_COMPONENT . '/views/' . $viewName . '/tmpl', 'normal');
$viewClass = 'ComboscategoriesViews' . ucfirst($viewName) . ucfirst($viewFormat);
$modelClass = 'ComboscategoriesModels' . ucfirst($viewName);
$view = new $viewClass(new $modelClass, $paths);
$view->setLayout($layoutName);
// Render our view.
echo $view->render();
return true;
}
}
I searched related to this but i dont find the solution. kindly help me to sort this
Hi im new to joomla but i think that your classes have bad names. it Should be ComboscategoriesViewStatisticsHtml or ComboscategoriesControllerDisplay
You have to use the name of the model: if you have a controller called ComboscategoriesControllersDisplay your call should be JToolBarHelper::addNew('display.add').
Best read this: http://docs.joomla.org/J3.x:Developing_a_MVC_Component/Adding_backend_actions
I have a view for an project. When I do an Ajax call on the method showProjects() in my ProjectController, I would get a json feed which contain an array of templates for each item.
Actually, I try that, but I get a json feed which contain request objects , not the templates. I don't want to display one template with each item, because I want to process them in js.
#ProjectController.php
public function showProjects() {
$em = $this->getDoctrine()->getEntityManager();
$projects = $em->getRepository('BtaskBoardBundle:Project')->findAll();
if (!$projects) {
throw new NotFoundHttpException();
}
$projects_template = array();
foreach ($projects as $project) {
$projects_template[] = $this->render('MyBundle::project.html.twig', array(
'project' => $project,
));
}
return new Response(json_encode($projects_template), 200);
}
#project.html.twig
<a class="project" data-id="{{ project.id }}" href="#">{{ project.name }}</a>
What's wrong?
Thanks in advance.
The response object you get back should have a getContent() method. Check out the Symfony\Component\HttpFoundation\Response class in the documentation for more information. You should be able to do it like so:
foreach ($projects as $project) {
$projects_template[] = $this->render('MyBundle::project.html.twig', array(
'project' => $project,
))->getContent();
}