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.
Related
I have this situation:
Symfony 4.4.8, in the controller, for some users, I change some properties of an entity before displaying it:
public function viewAction(string $id)
{
$em = $this->getDoctrine()->getManager();
/** #var $offer Offer */
$offer = $em->getRepository(Offer::class)->find($id);
// For this user the payout is different, set the new payout
// (For displaying purposes only, not intended to be stored in the db)
$offer->setPayout($newPayout);
return $this->render('offers/view.html.twig', ['offer' => $offer]);
}
Then, I have a onKernelTerminate listener that updates the user language if they changed it:
public function onKernelTerminate(TerminateEvent $event)
{
$request = $event->getRequest();
if ($request->isXmlHttpRequest()) {
// Don't do this for ajax requests
return;
}
if (is_object($this->user)) {
// Check if language has changed. If so, persist the change for the next login
if ($this->user->getLang() && ($this->user->getLang() != $request->getLocale())) {
$this->user->setLang($request->getLocale());
$this->em->persist($this->user);
$this->em->flush();
}
}
}
public static function getSubscribedEvents()
{
return [
KernelEvents::TERMINATE => [['onKernelTerminate', 15]],
];
}
Now, there is something very weird happening here, if the user changes language, the offer is flushed to the db with the new payout, even if I never persisted it!
Any idea how to fix or debug this?
PS: this is happening even if I remove $this->em->persist($this->user);, I was thinking maybe it's because of some relationship between the user and the offer... but it's not the case.
I'm sure the offer is persisted because I've added a dd('beforeUpdate'); in the Offer::beforeUpdate() method and it gets printed at the bottom of the page.
alright, so by design, when you call flush on the entity manager, doctrine will commit all the changes done to managed entities to the database.
Changing values "just for display" on an entity that represents a record in database ("managed entity") is really really bad design in that case. It begs the question what the value on your entity actually means, too.
Depending on your use case, I see a few options:
create a display object/array/"dto" just for your rendering:
$display = [
'payout' => $offer->getPayout(),
// ...
];
$display['payout'] = $newPayout;
return $this->render('offers/view.html.twig', ['offer' => $display]);
or create a new non-persisted entity
use override-style rendering logic
return $this->render('offers/view.html.twig', [
'offer' => $offer,
'override' => ['payout' => $newPayout],
]);
in your template, select the override when it exists
{{ override.payout ?? offer.payout }}
add a virtual field (meaning it's not stored in a column!) to your entity, maybe call it "displayPayout" and use the content of that if it exists
In Symfony 3 it's no longer possible to re-validate whole entity with dynamic groups in a callback, like this:
$executionContext->validate($this, '', 'someGroup', true);
How to do it in version 3?
There is a need to get validator and set it context to current one
public function validatePrice(ExecutionContextInterface $executionContext)
{
if($this->type !== self::TYPE_FREE){
$executionContext
->getValidator()
->inContext($executionContext)
->validate($this, null, ['payableTask'] );
}
}
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;
}
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);
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.