Skip to content

Instantly share code, notes, and snippets.

@peter-gribanov
Forked from qrizly/TranslatedFieldType.php
Last active August 17, 2024 10:02
Show Gist options
  • Save peter-gribanov/06aeffbf10b94b998fc3 to your computer and use it in GitHub Desktop.
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';
}
}
@peter-gribanov
Copy link
Author

Changes:

  • Add support Symfony 2.7 (Symfony 3 not tested, but should work)
  • Correct code style to PSR
  • Add annotations for autocomplete in IDE
  • Add attr option (can change CSS class for field)
  • Rename field translatable_field -> translatable
  • Rename translatable classes
    • ExampleBundle\Form\EventListener\AddTranslatedFieldSubscriber -> ExampleBundle\Form\Event\Subscriber\Translatable
    • ExampleBundle\Form\TranslatedFieldType -> ExampleBundle\Form\TranslatableType
  • Get supported locales from service parameters
  • Not use DI container
  • Add example usage
  • Auto set the field option from the form field name (_field_ option is not a required)

@Ronan-Lenor
Copy link

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;
}

@jazithedev
Copy link

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.

@Mirozh
Copy link

Mirozh commented May 31, 2016

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.

@idchlife
Copy link

So uh, it's strange. Doctrine trying to insert into object field, but not object_id, and I'm getting no column object

@edertxodev
Copy link

Thank you so much, you save my life!!! 😭

@valerymelou
Copy link

Thank you. I've been looking for this for so long.

Any advice on how to use it with sonata_formatter_type?

@Bukashk0zzz
Copy link

Bukashk0zzz commented Jan 28, 2018

@peter-gribanov Thank you man!

All work in Symfony 4/Flex with validator fix.

@StefanGla
Copy link

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.

@Nugjii
Copy link

Nugjii commented Jan 12, 2021

@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.

@peter-gribanov
Copy link
Author

@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

@Muspi
Copy link

Muspi commented Jun 18, 2021

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment