使用Zend Framework 2表单在指定的时区中处理日期和时间

I'm in the process of creating a form that let's the user schedule an event at a specified date, time and timezone. I want to combine the input of those three form fields and store them in one datetime column in the database. Based on the input I want to convert the specified date and time to UTC.

However I'm not completely sure how to write the form code for this. I was writing a Fieldset class extending Fieldset and adding the three fields to this fieldset:

<?php
namespace Application\Form\Fieldset;

use Zend\Form\Fieldset;
use Zend\InputFilter\InputFilterInterface;
use Zend\InputFilter\InputFilterProviderInterface;
use Zend\Stdlib\Hydrator\ClassMethods;

class SendDateFieldset extends Fieldset implements InputFilterProviderInterface
{
    public function __construct()
    {
        parent::__construct('senddate');

        $this->add(array(
                'name' => 'date',
                'type' => 'Text',
                'options' => array(
                        'label' => 'Date to send:',
                )
            )
        );

        $this->add(array(
                'name' => 'time',
                'type' => 'Text',
                'options' => array(
                        'label' => 'Time to send:',
                )
            )
        );

        $this->add(array(
                'name' => 'timezone',
                'type' => 'Select',
                'options'       => array(
                'label'             => "Recipient's timezone",
                'value_options'     => array(
                    -12           => '(GMT-12:00) International Date Line West',
                    -11           => '(GMT-11:00) Midway Island, Samoa',
                    -10           => '(GMT-10:00) Hawaii',
                ),
            ),
            )
        );
    }

    public function getInputFilterSpecification()
    {
        return array(
                'date' => array(
                    'required' => true,
                    'filters' => array(
                        array('name' => 'StringTrim'),
                    ),
                    'validators' => array(
                        array(
                            'name' => 'Date',
                            'break_chain_on_failure' => true,
                            'options' => array(
                                'message' => 'Invalid date'
                            ),
                        ),
                    ),
                ),

                'time' => array(
                    'required' => true,
                    'filters' => array(
                        array('name' => 'StringTrim'),
                    ),
                ),

                'timezone' => array(
                    'required' => true,
                ),
        );
    }
}

I then add this fieldset to my form like so:

<?php 
namespace Application\Form;

use Zend\Form\Form;

class Order extends Form
{
    public function __construct()
    {
        parent::__construct("new-order");
        $this->setAttribute('action', '/order');
        $this->setAttribute('method', 'post');

        $this->add(
                array(
                    'type' => 'Application\Form\Fieldset\SendDateFieldset',
                    'options' => array(
                            'use_as_base_fieldset' => false
                    ),      
                )
        );
    }
}

Of course I will add other fieldsets to the form, the base fieldset for the order information itself and another fieldset with recipient info.

I have two questions about this:

  1. What would be the most elegant way to handle the three fields and store them as 1 datetime (converted to UTC) in the database? I have an Order service object too that will be responsible for handling a new order, so I could take care of it in the method responsible for handling a new order in that service class or is there a better way?

  2. I only posted a small snippet of the list of timezones in the SendDate fieldset. Is there a cleaner way to do render this list?

Okay, so as promised I'll share my solution to this problem. Hopefully it will help someone else in the future.

I ended up using the SendDateFieldset which I initially had already.

Application\Form\Fieldset\SendDateFieldset:

<?php
namespace Application\Form\Fieldset;

use Application\Hydrator\SendDate as SendDateHydrator;

use Zend\Form\Fieldset;
use Zend\InputFilter\InputFilterInterface;
use Zend\InputFilter\InputFilterProviderInterface;

class SendDateFieldset extends Fieldset implements InputFilterProviderInterface
{
    public function __construct()
    {
        parent::__construct('senddate');
        $this->setHydrator(new SendDateHydrator());
        $this->setObject(new \DateTime());

        $this->add(array(
                'name' => 'date',
                'type' => 'Text',
                'options' => array(
                        'label' => 'Date to send:',
                )
            )
        );

        $this->add(array(
                'name' => 'time',
                'type' => 'Text',
                'options' => array(
                        'label' => 'Time to send:',
                )
            )
        );

        $this->add(array(
                'name' => 'timezone',
                'type' => 'Select',
                'options'       => array(
                'label'             => "Recipient's timezone",
                'value_options'     => array(
                        // The list of timezones is being populated by the OrderFormFactory
                ),
            ),
            )
        );
    }

    public function getInputFilterSpecification()
    {
        return array(
                'date' => array(
                    'required' => true,
                    'filters' => array(
                        array('name' => 'StringTrim'),
                    ),
                    'validators' => array(
                        array(
                            'name' => 'Date',
                            'break_chain_on_failure' => true,
                            'options' => array(
                                'message' => 'Invalid date'
                            ),
                        ),
                    ),
                ),

                'time' => array(
                    'required' => true,
                    'filters' => array(
                        array('name' => 'StringTrim'),
                    ),
                    'validators' => array(
                        array(
                            'name' => 'Callback',
                            'options' => array(
                                    'callback' => function($value, $context)
                                    {
                                        // @todo: check if date and time is in the future
                                        return true;
                                    }
                            ),
                        ),
                    ),
                ),

                'timezone' => array(
                    'required' => true,
                ),
        );
    }
}

As you can see in this fieldset I now use a plain DateTime object as entity. To populate the DateTime object I use a custom hydrator for this fieldset: SendDateHydrator, which looks like this:

<?php
namespace Application\Hydrator;

use Zend\Stdlib\Hydrator\AbstractHydrator;
use DateTime;
use DateTimeZone;

class SendDate extends AbstractHydrator
{
    public function __construct($underscoreSeparatedKeys = true)
    {
        parent::__construct();
    }

    /**
     * Extract values from an object
     *
     * @param  object $object
     * @return array
     * @throws Exception\BadMethodCallException for a non-object $object
     */
    public function extract($object)
    {
        throw new Exception\BadMethodCallException(sprintf(
                    '%s is not implemented yet)', __METHOD__
            ));
    }

    /**
     * Hydrate data into DateTime object
     *
     * @param array $data
     * @param object $object
     * @return object
     * @throws Exception\BadMethodCallException for a non-object $object
     */
    public function hydrate(array $data, $object)
    {
        if (!$object instanceof DateTime)
        {
            throw new Exception\BadMethodCallException(sprintf(
                    '%s expects the provided $object to be a DateTime object)', __METHOD__
            ));
        }

        $object = null;
        $object = new DateTime();

        if (array_key_exists('date', $data) && array_key_exists('time', $data) && array_key_exists('timezone', $data))
        {
            $object = new DateTime($data['date'] . ' ' . $data['time'], new DateTimeZone($data['timezone']));
        }
        else
        {
            throw new Exception\BadMethodCallException(sprintf(
                    '%s expects the provided $data to contain a date, time and timezone)', __METHOD__
            ));
        }

        return $object;
    }
}

The hydrate method takes care of creating the DateTime object using the timezone specified by the user using a selectbox.

To generate the select with timezones in the form I made a small service which uses DateTimeZone to generate a list of timezones and formats them nicely. The end result is an associative array that can be passed to the value options of the select. The keys of this array are official timezone identifiers that DateTimeZone can handle. I pass this list in the factory class responsible for creating the form where I use this selectbox:

Application\Factory\OrderFormFactory:

<?php
namespace Application\Factory;

use Application\Service\TimezoneService;

use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;

use Application\Form\Order as OrderForm;

class OrderFormFactory implements FactoryInterface
{
    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        $orderForm = new OrderForm();

        /* @var $timezoneSvc TimezoneService */
        $timezoneSvc = $serviceLocator->get('Application\Service\TimezoneService');

        // Set list of timezones in SendDate fieldset
        $orderForm->get('order')->get('senddate')->get('timezone')->setValueOptions(
            $timezoneSvc->getListOfTimezones()
        );
        return $orderForm;
    }
}

The generated fieldset in the form looks like this: Screenshot

When saving the order the orderservice converts the DateTime to a UTC time before storing it in the database.