I stumbled upon a question regarding Symfony's DataTransformers and how to properly use them. While I know how to implement and add them to my form field, I was wondering how DataTransformers are supposed to be combined with Constraints.
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 aConsentand 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
FormBuilderServiceis 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 Users 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
TransformationFailedExceptionif the user did not consent).This way the value is still validated, but a
DataTransformershould 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 theIsTrue-constraint. The reason being that thisConstraintallows to define a normalizer, which could transform theConsentback to abooleanfor theNotBlankValidatoronly.If only the normalizer was called independent of the value's data type (currently only
stringis supported).Extending the
IsTrue-constraint in order to override thevalidatedBymethod.While this allows me to create a custom validator that is aware of the
Consentclass, the developer needs to know whichIsTrue-constraint to use, if they decide to customize theAccountConsentField, e.g. by overriding defaultConstraints. 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-inValidators 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.