سؤال

I am currently working on a symfony2.3 project with doctrine2 trying to implement personal translations management in the Sonata backend.

Translations are based on the doctrine2 translatable behaviour model: https://github.com/l3pp4rd/DoctrineExtensions/blob/master/doc/translatable.md and more precisely Personal Translations.

The admin form is using the symfony2.3 version of TranslatedFieldType.php.

My entity class is as follows:

<?php
namespace Hr\OnlineBundle\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Gedmo\Mapping\Annotation as Gedmo;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @Gedmo\TranslationEntity(class="Hr\OnlineBundle\Entity\CategoryTranslation")
 */
class Category
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue
     */
    private $id;

    /**
     * @Gedmo\Translatable
     * @ORM\Column(length=64)
     */
    private $title;

    /**
     * @Gedmo\Translatable
     * @ORM\Column(type="text", nullable=true)
     */
    private $description;

    /**
     * @ORM\OneToMany(
     *   targetEntity="CategoryTranslation",
     *   mappedBy="object",
     *   cascade={"persist", "remove"}
     * )
     */
    private $translations;

    public function __construct()
    {
        $this->translations = new ArrayCollection();
    }

    public function getTranslations()
    {
        return $this->translations;
    }

    public function addTranslation(CategoryTranslation $t)
    {
        if (!$this->translations->contains($t)) {
            $this->translations[] = $t;
            $t->setObject($this);
        }
    }

    public function getId()
    {
        return $this->id;
    }

    public function setTitle($title)
    {
        $this->title = $title;
    }

    public function getTitle()
    {
        return $this->title;
    }

    public function setDescription($description)
    {
        $this->description = $description;
    }

    public function getDescription()
    {
        return $this->description;
    }

    public function __toString()
    {
        return $this->getTitle();
    }
}

The related Personal Translation class is this:

<?php

namespace Hr\OnlineBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Gedmo\Translatable\Entity\MappedSuperclass\AbstractPersonalTranslation;

/**
 * @ORM\Entity
 * @ORM\Table(name="category_translations",
 *     uniqueConstraints={@ORM\UniqueConstraint(name="lookup_unique_idx", columns={
 *         "locale", "object_id", "field"
 *     })}
 * )
 */
class CategoryTranslation extends AbstractPersonalTranslation
{
    /**
     * Convinient constructor
     *
     * @param string $locale
     * @param string $field
     * @param string $value
     */
    public function __construct($locale, $field, $value)
    {
        $this->setLocale($locale);
        $this->setField($field);
        $this->setContent($value);
    }

    /**
     * @ORM\ManyToOne(targetEntity="Category", inversedBy="translations")
     * @ORM\JoinColumn(name="object_id", referencedColumnName="id", onDelete="CASCADE")
     */
    protected $object;
}

The admin form class is this:

<?php
namespace Hr\OnlineBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Hr\OnlineBundle\Form\Type\TranslatedFieldType;

class CategoryAdmin extends Admin
{
    // Fields to be shown on create/edit forms
    protected function configureFormFields(FormMapper $formMapper)
    {

        $formMapper
        ->with('General')
            ->add('title', 'translatable_field', array(
                'field'                => 'title',
                'personal_translation' => 'Hr\OnlineBundle\Entity\CategoryTranslation',
                'property_path'        => 'translations',
            ))
        ->end();

    }

    // Fields to be shown on filter forms
    protected function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('title')
        ;
    }

    // Fields to be shown on lists
    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('title')
        ;
    }
}

The admin form is using the following form type class:

<?php
namespace Hr\OnlineBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

use Hr\OnlineBundle\Form\EventListener\addTranslatedFieldSubscriber;

class TranslatedFieldType extends AbstractType
{
    protected $container;

    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        if(! class_exists($options['personal_translation']))
        {
            Throw new \InvalidArgumentException(sprintf("Unable to find personal translation class: '%s'", $options['personal_translation']));
        }
        if(! $options['field'])
        {
            Throw new \InvalidArgumentException("You should provide a field to translate");
        }

        $subscriber = new addTranslatedFieldSubscriber($builder->getFormFactory(), $this->container, $options);
        $builder->addEventSubscriber($subscriber);
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'remove_empty' => true,
            'csrf_protection'=> false,
            'field' => false,
            'personal_translation' => false,
            'locales'=>array('en', 'fr', 'de'),
            'required_locale'=>array('en'),
            'widget'=>'text',
            'entity_manager_removal'=>true,
        ));
    }

    public function getName()
    {
        return 'translatable_field';
    }
}

and the corresponding Event Listener:

<?php

namespace Hr\OnlineBundle\Form\EventListener;

use Symfony\Component\Form\Event\DataEvent;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Form\FormError;

class AddTranslatedFieldSubscriber implements EventSubscriberInterface
{
    private $factory;
    private $options;
    private $container;

    public function __construct(FormFactoryInterface $factory, ContainerInterface $container, Array $options)
    {
        $this->factory = $factory;
        $this->options = $options;
        $this->container = $container;
    }

    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 array(
            FormEvents::PRE_SET_DATA => 'preSetData',
            FormEvents::POST_BIND => 'postBind',
            FormEvents::BIND => 'bindNormData'
        );  
    }

    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 = array();
        $availableTranslations = array();

        foreach($data as $Translation)
        {
            if(strtolower($Translation->getField()) == strtolower($this->options['field']))
            {
                $availableTranslations[ strtolower($Translation->getLocale()) ] = $Translation;
            }
        }

        foreach($this->getFieldNames() as $locale => $fieldName)
        {
            if(isset($availableTranslations[ strtolower($locale) ]))
            {
                $Translation = $availableTranslations[ strtolower($locale) ];
            }
            else
            {
                $Translation = $this->createPersonalTranslation($locale, $this->options['field'], NULL);
            }

            $collection[] = array(
                'locale'      => $locale,
                'fieldName'   => $fieldName,
                'translation' => $Translation,
            );
        }

        return $collection;
    }

    private function getFieldNames()
    {
        //helper function to generate all field names in format:
        // '<locale>' => '<field>|<locale>'
        $collection = array();
        foreach($this->options['locales'] as $locale)
        {
            $collection[ $locale ] = $this->options['field'] .":". $locale;
        }
        return $collection;
    }

    private function createPersonalTranslation($locale, $field, $content)
    {
        //creates a new Personal Translation
        $className = $this->options['personal_translation'];

        return new $className($locale, $field, $content);
    }

    public function bindNormData(FormEvent $event)
    {
        //Validates the submitted form
        $data = $event->getData();
        $form = $event->getForm();

        $validator = $this->container->get('validator');

        foreach($this->getFieldNames() as $locale => $fieldName)
        {
            $content = $form->get($fieldName)->getData();

            if(
                NULL === $content &&
                in_array($locale, $this->options['required_locale']))
            {
                $form->addError(new FormError(sprintf("Field '%s' for locale '%s' cannot be blank", $this->options['field'], $locale)));
            }
            else
            {
                $Translation = $this->createPersonalTranslation($locale, $fieldName, $content);
                $errors = $validator->validate($Translation, array(sprintf("%s:%s", $this->options['field'], $locale)));

                if(count($errors) > 0)
                {
                    foreach($errors as $error)
                    {
                        $form->addError(new FormError($error->getMessage()));
                    }
                }
            }
        }
    }

    public function postBind(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();
           $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->container->get('doctrine.orm.entity_manager')->remove($Translation);
                   }
               }
           }
           elseif(NULL !== $content)
           {
               //add it to entity
               $entity->addTranslation($Translation);

               if(! $data->contains($Translation))
               {
                   $data->add($Translation);
               }
           }
       }
    }

    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)
        {
            $form->add($this->factory->createNamed(
                $binded['fieldName'],
                $this->options['widget'],
                $binded['translation']->getContent(),
                array(
                    'label' => $binded['locale'],
                    'required' => in_array($binded['locale'], $this->options['required_locale']),
                    'auto_initialize' => false,
                )
            ));
        }
    }
}

So, the form shows the three fields for the three specified locales, which is fine, but when submitting the form the following error occurs:

FatalErrorException: Error: Call to a member function getField() on a non-object in C:\wamp\www\hronline\src\Hr\OnlineBundle\Form\EventListener\addTranslatedFieldSubscriber.php line 47
in C:\wamp\www\hronline\src\Hr\OnlineBundle\Form\EventListener\addTranslatedFieldSubscriber.php line 47
at ErrorHandler->handleFatal() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\Debug\ErrorHandler.php line 0
at AddTranslatedFieldSubscriber->bindTranslations() in C:\wamp\www\hronline\src\Hr\OnlineBundle\Form\EventListener\addTranslatedFieldSubscriber.php line 136
at AddTranslatedFieldSubscriber->postBind() in C:\wamp\www\hronline\app\cache\dev\classes.php line 1667
at ??call_user_func() in C:\wamp\www\hronline\app\cache\dev\classes.php line 1667
at EventDispatcher->doDispatch() in C:\wamp\www\hronline\app\cache\dev\classes.php line 1600
at EventDispatcher->dispatch() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\EventDispatcher\ImmutableEventDispatcher.php line 42
at ImmutableEventDispatcher->dispatch() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\Form\Form.php line 631
at Form->submit() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\Form\Form.php line 552
at Form->submit() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\Form\Form.php line 645
at Form->bind() in C:\wamp\www\hronline\vendor\sonata-project\admin-bundle\Sonata\AdminBundle\Controller\CRUDController.php line 498
at CRUDController->createAction() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2844
at ??call_user_func_array() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2844
at HttpKernel->handleRaw() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2818
at HttpKernel->handle() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2947
at ContainerAwareHttpKernel->handle() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2249
at Kernel->handle() in C:\wamp\www\hronline\web\app_dev.php line 28
at ??{main}() in C:\wamp\www\hronline\web\app_dev.php line 0

It appears that on this line of the event listener AddTranslatedFieldSubscriber:

if(strtolower($Translation->getField()) == strtolower($this->options['field']))

the $Translation variable comes as a string (e.g. string 'Lorem' (length=5)) instead of an object. For some reason the form data is converted to an array of strings instead of an array of objects of type CategoryTranslation.

What could be the reason for this? Thanks!

هل كانت مفيدة؟

المحلول

Do yourself a favour and don't use the Gedmo Translatable behaviour. It is very buggy, slow and has a lot of weird edge cases. I recommend using the KNP translatable behaviour which is quite good (requires PHP 5.4) or the Prezent Translatable which is similar but can work on PHP 5.3. Disclaimer: I wrote that last one. It's beta but works fine. I just added the documentation for it.

You can use the a2lix bundle to integrate it all in Sonata Admin.

مرخصة بموجب: CC-BY-SA مع الإسناد
لا تنتمي إلى StackOverflow
scroll top