Leevi Graham
Leevi Graham

Reputation: 674

Symfony2 DateRange custom type

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:

Cons:

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

enter image description here enter image description here

Upvotes: 1

Views: 4115

Answers (2)

Bernhard Schussek
Bernhard Schussek

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

np87
np87

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

Related Questions