in my project I have some forms with choice types with a lot of options.
So I decided to build an autocomplete choice type based on jquery autocomplete, which adds new <option> HTML elements to the original <select> on runtime. When selected they are submitted correctly, but can't be handled within the default ChoicesToValuesTransformer, since the don't exist in my form when I create it.
How can I make symfony accept my dynamically added values?
I found this answer Validating dynamically loaded choices in Symfony 2 , where the submitted values are used to modify the form on the PRE_SUBMIT form event, but couldn't get it running for my situation. I need to change choices known to the current type instead of adding a new widget to the form
To deal with dynamically added values use 'choice_loader' option of choice type. It's new in symfony 2.7 and sadly doesn't have any documentaion at all.
Basically it's a service implementing ChoiceLoaderInterface which defines three functions:
loadValuesForChoices(array $choices, $value = null)
is called on build form and receives the preset values of object bound into the form
loadChoiceList($value = null)
is called on build view and should return the full list of choices in general
loadChoicesForValues(array $values, $value = null)
is called on form submit and receives the submitted data
Now the idea is to keep a ArrayChoiceList as private property within the choice loader. On build form loadValuesForChoices(...) is called, here we add all preset choices into our choice list so they can be displayed to the user. On build view loadChoiceList(...) is called, but we don't load anything, we just return our private choice list created before.
Now the user interacts with the form, some additional choices are loaded via an autocomplete and put into th HTML. On submit of the form the selected values are submitted and in our controller action first the form is created and afterwards on $form->handleRequest(..) loadChoicesForValues(...) is called, but the submitted values might be completly different from those which where included in the beginning. So we replace our internal choice list with a new one containing only the submitted values.
Our form now perfectly holds the data added by autocompletion.
The tricky part is, that we need a new instance of our choice loader whenever we use the form type, otherwise the internal choice list would hold a mixture of all choices.
Since the goal is to write a new autocomplete choice type, you usually would use dependency injection to pass your choice loader into the type service.
But for types this is not possible if you always need a new instance, instead we have to include it via options. Setting the choice loader in the default options does not work, since they are cached too. To solve that problem you have to write a anonymous function which needs to take the options as parameters:
$resolver->setDefaults(array(
'choice_loader' => function (Options $options) {
return AutocompleteFactory::createChoiceLoader();
},
));
Edit:
Here is a reduced version of the choice loader class:
use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
class AutocompleteChoiceLoader implements ChoiceLoaderInterface
{
/** #var ChoiceListInterface */
private $choiceList;
public function loadValuesForChoices(array $choices, $value = null)
{
// is called on form creat with $choices containing the preset of the bound entity
$values = array();
foreach ($choices as $key => $choice) {
// we use a DataTransformer, thus only plain values arrive as choices which can be used directly as value
if (is_callable($value)) {
$values[$key] = (string)call_user_func($value, $choice, $key);
}
else {
$values[$key] = $choice;
}
}
// this has to be done by yourself: array( label => value )
$labeledValues = MyLabelService::getLabels($values);
// create internal choice list from loaded values
$this->choiceList = new ArrayChoiceList($labeledValues, $value);
return $values;
}
public function loadChoiceList($value = null)
{
// is called on form view create after loadValuesForChoices of form create
if ($this->choiceList instanceof ChoiceListInterface) {
return $this->choiceList;
}
// if no values preset yet return empty list
$this->choiceList = new ArrayChoiceList(array(), $value);
return $this->choiceList;
}
public function loadChoicesForValues(array $values, $value = null)
{
// is called on form submit after loadValuesForChoices of form create and loadChoiceList of form view create
$choices = array();
foreach ($values as $key => $val) {
// we use a DataTransformer, thus only plain values arrive as choices which can be used directly as value
if (is_callable($value)) {
$choices[$key] = (string)call_user_func($value, $val, $key);
}
else {
$choices[$key] = $val;
}
}
// this has to be done by yourself: array( label => value )
$labeledValues = MyLabelService::getLabels($values);
// reset internal choice list
$this->choiceList = new ArrayChoiceList($labeledValues, $value);
return $choices;
}
}
A basic (and probably not the best) option would be to unmap the field in your form like :
->add('field', choiceType::class, array(
...
'mapped' => false
))
In the controller, after validation, get the data and send them to the entity like this :
$data = request->request->get('field');
// OR
$data = $form->get('field')->getData();
// and finish with :
$entity = setField($data);
Related
I have a custom field type that contains a small validator. It is used all over the place on the Frontend, so for convenience we also use it in the CMS.
class MyTextField extends TextField {
public function Type() {
return 'text';
}
public function getAttributes() {
return array_merge(
parent::getAttributes(),
array(
'type' => 'text'
)
);
}
public function validate($validator) {
$this->value = trim($this->value);
$maxLength = empty($this->getMaxLength())?50:$this->getMaxLength();
if(strlen($this->value)>$maxLength) {
$validator->validationError(
$this->name,
'Exceeded max length '.$maxLength,
"validation"
);
return false;
}else{
return true;
}
}
}
}
Field works fine. If a create a new instance using it, and that constructor doesn't include a maxlength property, it defaults to 50.
The issue I am having is that a change to a frontend field "MyText" was set to 100, while the version of this field in getCMSFields was not, and thus defaulted to a max length of 50. Obviously validation failure.
Of course this should and has been rectified, but the issue came up as a CMS user could not delete a record. And I don't really disagree with the user in that validation should be ignored when you discarding the data anyway. It would make sense for add/edit, but not really delete.
Anyone know of a way of disabling validation for a delete operation inside getCMSFields? Here is an excerpt of my getCMSFields in case a refactor is required.
function getCMSFields() {
$detailBlock = CompositeField::create(
MyTextField::create('MyTextField','CustomField', '', 255),
);
// Build the fieldlist for the form.
$fields = FieldList::create (
TabSet::create (
"Root",
Tab::create (
"Main Content",
$detailBlock
)
)
);
return $fields;
}
EDIT: I have just discovered that I need a way to bypass getCMSValidator for delete operations too. There are required fields set in here that can trigger a validation error on delete too. It doesn't really make sense that you would need to put a record in a valid state just to delete it.
I simply use _t() to translate CMS Fields in a DataObject: TextField::create('Title', _t('cms.TitleField', 'Title'));. I thought translating $summary_fields was just as simple, but it's not.
Instead of trying to translate Fields and their accompanying summary_fields seperately, I believe I noticed a better way how these fields are translated using the function FieldLabels as used in SiteTree.
Is there way I can translate these both fields in one place (DRY principle) and apply to both easily by calling the var?
Yes I would certainly say the use of FieldLabels is for localisation / translation because of the comment "Localize fields (if possible)" here in the DataObject code...
public function summaryFields() {
$fields = $this->stat('summary_fields');
// if fields were passed in numeric array,
// convert to an associative array
if($fields && array_key_exists(0, $fields)) {
$fields = array_combine(array_values($fields), array_values($fields));
}
if (!$fields) {
$fields = array();
// try to scaffold a couple of usual suspects
if ($this->hasField('Name')) $fields['Name'] = 'Name';
if ($this->hasDatabaseField('Title')) $fields['Title'] = 'Title';
if ($this->hasField('Description')) $fields['Description'] = 'Description';
if ($this->hasField('FirstName')) $fields['FirstName'] = 'First Name';
}
$this->extend("updateSummaryFields", $fields);
// Final fail-over, just list ID field
if(!$fields) $fields['ID'] = 'ID';
// Localize fields (if possible)
foreach($this->fieldLabels(false) as $name => $label) {
// only attempt to localize if the label definition is the same as the field name.
// this will preserve any custom labels set in the summary_fields configuration
if(isset($fields[$name]) && $name === $fields[$name]) {
$fields[$name] = $label;
}
}
return $fields;
}
I am trying to modify a field collection in a node that already exists so I can change an image on the first element in an array of 3. The problem is, the hostEntity info is not set when I do a entity_load or entity_load_single so when I do a:
$field_collection_item->save(true); // with or without the true
// OR
$fc_wrapper->save(true); // with or without the true
I get the following error:
Exception: Unable to save a field collection item without a valid reference to a host entity. in FieldCollectionItemEntity->save()
When i print_r the field collection entity the hostEntity:protected fields are indeed empty. My field collection is setup as follows:
field_home_experts
Expert Image <--- Want to change this data only and keep the rest below
field_expert_image
Image
Expert Name
field_expert_name
Text
Expert Title
field_expert_title
Text
Here is the code I am trying to use to modify the existing nodes field collection:
$node = getNode(1352); // Get the node I want to modify
// There can be up to 3 experts, and I want to modify the image of the first expert
$updateItem = $node->field_home_experts[LANGUAGE_NONE][0];
if ($updateItem) { // Updating
// Grab the field collection that currently exists in the 0 spot
$fc_item = reset(entity_load('field_collection_item', array($updateItem)));
// Wrap the field collection entity in the field API wrapper
$fc_wrapper = entity_metadata_wrapper('field_collection_item', $fc_item);
// Set the new image in place of the current
$fc_wrapper->field_expert_image->set((array)file_load(4316));
// Save the field collection
$fc_wrapper->save(true);
// Save the node with the new field collection (not sure this is needed)
node_save($node);
}
Any help would be greatly appreciated, I am still quite new to Drupal as a whole (end-user or developer)
Alright so I think I have figured this out, I wrote up a function that will set a field collection values:
// $node: (obj) node object returned from node_load()
// $collection: (string) can be found in drupal admin interface:
// structure > field collections > field name
// $fields: (array) see usage below
// $index: (int) the index to the element you wish to edit
function updateFieldCollection($node, $collection, $fields = Array(), $index = 0) {
if ($node && $collection && !empty($fields)) {
// Get the field collection ID
$eid = $node->{$collection}[LANGUAGE_NONE][$index]['value'];
// Load the field collection with the ID from above
$entity = entity_load_single('field_collection_item', array($eid));
// Wrap the loaded field collection which makes setting/getting much easier
$node_wrapper = entity_metadata_wrapper('field_collection_item', $entity);
// Loop through our fields and set the values
foreach ($fields as $field => $data) {
$node_wrapper->{$field}->set($data);
}
// Once we have added all the values we wish to change then we need to
// save. This will modify the node and does not require node_save() so
// at this point be sure it is all correct as this will save directly
// to a published node
$node_wrapper->save(true);
}
}
USAGE:
// id of the node you wish to modify
$node = node_load(123);
// Call our function with the node to modify, the field collection machine name
// and an array setup as collection_field_name => value_you_want_to_set
// collection_field_name can be found in the admin interface:
// structure > field collections > manage fields
updateFieldCollection(
$node,
'field_home_experts',
array (
'field_expert_image' => (array)file_load(582), // Loads up an existing image
'field_expert_name' => 'Some Guy',
'field_expert_title' => 'Some Title',
)
);
Hope this helps someone else as I spent a whole day trying to get this to work (hopefully I won't be a noob forever in Drupal7). There may be an issue getting formatted text to set() properly but I am not sure what that is at this time, so just keep that in mind (if you have a field that has a format of filtered_html for example, not sure that will set correctly without doing something else).
Good luck!
Jake
I was still getting the error, mentioned in the question, after using the above function.
This is what worked for me:
function updateFieldCollection($node, $collection, $fields = Array(), $index = 0) {
$eid = $node->{$collection}[LANGUAGE_NONE][$index]['value'];
$fc_item = entity_load('field_collection_item', array($eid));
foreach ($fields as $field => $data) {
$fc_item[$eid]->{$field}[LANGUAGE_NONE][0]['value'] = $data;
}
$fc_item[$eid]->save(TRUE);
}
I hope this helps someone as it took me quite some time to get this working.
I've a custom module that builds a form with a couple of fields, so far so good.
In one of my pages, i print this form twice (different blocks), the form gets the same "form_id", so when i submit one of them and get an error, both of them get the error highlighted, and the fields populated.
I want that only the form i submit gets the errors, is there a way to do this?
Thanks!!
For anyone interested, to do this you need to use the hook_forms.
This hook only gets called when the form_id passed to a drupal_get_form doesn't exist, this is important, if you want to use this, make sure your calls use a non existing form_id, for example:
//Defining the form:
function mx_wtransnet_form_contacto($form, &$form_state, $block = null, $formType = null) {
}
I want to use this form multiple times and get different error handlers, instead of loading my form (mx_transnet_form_contacto), i'll call a non existing one:
$form = drupal_get_form("mx_wtransnet_form_contacto_invalid", "contacto-mini");
Then i create my hook:
function mx_wtransnet_forms($form_id, $args) {
$forms = array();
if (strpos($form_id, '_contacto_') !==false) {
$forms[$form_id] = array(
'callback' => 'mx_wtransnet_form_contacto',
);
}
return $forms;
}
This function will catch all my druapl_get_form calls that don't exist, so i can process/direct them, in my example, what i do is simply check that the form_id contains contacto and then set the callback for this form to the original function.
In this case better to create another form with different "form_id" but with the same submit handler.
Another case: when you output same form twice on the page it also may get JS errors because ID of form elements are the same.
In case you don't repeat the form code and its submit handler(DRY principle), I would recommend create a custom function that has the form array
function form_my_custom($form_id){
$form['my_first_field'] = array();
$form['my_second_field'] = array();
$form['#attributes']['id'] = $form_id;
$form['my_submit_button'] = array(
'#submit' => array('my_custom_form_submit')
);
return $form;
}
function my_block1_form(){
return my_custom_form('my_form_id_1');
}
function my_block2_form(){
return my_custom_form('my_form_id_2');
}
function my_custom_form_submit(&$form, &$form_state){
// your submit handler.
}
I have a case where we create registration for sports events.
The registration contains some fields specific to each sport. Some of which will be named similarly although they will be different for each sport. Example: "favorite position on the field":
For Basketball it would be a choice field between:
Point guard
Shooting guard
etc...
For baseball, it would be the same choice field but with some different choices available:
Pitcher
Infield
Outfield
...
When first creating the form (for display), the sport is passed as part of the data in the registration:
$registration = new Registration;
$registration->setEvent($event);
and $event->getSport(); would return the sport for that event.
So far so good, and adding a listener to the generation of my form, I can set only the fields specific to that sport:
public static function getSubscribedEvents()
{
return [FormEvents::POST_SET_DATA => 'preSetData'];
}
/**
* #param event DataEvent
*/
public function preSetData(DataEvent $event)
{
$form = $event->getForm();
if (null === $event->getData()) {
return;
}
// (The get event here means the real life sports gathering)
$sport = $event->getData()->getEvent()->getSport();
/**
* Then I customize the fields depending on the current sport
*/
}
The problem comes when the user submits this form back. In this case, $event->getData()->getEvent() is null.
The "event" (real life one) is a document_id field in the registration form (using MongoDB here).
If I listen to the ::BIND event instead of ::PRE_SET_DATA, then I can access everything, but it's too late to customize the form as it is already bound. ::PRE_BIND does the same as ::PRE_SET_DATA.
How can I correctly retrieve my Event and Sport Documents here in order to customize my form and validate it appropriately?
Why would you need an event to do such task? You can define the fields in the buildForm() action of the form class. To access the event object simply use $options['data']->getEvent()
So ... Finally found how to do this properly.It requires subscribing to two different events.
First time the form is built, some data is passed to it, therefore, the PRE_SET_DATA event contains that data and everything works fine as explained in the question.
On the moment the form is submitted, it is first created with NO data, therefore the data accessed in PRE_SET_DATA will be null. In this case we skip over the form customization:
public function preSetData(DataEvent $event)
{
$myEvent = $event->getData()->getEvent();
if (null === $myEvent) {
return;
}
$this->customizeForm();
}
This ensures that we don't run into issues when submitting the form and no data is passed, however getData() will return an empty object and not NULL.
Now, when the form is submitted, we will bind it to the data received. That's when we want to interfere. So we'll also subscribe to the PRE_BIND event:
public static function getSubscribedEvents()
{
return [
FormEvents::PRE_BIND => 'preBind',
FormEvents::PRE_SET_DATA => 'preSetData',
];
}
In pre-bind, the data we receive is only an array of values and not an object graph.
But if we injected the object manager in our listener, then we can find our objects and work with them:
public function preBind(DataEvent $event)
{
$data = $event->getData();
$id = $data['event'];
$myEvent = $this->om
->getRepository('Acme\DemoBundle\Document\Event')
->find(new \MongoId($id));
if($myEvent === null){
$msg = 'The event %s could not be found';
throw new \Exception(sprintf($msg, $id));
}
$this->customizeForm();
}