-
-
Save peter-gribanov/06aeffbf10b94b998fc3 to your computer and use it in GitHub Desktop.
<?php | |
namespace ExampleBundle\Admin; | |
use Sonata\AdminBundle\Admin\Admin; | |
use Sonata\AdminBundle\Form\FormMapper; | |
use ExampleBundle\Entity\ExampleTranslation; // is a Personal Translation | |
class ExampleAdmin extends Admin | |
{ | |
/** | |
* @param FormMapper $formMapper | |
*/ | |
protected function configureFormFields(FormMapper $formMapper) | |
{ | |
$formMapper | |
->add('title', 'translatable', [ | |
'label' => 'form.example.title.label', | |
'personal_translation' => ExampleTranslation::class, | |
'property_path' => 'translations' | |
]) | |
->add('description', 'translatable', [ | |
'label' => 'form.example.description.label', | |
'personal_translation' => ExampleTranslation::class, | |
'property_path' => 'translations', | |
'field' => 'description', | |
'attr' => ['rows' => 5], | |
'widget' => 'textarea' | |
]); | |
} | |
// ... | |
} |
parameters: | |
locale: 'en' | |
locales: ['en', 'nl'] |
services: | |
form.type.translatable: | |
class: ExampleBundle\Form\TranslatableType | |
arguments: [ '@doctrine.orm.default_entity_manager', '@validator', '%locales%', '%locale%' ] | |
tags: | |
- { name: form.type, alias: translatable } |
<?php | |
namespace ExampleBundle\Form\Event\Subscriber; | |
use Symfony\Component\Form\FormEvent; | |
use Symfony\Component\Form\FormFactoryInterface; | |
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | |
use Symfony\Component\Form\FormEvents; | |
use Symfony\Component\Form\FormError; | |
use Symfony\Component\Validator\Validator\ValidatorInterface; | |
use Doctrine\ORM\EntityManagerInterface; | |
use Gedmo\Translatable\Entity\MappedSuperclass\AbstractPersonalTranslation; | |
class Translatable implements EventSubscriberInterface | |
{ | |
/** | |
* @var FormFactoryInterface | |
*/ | |
protected $factory; | |
/** | |
* @var EntityManagerInterface | |
*/ | |
protected $em; | |
/** | |
* @var ValidatorInterface | |
*/ | |
protected $validator; | |
/** | |
* @var array | |
*/ | |
protected $options = []; | |
/** | |
* @param FormFactoryInterface $factory | |
* @param EntityManagerInterface $em | |
* @param ValidatorInterface $validator | |
* @param array $options | |
*/ | |
public function __construct( | |
FormFactoryInterface $factory, | |
EntityManagerInterface $em, | |
ValidatorInterface $validator, | |
array $options | |
) { | |
$this->em = $em; | |
$this->options = $options; | |
$this->factory = $factory; | |
$this->validator = $validator; | |
} | |
/** | |
* @return array | |
*/ | |
public static function getSubscribedEvents() | |
{ | |
// Tells the dispatcher that we want to listen on the form.pre_set_data | |
// , form.post_data and form.bind_norm_data event | |
return [ | |
FormEvents::PRE_SET_DATA => 'preSetData', | |
FormEvents::POST_SUBMIT => 'postSubmit', | |
FormEvents::SUBMIT => 'submit' | |
]; | |
} | |
/** | |
* @param array $data | |
* | |
* @return array | |
*/ | |
private function bindTranslations($data) | |
{ | |
// Small helper function to extract all Personal Translation | |
// from the Entity for the field we are interested in | |
// and combines it with the fields | |
$collection = []; | |
$available_translations = []; | |
foreach ($data as $translation) { | |
/* @var $translation AbstractPersonalTranslation */ | |
if (is_object($translation) && | |
strtolower($translation->getField()) == strtolower($this->options['field']) | |
) { | |
$available_translations[strtolower($translation->getLocale())] = $translation; | |
} | |
} | |
foreach ($this->getFieldNames() as $locale => $field_name) { | |
if (isset($available_translations[strtolower($locale)])) { | |
$translation = $available_translations[strtolower($locale) ]; | |
} else { | |
$translation = $this->createPersonalTranslation($locale, $this->options['field'], null); | |
} | |
$collection[] = [ | |
'locale' => $locale, | |
'fieldName' => $field_name, | |
'translation' => $translation, | |
]; | |
} | |
return $collection; | |
} | |
/** | |
* @return array | |
*/ | |
private function getFieldNames() | |
{ | |
//helper function to generate all field names in format: | |
// '<locale>' => '<field>:<locale>' | |
$collection = []; | |
foreach ($this->options['locales'] as $locale) { | |
$collection[$locale] = $this->options['field'] . ':' . $locale; | |
} | |
return $collection; | |
} | |
/** | |
* @param string $locale | |
* @param string $field | |
* @param string $content | |
* | |
* @return mixed | |
*/ | |
private function createPersonalTranslation($locale, $field, $content) | |
{ | |
// creates a new Personal Translation | |
$class_name = $this->options['personal_translation']; | |
return new $class_name($locale, $field, $content); | |
} | |
/** | |
* @param FormEvent $event | |
*/ | |
public function submit(FormEvent $event) | |
{ | |
// Validates the submitted form | |
$form = $event->getForm(); | |
foreach($this->getFieldNames() as $locale => $field_name) { | |
$content = $form->get($field_name)->getData(); | |
if (null === $content && in_array($locale, $this->options['required_locale'])) { | |
$form->addError($this->getCannotBeBlankException($this->options['field'], $locale)); | |
} else { | |
$errors = $this->validator->validate( | |
$this->createPersonalTranslation($locale, $field_name, $content), | |
[sprintf('%s:%s', $this->options['field'], $locale)] | |
); | |
foreach ($errors as $error) { | |
$form->addError(new FormError($error->getMessage())); | |
} | |
} | |
} | |
} | |
/** | |
* @param string $field | |
* @param string $locale | |
* | |
* @return FormError | |
*/ | |
public function getCannotBeBlankException($field, $locale) | |
{ | |
return new FormError(sprintf('Field "%s" for locale "%s" cannot be blank', $field, $locale)); | |
} | |
/** | |
* @param FormEvent $event | |
*/ | |
public function postSubmit(FormEvent $event) | |
{ | |
// if the form passed the validattion then set the corresponding Personal Translations | |
$form = $event->getForm(); | |
$data = $form->getData(); | |
$entity = $form->getParent()->getData(); | |
foreach ($this->bindTranslations($data) as $binded) { | |
$content = $form->get($binded['fieldName'])->getData(); | |
/* @var $translation AbstractPersonalTranslation */ | |
$translation = $binded['translation']; | |
// set the submitted content | |
$translation->setContent($content); | |
// test if its new | |
if ($translation->getId()) { | |
//Delete the Personal Translation if its empty | |
if (null === $content && $this->options['remove_empty']) { | |
$data->removeElement($translation); | |
if ($this->options['entity_manager_removal']) { | |
$this->em->remove($translation); | |
} | |
} | |
} elseif (null !== $content) { | |
// add it to entity | |
$entity->addTranslation($translation); | |
if (! $data->contains($translation)) { | |
$data->add($translation); | |
} | |
} | |
} | |
// remove string elements from "translations", we need only objects | |
foreach ($data as $rec) { | |
if (! is_object($rec)){ | |
$data->removeElement($rec); | |
} | |
} | |
} | |
/** | |
* @param FormEvent $event | |
*/ | |
public function preSetData(FormEvent $event) | |
{ | |
// Builds the custom 'form' based on the provided locales | |
$data = $event->getData(); | |
$form = $event->getForm(); | |
// During form creation setData() is called with null as an argument | |
// by the FormBuilder constructor. We're only concerned with when | |
// setData is called with an actual Entity object in it (whether new, | |
// or fetched with Doctrine). This if statement let's us skip right | |
// over the null condition. | |
if (null === $data) { | |
return; | |
} | |
foreach ($this->bindTranslations($data) as $binded) { | |
/* @var $translation AbstractPersonalTranslation */ | |
$translation = $binded['translation']; | |
$form->add($this->factory->createNamed( | |
$binded['fieldName'], | |
$this->options['widget'], | |
$translation->getContent(), | |
[ | |
'auto_initialize'=> false, | |
'label' => $binded['locale'], | |
'required' => in_array($binded['locale'], $this->options['required_locale']), | |
'property_path' => null, | |
'attr' => $this->options['attr'] | |
] | |
)); | |
} | |
} | |
} |
<?php | |
namespace ExampleBundle\Form; | |
use Symfony\Component\Form\AbstractType; | |
use Symfony\Component\Form\FormBuilderInterface; | |
use Symfony\Component\OptionsResolver\OptionsResolver; | |
use Symfony\Component\Validator\Validator\ValidatorInterface; | |
use Doctrine\ORM\EntityManagerInterface; | |
use ExampleBundle\Form\Event\Subscriber\Translatable; | |
class TranslatableType extends AbstractType | |
{ | |
/** | |
* @var EntityManagerInterface | |
*/ | |
protected $em; | |
/** | |
* @var ValidatorInterface | |
*/ | |
protected $validator; | |
/** | |
* @var array | |
*/ | |
protected $locales; | |
/** | |
* @var string | |
*/ | |
protected $locale; | |
/** | |
* @param EntityManagerInterface $em | |
* @param ValidatorInterface $validator | |
* @param array $locales | |
* @param string $locale | |
*/ | |
public function __construct(EntityManagerInterface $em, ValidatorInterface $validator, array $locales, $locale) | |
{ | |
$this->em = $em; | |
$this->validator = $validator; | |
$this->locales = $locales; | |
$this->locale = $locale; | |
} | |
/** | |
* @param FormBuilderInterface $builder | |
* @param array $options | |
*/ | |
public function buildForm(FormBuilderInterface $builder, array $options) | |
{ | |
if (! class_exists($options['personal_translation'])) { | |
throw $this->getNoPersonalTranslationException($options['personal_translation']); | |
} | |
$options['field'] = $options['field'] ?: $builder->getName(); | |
$builder->addEventSubscriber( | |
new Translatable($builder->getFormFactory(), $this->em, $this->validator, $options) | |
); | |
} | |
/** | |
* @param OptionsResolver $resolver | |
*/ | |
public function configureOptions(OptionsResolver $resolver) | |
{ | |
$resolver->setDefaults($this->getDefaultOptions()); | |
} | |
/** | |
* @param array $options | |
* | |
* @return array | |
*/ | |
public function getDefaultOptions(array $options = array()) | |
{ | |
$options['remove_empty'] = true; // Personal Translations without content are removed | |
$options['csrf_protection'] = false; | |
$options['personal_translation'] = false; // Personal Translation class | |
$options['locales'] = $this->locales; // the locales you wish to edit | |
$options['required_locale'] = [$this->locale]; // the required locales cannot be blank | |
$options['field'] = false; // the field that you wish to translate | |
$options['widget'] = 'text'; // change this to another widget like 'texarea' if needed | |
$options['entity_manager_removal'] = true; // auto removes the Personal Translation thru entity manager | |
$options['attr'] = []; | |
return $options; | |
} | |
/** | |
* @param string $translation | |
* | |
* @return \InvalidArgumentException | |
*/ | |
public function getNoPersonalTranslationException($translation) | |
{ | |
return new \InvalidArgumentException(sprintf('Unable to find personal translation class: "%s"', $translation)); | |
} | |
/** | |
* @return string | |
*/ | |
public function getName() | |
{ | |
return 'translatable'; | |
} | |
} |
Here the solution to resolve the problem with the collection type.
in your TranslatableType.php in getDefaultOptions() method, add this:
use Symfony\Component\Form\FormInterface;
....
$options['empty_data'] = function (FormInterface $form) {
return new \Doctrine\Common\Collections\ArrayCollection();
};
in Translatable.php:
-
preSetData() method:
if (null === $data) { // 1/2 in case of a type collection (add new only) it's necessary to add fields for the data-prototype foreach ($this->options['locales'] as $locale) { $class_name = $this->options['personal_translation']; $translation = new $class_name($locale, $this->options['field'], null); $form->add( $this->factory->createNamed( $this->options['field'].':'.$locale, $this->options['widget'], $translation->getContent(), [ 'auto_initialize' => false, 'label' => $locale, 'required' => in_array($locale, $this->options['required_locale']), 'property_path' => null, 'attr' => $this->options['attr'] ] ) ); } return; }
-
postSubmit() method (just after $entity = $form->getParent()->getData();):
// 2/2 in case of a type collection (add new only) create a new entity if (empty($entity)) { $class_name = str_replace('Translation','',$this->options['personal_translation']); $entity = new $class_name(); }
And finally, in ALL your entity that contains the addTranslations method, add this new method:
public function setTranslations($at) // method used when values is set throught a type collection (add new throught the data-prototype)
{
foreach ($at as $t) {
$this->addTranslation($t);
}
return $this;
}
Does not work in Symfony 3:
Catchable Fatal Error: Argument 1 passed to Symfony\Component\Validator\Mapping\GenericMetadata::addConstraint() must be an instance of Symfony\Component\Validator\Constraint, string given, called in A:\[...]\vendor\symfony\symfony\src\Symfony\Component\Validator\Mapping\GenericMetadata.php on line 167 and defined
Any ideas why?
UPDATE:
To make it work, make change in submit()
method in Translatable.php
file. The 2nd argument of validate()
method must be passed as 3rd argument, and set null
as 2nd argument.
To make it work, make change in submit() method in Translatable.php file. The 2nd argument of validate() method must be passed as 3rd argument, and set null as 2nd argument.
Thank you. It works.
So uh, it's strange. Doctrine trying to insert into object field, but not object_id, and I'm getting no column object
Thank you so much, you save my life!!! 😭
Thank you. I've been looking for this for so long.
Any advice on how to use it with sonata_formatter_type
?
@peter-gribanov Thank you man!
All work in Symfony 4/Flex with validator fix.
How can I define different attributes to personal translations fields? I my case, I want to be that german and english is disabled but french should be editable. Currently all attributes are the same on each translated field, but I need some differences between them.
@peter-gribanov Thank you man!
All work in Symfony 4/Flex with validator fix.
In Symfony 4.4 i have following error. How can i solve it, please suggest solution.
In DefinitionErrorExceptionPass.php line 54:
Cannot autowire service "App\Form\Event\Subscriber\Translatable": argument "$options" of method "__construct()" is
type-hinted "array", you should configure its value explicitly.
@Nugjii this subscriber does not need to be configured as a service. It is used explicitly in a form type.
https://gist.github.com/peter-gribanov/06aeffbf10b94b998fc3#file-translatabletype-php-L61
Hi, I'm trying to use this form via an api call (JSON format).
I try to send following data:
{ "name":{ "fr":"name fr", "en": "name en" } }
But i got this error:
Child "name:en" does not exist.
Any help please?
Thanks
Changes: