I stumbled upon a question regarding Symfony
's DataTransformer
s and how to properly use them. While I know how to implement and add them to my form field, I was wondering how DataTransformer
s are supposed to be combined with Constraint
s.
The following code shows my use case.
The Form
<?php
namespace AppBundle\Form;
use Passioneight\Bundle\FormBuilderBundle\Form\AbstractForm;
use Passioneight\Bundle\FormBuilderBundle\Form\DataTransformer\Consent\ConsentTransformer;
use Passioneight\Bundle\FormBuilderBundle\Form\Field\Button\SubmitButton;
use Passioneight\Bundle\FormBuilderBundle\Form\Field\Checkbox\AccountConsentField;
use Passioneight\Bundle\FormBuilderBundle\Form\Field\Text\EmailField;
use Passioneight\Bundle\FormBuilderBundle\Form\Field\Text\RepeatedField\RepeatedPasswordField;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class RegistrationForm extends AbstractForm
{
const OPTION_TERMS_AND_CONDITIONS_HREF = "terms-and-conditions-href";
/** @var ConsentTransformer $consentTransformer */
private $consentTransformer;
/**
* RegistrationForm constructor.
* @param ConsentTransformer $consentTransformer
*/
public function __construct(ConsentTransformer $consentTransformer)
{
$this->consentTransformer = $consentTransformer;
}
/**
* @inheritDoc
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addField(new EmailField());
$builder->addField(new RepeatedPasswordField());
$builder->addField(new AccountConsentField([
'label_translation_parameters' => [
'{{ href }}' => $options[self::OPTION_TERMS_AND_CONDITIONS_HREF],
'{{ href-class }}' => 'text-primary-hover'
],
]));
$builder->get(AccountConsentField::NAME)->addModelTransformer($this->consentTransformer);
$builder->addField(new SubmitButton([
'label_format' => 'form.registration.%name%',
'attr' => [
'class' => 'btn-primary btn-raised m-0'
]
]));
}
/**
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$this->allowOption($resolver, self::OPTION_TERMS_AND_CONDITIONS_HREF, "#");
}
}
Wondering about the way form fields are added to the form? That's because I'm using my bundle. It's essentially the same as $builder->addField(...);
, only that any predefined fields in the bundle can be added in a more convenient way.
The Model
<?php
class User extends Concrete implements \Pimcore\Model\DataObject\DirtyIndicatorInterface
{
protected $accountConsent;
/**
* Get accountConsent - Account Consent
* @return \Pimcore\Model\DataObject\Data\Consent
*/
public function getAccountConsent () {
if($this instanceof PreGetValueHookInterface && !\Pimcore::inAdmin()) {
$preValue = $this->preGetValue("accountConsent");
if($preValue !== null) {
return $preValue;
}
}
$data = $this->accountConsent;
if ($data instanceof \Pimcore\Model\DataObject\Data\EncryptedField) {
return $data->getPlain();
}
return $data;
}
/**
* Set accountConsent - Account Consent
* @param \Pimcore\Model\DataObject\Data\Consent $accountConsent
* @return \Pimcore\Model\DataObject\User
*/
public function setAccountConsent ($accountConsent) {
$fd = $this->getClass()->getFieldDefinition("accountConsent");
$this->accountConsent = $accountConsent;
return $this;
}
}
A lot of code was omitted for the sake of brevity. The model is a Pimcore class, which also holds the code required for mapping the other form fields (such as EmailField
).
The DataTransformer
<?php
namespace Passioneight\Bundle\FormBuilderBundle\Form\DataTransformer\Consent;
use Pimcore\Model\DataObject\Data\Consent;
use Symfony\Component\Form\DataTransformerInterface;
class ConsentTransformer implements DataTransformerInterface
{
/**
* @inheritDoc
* @param Consent|null $consent
*/
public function transform($consent)
{
return $consent instanceof Consent && $consent->getConsent();
}
/**
* @inheritDoc
* @param bool|null $consented
*/
public function reverseTransform($consented)
{
$consent = new Consent();
$consent->setConsent($consented ?: false);
return $consent;
}
}
As you can see any submitted value (i.e., null
, true
, false
) will be converted to a Consent
and vice-versa.
Building the Form
<?php
namespace AppBundle\Controller;
use AppBundle\Form\RegistrationForm;
use Pimcore\Model\DataObject\User;
use Passioneight\Bundle\FormBuilderBundle\Service\FormBuilderService;
/**
* Class AuthController
* @package AppBundle\Controller
*
* @Route("/{_locale}")
*/
class AuthController extends AbstractFrontendController
{
/**
* @Route("/registration")
*
* @param FormBuilderService $formBuilderService
*/
public function registrationAction(FormBuilderService $formBuilderService)
{
$options = [
RegistrationForm::OPTION_TERMS_AND_CONDITIONS_HREF => "/url/to/TACs",
];
$formEvent = $formBuilderService->buildForm(RegistrationForm::class, new User(), $options);
$this->view->registrationForm = $formEvent->getFormView();
}
}
Note how a new User()
is passed as entity in order to automatically populate it with the submitted values.
The FormBuilderService
is also available in my bundle, as it is just some convience class to avoid doing the same thing over and over again for each form.
The Problem
The form can be built just fine, ultimately, displaying a checkbox with my specified label. Due to the transformer, the checked
-state is even correctly displayed, as the transform
method converts the User
s Consent
into a boolean
(even if no Consent
was set yet).
However, when submitting the form an error is displayed, saying that the account consent is required. While this is fine when it comes to submitting the form without giving consent, it's not quite the desired outcome when acutally consenting.
The submitted value is converted to a Consent
, which will then hold the value true
. But since the transformation is done before the submitted value is validated the beforementioned error is displayed. This happens, because the AccountConsentField
that was added in the form has a Constraint
set, namely, IsTrue
. Due to this, the IsTrueValidator
validates the Consent
(instead of the actually submitted value).
Obviously, the IsTrueValidator
cannot know about Pimcore's Consent
class. So, I've found the following workarounds.
Known Workarounds
- Removing the
IsTrue
-constraint.
By doing so, the value is not validated anymore, but that's not a good idea - for obvious reasons.
- Same as 1., but with the validation in the transformer (i.e., by throwing the
TransformationFailedException
if the user did not consent).
This way the value is still validated, but a DataTransformer
should not handle validation (which others think too).
- Leaving the transformation to the developer.
Well, obviously I don't want to go with this option, because I want to ease these kind of things for the developers.
All of these workarounds also leave me with the problem of a possible combination of the DataTransformer
and any Constraint
when a developer is not aware of this issue.
Side-Note: I labled this "workarounds" because these solutions just don't feel like the proper way to go.
The Question
All of this leaves me with the question: how do I properly combine the IsTrue
-constraint with my ConsentDataTransformer
?
What else I tried
Using a NotBlank
-constraint instead of the IsTrue
-constraint. The reason being that this Constraint
allows to define a normalizer, which could transform the Consent
back to a boolean
for the NotBlankValidator
only.
If only the normalizer was called independent of the value's data type (currently only string
is supported).
Extending the IsTrue
-constraint in order to override the validatedBy
method.
While this allows me to create a custom validator that is aware of the Consent
class, the developer needs to know which IsTrue
-constraint to use, if they decide to customize the AccountConsentField
, e.g. by overriding default Constraint
s. Even though this situation is unlikely; just imagine a different transformer for a field that is likely changed in terms of constraints. Symfony's built-in Validator
s would most likely behave unexpectedly.
Research
I've already searched for similar issues and found that especially this question seems to be alike (though only in the beginning). Yet, it did not quite answer my question.