Reputation: 674
I'm trying to create a simple custom Symfony2 dateRange formType with a slight twist.
The idea is that the user can choose a string representation of the date eg "today", "this month" or "custom" start / end dates.
When the form is submitted if a user chooses a period eg: "today" the start and end submitted form data are ignored and the start and end are calculated based on the period.
When submitting the form with "custom" period:
When I fill in the form
And submit "period" with "custom"
And submit "start" with "2014-01-01"
And submit "end" with "2014-01-01"
Then the form should display:
"custom" in the period select box
And "2014-01-01" in start
And "2014-01-01" in end
When submitting the form with "tomorrow" period (assuming the date is 2014-01-01):
When I fill in the form
And submit "period" with "tomorrow"
Then the form should display:
"tomorrow" in the period select box
And "2014-01-02" in start
And "2014-01-02" in end
The view / norm data is an array consisting of a period (int), start, end.
$viewData = array(
'period' => 0,
'start' => new \DateTime()
'end' => new \DateTime()
);
The model data is a DateRange value object.
<?php
namespace Nsm\Bundle\ApiBundle\Form\Model;
use DateTime;
class DateRange
{
/**
* @var DateTime
*/
protected $start;
/**
* @var DateTime
*/
protected $end;
/**
* @param DateTime $start
* @param DateTime $end
*/
public function __construct(DateTime $start = null, DateTime $end = null)
{
$this->start = $start;
$this->end = $end;
}
/**
* @param DateTime $start
*
* @return $this
*/
public function setStart(DateTime $start = null)
{
$this->start = $start;
return $this;
}
/**
* @return DateTime
*/
public function getStart()
{
return $this->start;
}
/**
* @param DateTime $end
*
* @return $this
*/
public function setEnd(DateTime $end = null)
{
$this->end = $end;
return $this;
}
/**
* @return DateTime
*/
public function getEnd()
{
return $this->end;
}
}
The form type looks like:
<?php
namespace Nsm\Bundle\ApiBundle\Form\Type;
use Nsm\Bundle\ApiBundle\Form\DataTransformer\DateRangeToArrayTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Symfony\Component\Form\Form;
class DateRangeType extends AbstractType
{
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add(
'period',
'choice',
array(
'choices' => array(
'custom',
'today',
'tomorrow',
)
)
)
->add('start', 'date')
->add('end', 'date');
$transformer = new DateRangeToArrayTransformer();
$builder->addModelTransformer($transformer);
}
/**
* @param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(
array(
// Don't modify DateRange classes by reference, we treat
// them like immutable value objects
'by_reference' => false,
'error_bubbling' => false,
// If initialized with a DateRange object, FormType initializes
// this option to "DateRange". Since the internal, normalized
// representation is not DateRange, but an array, we need to unset
// this option.
'data_class' => null,
'required' => false
)
);
}
/**
* @return string
*/
public function getName()
{
return 'date_range';
}
}
And finally the transformer looks like:
<?php
namespace Nsm\Bundle\ApiBundle\Form\DataTransformer;
use Nsm\Bundle\ApiBundle\Form\Model\DateRange;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class DateRangeToArrayTransformer implements DataTransformerInterface
{
/**
* Model to Norm
*
* @param mixed $dateRange
*
* @return array|mixed|string
* @throws \Symfony\Component\Form\Exception\UnexpectedTypeException
*/
public function transform($dateRange)
{
if (null === $dateRange) {
return null;
}
if (!$dateRange instanceof DateRange) {
throw new UnexpectedTypeException($dateRange, 'DateRange');
}
return array(
'period' => 0,
'start' => $dateRange->getStart(),
'end' => $dateRange->getEnd()
);
}
/**
* Norm to Model
*
* @param $value
*
* @return DateRange|null
* @throws \Symfony\Component\Form\Exception\UnexpectedTypeException
*/
public function reverseTransform($value)
{
if (null === $value) {
return null;
}
if (!is_array($value)) {
throw new UnexpectedTypeException($value, 'array');
}
// Check here if period is custom and calculate dates
return new DateRange($value['start'], $value['end']);
}
}
I've decided to store the normalised data as an array so I can keep the 'period' value.
The code above transforms the form data as expected but I still ave an issue of manipulating the start and end values based on the period value.
My first attempt was to change the value of start / end in the reverseTransform method of the transformer. However this would break the bijective principal.
Next attempt was to use events.
Using FormEvents:PRE_SUBMIT
introduced a few more complications namely the date form type can be submitted as a raw single text or array.
Using FormEvents:SUBMIT
allows me to manipulate the form data successfully. $form->getData()
returns the correct \DateRange object. However the start, end child forms are not updated (their view data is already set).
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) {
$data = $event->getData();
// ... manipulate $data here based on period ...
$event->setData($data);
});
So my question:
Cheers Leevi
Update
Tweaking the data transformer to check the period
in reverseTransform to check the period and return a new DateRange
kinda works.
Pros:
$form->getData();
returns the correct DateRange
objectCons:
$form->get('end');
returns the child form but it's data does not reflect the modified data set in reverseTransform.All of the cons relate to the fact that the changes in reverseTransform are not pushed down into the child forms as they are processed first.
<?php
namespace Nsm\Bundle\ApiBundle\Form\DataTransformer;
use Nsm\Bundle\ApiBundle\Form\Model\DateRange;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class DateRangeToArrayTransformer implements DataTransformerInterface
{
/**
* Model to Norm
*
* @param mixed $dateRange
*
* @return array|mixed|string
* @throws \Symfony\Component\Form\Exception\UnexpectedTypeException
*/
public function transform($dateRange)
{
if (null === $dateRange) {
return null;
}
if (!$dateRange instanceof DateRange) {
throw new UnexpectedTypeException($dateRange, 'DateRange');
}
return array(
'period' => 0,
'start' => $dateRange->getStart(),
'end' => $dateRange->getEnd()
);
}
/**
* Norm to Model
*
* @param $value
*
* @return DateRange|null
* @throws \Symfony\Component\Form\Exception\UnexpectedTypeException
*/
public function reverseTransform($value)
{
if (null === $value) {
return null;
}
if (!is_array($value)) {
throw new UnexpectedTypeException($value, 'array');
}
switch($value['period']) {
// Custom
case 0:
$start = $value['start'];
$end = $value['end'];
break;
// Today
case 1:
$start = new \DateTime('today');
$end = new \DateTime('today');
break;
// Tomorrow
case 2:
$start = new \DateTime('tomorrow');
$end = new \DateTime('tomorrow');
break;
// This week
case 3:
$start = new \DateTime('this week');
$end = new \DateTime('this week');
break;
default:
break;
}
// Check here if period is custom and calculate dates
return new DateRange($start, $end);
}
}
Upvotes: 1
Views: 4115
Reputation: 4841
Should I be assigning events to the child start / end forms rather than the parent form?
This sounds like a good solution to me, did you try that?
$correctDateBasedOnPeriod = function (FormEvent $event) {
$period = $event->getForm()->getParent()->get('period')->getData();
if (1 === $period) {
$event->setData(new \DateTime('today'));
} elseif (2 === $period) {
$event->setData(new \DateTime('tomorrow'));
}
};
$builder->get('start')->addEventListener(FormEvents::SUBMIT, $correctDateBasedOnPeriod);
$builder->get('end')->addEventListener(FormEvents::SUBMIT, $correctDateBasedOnPeriod);
This solution has the minor drawback that then "start" and "end" depend on "period" being submitted before them. Currently, this is the case - as long as you add "period" before adding the other fields - but there's no guarantee this will be the same in the future (due to potential optimizations).
However, should this ever change, you will most likely also have a new syntax to declare the dependencies between the fields and enable BC behavior.
Upvotes: 0
Reputation: 543
I would definitely give up the transformer and use custom getter/setters methods instead with validation to ensure the stability of your data.
Maybe I'm missing something that forces you to use transformers though...
Upvotes: 1