Skip to content

Commit ded6c03

Browse files
committed
[Form] DateTimeType now handles RFC 3339 dates as provided by HTML5
1 parent 7e8b622 commit ded6c03

File tree

8 files changed

+274
-25
lines changed

8 files changed

+274
-25
lines changed

src/Symfony/Component/Form/CHANGELOG.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,8 @@ CHANGELOG
149149
* fixed: the "data" option supersedes default values from the model
150150
* changed DateType to refer to the "format" option for calculating the year and day choices instead
151151
of padding them automatically
152-
* [BC BREAK] DateType defaults to the format "yyyy-MM-dd" now in order to support
153-
the HTML 5 date field out of the box
152+
* [BC BREAK] DateType defaults to the format "yyyy-MM-dd" now if the widget is
153+
"single_text", in order to support the HTML 5 date field out of the box
154154
* added the option "format" to DateTimeType
155-
* [BC BREAK] DateTimeType defaults to the format "yyyy-MM-dd'T'HH:mm:ss" now. This
156-
is almost identical to the pattern of the HTML 5 datetime input, but not quite,
157-
because ICU cannot generate RFC 3339 dates (which have a timezone suffix).
155+
* [BC BREAK] DateTimeType now outputs RFC 3339 dates by default, as generated and
156+
consumed by HTML5 browsers, if the widget is "single_text"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Extension\Core\DataTransformer;
13+
14+
use Symfony\Component\Form\Exception\UnexpectedTypeException;
15+
use Symfony\Component\Form\Exception\TransformationFailedException;
16+
17+
/**
18+
* @author Bernhard Schussek <bschussek@gmail.com>
19+
*/
20+
class DateTimeToRfc3339Transformer extends BaseDateTimeTransformer
21+
{
22+
/**
23+
* {@inheritDoc}
24+
*/
25+
public function transform($dateTime)
26+
{
27+
if (null === $dateTime) {
28+
return '';
29+
}
30+
31+
if (!$dateTime instanceof \DateTime) {
32+
throw new UnexpectedTypeException($dateTime, '\DateTime');
33+
}
34+
35+
if ($this->inputTimezone !== $this->outputTimezone) {
36+
$dateTime = clone $dateTime;
37+
$dateTime->setTimezone(new \DateTimeZone($this->outputTimezone));
38+
}
39+
40+
return preg_replace('/\+00:00$/', 'Z', $dateTime->format('c'));
41+
}
42+
43+
/**
44+
* {@inheritDoc}
45+
*/
46+
public function reverseTransform($rfc3339)
47+
{
48+
if (!is_string($rfc3339)) {
49+
throw new UnexpectedTypeException($rfc3339, 'string');
50+
}
51+
52+
if ('' === $rfc3339) {
53+
return null;
54+
}
55+
56+
57+
$dateTime = new \DateTime($rfc3339);
58+
59+
if ($this->outputTimezone !== $this->inputTimezone) {
60+
try {
61+
$dateTime->setTimezone(new \DateTimeZone($this->inputTimezone));
62+
} catch (\Exception $e) {
63+
throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e);
64+
}
65+
}
66+
67+
if (preg_match('/(\d{4})-(\d{2})-(\d{2})/', $rfc3339, $matches)) {
68+
if (!checkdate($matches[2], $matches[3], $matches[1])) {
69+
throw new TransformationFailedException(sprintf(
70+
'The date "%s-%s-%s" is not a valid date.',
71+
$matches[1],
72+
$matches[2],
73+
$matches[3]
74+
));
75+
}
76+
}
77+
78+
return $dateTime;
79+
}
80+
}

src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer;
2323
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToLocalizedStringTransformer;
2424
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer;
25+
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToRfc3339Transformer;
2526
use Symfony\Component\Form\Extension\Core\DataTransformer\ArrayToPartsTransformer;
2627
use Symfony\Component\OptionsResolver\Options;
2728
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
@@ -44,8 +45,17 @@ class DateTimeType extends AbstractType
4445
* http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax
4546
* http://www.w3.org/TR/html-markup/input.datetime.html
4647
* http://tools.ietf.org/html/rfc3339
48+
*
49+
* An ICU ticket was created:
50+
* http://icu-project.org/trac/ticket/9421
51+
*
52+
* To temporarily circumvent this issue, DateTimeToRfc3339Transformer is used
53+
* when the format matches this constant.
54+
*
55+
* ("ZZZZZZ" is not recognized by ICU and used here to differentiate this
56+
* pattern from custom patterns).
4757
*/
48-
const HTML5_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
58+
const HTML5_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZZZZZZ";
4959

5060
private static $acceptedFormats = array(
5161
\IntlDateFormatter::FULL,
@@ -78,18 +88,25 @@ public function buildForm(FormBuilderInterface $builder, array $options)
7888
}
7989

8090
if (null !== $pattern && (false === strpos($pattern, 'y') || false === strpos($pattern, 'M') || false === strpos($pattern, 'd') || false === strpos($pattern, 'H') || false === strpos($pattern, 'm'))) {
81-
throw new InvalidOptionsException(sprintf('The "format" option should contain the patterns "y", "M", "d", "H" and "m". Its current value is "%s".', $pattern));
91+
throw new InvalidOptionsException(sprintf('The "format" option should contain the letters "y", "M", "d", "H" and "m". Its current value is "%s".', $pattern));
8292
}
8393

8494
if ('single_text' === $options['widget']) {
85-
$builder->addViewTransformer(new DateTimeToLocalizedStringTransformer(
86-
$options['data_timezone'],
87-
$options['user_timezone'],
88-
$dateFormat,
89-
$timeFormat,
90-
$calendar,
91-
$pattern
92-
));
95+
if (self::HTML5_FORMAT === $pattern) {
96+
$builder->addViewTransformer(new DateTimeToRfc3339Transformer(
97+
$options['data_timezone'],
98+
$options['user_timezone']
99+
));
100+
} else {
101+
$builder->addViewTransformer(new DateTimeToLocalizedStringTransformer(
102+
$options['data_timezone'],
103+
$options['user_timezone'],
104+
$dateFormat,
105+
$timeFormat,
106+
$calendar,
107+
$pattern
108+
));
109+
}
93110
} else {
94111
// Only pass a subset of the options to children
95112
$dateOptions = array_intersect_key($options, array_flip(array(

src/Symfony/Component/Form/Extension/Core/Type/DateType.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public function buildForm(FormBuilderInterface $builder, array $options)
5353
}
5454

5555
if (null !== $pattern && (false === strpos($pattern, 'y') || false === strpos($pattern, 'M') || false === strpos($pattern, 'd'))) {
56-
throw new InvalidOptionsException(sprintf('The "format" option should contain the patterns "y", "M" and "d". Its current value is "%s".', $pattern));
56+
throw new InvalidOptionsException(sprintf('The "format" option should contain the letters "y", "M" and "d". Its current value is "%s".', $pattern));
5757
}
5858

5959
if ('single_text' === $options['widget']) {

src/Symfony/Component/Form/Tests/AbstractLayoutTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -970,7 +970,7 @@ public function testDateTimeWithWidgetSingleText()
970970
'/input
971971
[@type="datetime"]
972972
[@name="name"]
973-
[@value="2011-02-03T04:05:06"]
973+
[@value="2011-02-03T04:05:06+01:00"]
974974
'
975975
);
976976
}
@@ -988,7 +988,7 @@ public function testDateTimeWithWidgetSingleTextIgnoreDateAndTimeWidgets()
988988
'/input
989989
[@type="datetime"]
990990
[@name="name"]
991-
[@value="2011-02-03T04:05:06"]
991+
[@value="2011-02-03T04:05:06+01:00"]
992992
'
993993
);
994994
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Tests\Extension\Core\DataTransformer;
13+
14+
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToRfc3339Transformer;
15+
16+
class DateTimeToRfc3339TransformerTest extends DateTimeTestCase
17+
{
18+
protected $dateTime;
19+
protected $dateTimeWithoutSeconds;
20+
21+
protected function setUp()
22+
{
23+
parent::setUp();
24+
25+
$this->dateTime = new \DateTime('2010-02-03 04:05:06 UTC');
26+
$this->dateTimeWithoutSeconds = new \DateTime('2010-02-03 04:05:00 UTC');
27+
}
28+
29+
protected function tearDown()
30+
{
31+
$this->dateTime = null;
32+
$this->dateTimeWithoutSeconds = null;
33+
}
34+
35+
public static function assertEquals($expected, $actual, $message = '', $delta = 0, $maxDepth = 10, $canonicalize = FALSE, $ignoreCase = FALSE)
36+
{
37+
if ($expected instanceof \DateTime && $actual instanceof \DateTime) {
38+
$expected = $expected->format('c');
39+
$actual = $actual->format('c');
40+
}
41+
42+
parent::assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
43+
}
44+
45+
public function allProvider()
46+
{
47+
return array(
48+
array('UTC', 'UTC', '2010-02-03 04:05:06 UTC', '2010-02-03T04:05:06Z'),
49+
array('UTC', 'UTC', null, ''),
50+
array('America/New_York', 'Asia/Hong_Kong', '2010-02-03 04:05:06 America/New_York', '2010-02-03T17:05:06+08:00'),
51+
array('America/New_York', 'Asia/Hong_Kong', null, ''),
52+
array('UTC', 'Asia/Hong_Kong', '2010-02-03 04:05:06 UTC', '2010-02-03T12:05:06+08:00'),
53+
array('America/New_York', 'UTC', '2010-02-03 04:05:06 America/New_York', '2010-02-03T09:05:06Z'),
54+
);
55+
}
56+
57+
public function transformProvider()
58+
{
59+
return $this->allProvider();
60+
}
61+
62+
public function reverseTransformProvider()
63+
{
64+
return array_merge($this->allProvider(), array(
65+
// format without seconds, as appears in some browsers
66+
array('UTC', 'UTC', '2010-02-03 04:05:00 UTC', '2010-02-03T04:05Z'),
67+
array('America/New_York', 'Asia/Hong_Kong', '2010-02-03 04:05:00 America/New_York', '2010-02-03T17:05+08:00'),
68+
));
69+
}
70+
71+
/**
72+
* @dataProvider transformProvider
73+
*/
74+
public function testTransform($fromTz, $toTz, $from, $to)
75+
{
76+
$transformer = new DateTimeToRfc3339Transformer($fromTz, $toTz);
77+
78+
$this->assertSame($to, $transformer->transform(null !== $from ? new \DateTime($from) : null));
79+
}
80+
81+
/**
82+
* @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException
83+
*/
84+
public function testTransformRequiresValidDateTime()
85+
{
86+
$transformer = new DateTimeToRfc3339Transformer();
87+
$transformer->transform('2010-01-01');
88+
}
89+
90+
/**
91+
* @dataProvider reverseTransformProvider
92+
*/
93+
public function testReverseTransform($toTz, $fromTz, $to, $from)
94+
{
95+
$transformer = new DateTimeToRfc3339Transformer($toTz, $fromTz);
96+
97+
if (null !== $to) {
98+
$this->assertDateTimeEquals(new \DateTime($to), $transformer->reverseTransform($from));
99+
} else {
100+
$this->assertSame($to, $transformer->reverseTransform($from));
101+
}
102+
}
103+
104+
/**
105+
* @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException
106+
*/
107+
public function testReverseTransformRequiresString()
108+
{
109+
$transformer = new DateTimeToRfc3339Transformer();
110+
$transformer->reverseTransform(12345);
111+
}
112+
113+
/**
114+
* @expectedException Symfony\Component\Form\Exception\TransformationFailedException
115+
*/
116+
public function testReverseTransformWithNonExistingDate()
117+
{
118+
$transformer = new DateTimeToRfc3339Transformer('UTC', 'UTC');
119+
120+
var_dump($transformer->reverseTransform('2010-04-31T04:05Z'));
121+
}
122+
}

src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTimeTypeTest.php

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,12 @@ public function testSubmit_differentTimezonesDateTime()
165165

166166
$outputTime = new \DateTime('2010-06-02 03:04:00 Pacific/Tahiti');
167167

168-
$form->bind('2010-06-02T03:04:00');
168+
$form->bind('2010-06-02T03:04:00-10:00');
169169

170170
$outputTime->setTimezone(new \DateTimeZone('America/New_York'));
171171

172172
$this->assertDateTimeEquals($outputTime, $form->getData());
173-
$this->assertEquals('2010-06-02T03:04:00', $form->getViewData());
173+
$this->assertEquals('2010-06-02T03:04:00-10:00', $form->getViewData());
174174
}
175175

176176
public function testSubmit_stringSingleText()
@@ -182,10 +182,10 @@ public function testSubmit_stringSingleText()
182182
'widget' => 'single_text',
183183
));
184184

185-
$form->bind('2010-06-02T03:04:00');
185+
$form->bind('2010-06-02T03:04:00Z');
186186

187187
$this->assertEquals('2010-06-02 03:04:00', $form->getData());
188-
$this->assertEquals('2010-06-02T03:04:00', $form->getViewData());
188+
$this->assertEquals('2010-06-02T03:04:00Z', $form->getViewData());
189189
}
190190

191191
public function testSubmit_stringSingleText_withSeconds()
@@ -198,10 +198,10 @@ public function testSubmit_stringSingleText_withSeconds()
198198
'with_seconds' => true,
199199
));
200200

201-
$form->bind('2010-06-02T03:04:05');
201+
$form->bind('2010-06-02T03:04:05Z');
202202

203203
$this->assertEquals('2010-06-02 03:04:05', $form->getData());
204-
$this->assertEquals('2010-06-02T03:04:05', $form->getViewData());
204+
$this->assertEquals('2010-06-02T03:04:05Z', $form->getViewData());
205205
}
206206

207207
public function testSubmit_differentPattern()
@@ -355,4 +355,35 @@ public function testPassEmptyValueAsPartialArray_addNullIfRequired()
355355
$this->assertNull($view->get('time')->get('minute')->getVar('empty_value'));
356356
$this->assertSame('Empty second', $view->get('time')->get('second')->getVar('empty_value'));
357357
}
358+
359+
public function testPassHtml5TypeIfSingleTextAndHtml5Format()
360+
{
361+
$form = $this->factory->create('datetime', null, array(
362+
'widget' => 'single_text',
363+
));
364+
365+
$view = $form->createView();
366+
$this->assertSame('datetime', $view->getVar('type'));
367+
}
368+
369+
public function testDontPassHtml5TypeIfNotHtml5Format()
370+
{
371+
$form = $this->factory->create('datetime', null, array(
372+
'widget' => 'single_text',
373+
'format' => 'yyyy-MM-dd HH:mm',
374+
));
375+
376+
$view = $form->createView();
377+
$this->assertNull($view->getVar('datetime'));
378+
}
379+
380+
public function testDontPassHtml5TypeIfNotSingleText()
381+
{
382+
$form = $this->factory->create('datetime', null, array(
383+
'widget' => 'text',
384+
));
385+
386+
$view = $form->createView();
387+
$this->assertNull($view->getVar('type'));
388+
}
358389
}

src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTypeTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ public function testDontPassHtml5TypeIfNotHtml5Format()
677677
$this->assertNull($view->getVar('type'));
678678
}
679679

680-
public function testPassHtml5TypeIfNotSingleText()
680+
public function testDontPassHtml5TypeIfNotSingleText()
681681
{
682682
$form = $this->factory->create('date', null, array(
683683
'widget' => 'text',

0 commit comments

Comments
 (0)