diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index a19b52f8f937..dc6664600556 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -149,9 +149,8 @@ CHANGELOG * fixed: the "data" option supersedes default values from the model * changed DateType to refer to the "format" option for calculating the year and day choices instead of padding them automatically - * [BC BREAK] DateType defaults to the format "yyyy-MM-dd" now in order to support - the HTML 5 date field out of the box + * [BC BREAK] DateType defaults to the format "yyyy-MM-dd" now if the widget is + "single_text", in order to support the HTML 5 date field out of the box * added the option "format" to DateTimeType - * [BC BREAK] DateTimeType defaults to the format "yyyy-MM-dd'T'HH:mm:ss" now. This - is almost identical to the pattern of the HTML 5 datetime input, but not quite, - because ICU cannot generate RFC 3339 dates (which have a timezone suffix). + * [BC BREAK] DateTimeType now outputs RFC 3339 dates by default, as generated and + consumed by HTML5 browsers, if the widget is "single_text" diff --git a/src/Symfony/Component/Form/Extension/Core/DataTransformer/DateTimeToRfc3339Transformer.php b/src/Symfony/Component/Form/Extension/Core/DataTransformer/DateTimeToRfc3339Transformer.php new file mode 100644 index 000000000000..406275991362 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/DataTransformer/DateTimeToRfc3339Transformer.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * @author Bernhard Schussek + */ +class DateTimeToRfc3339Transformer extends BaseDateTimeTransformer +{ + /** + * {@inheritDoc} + */ + public function transform($dateTime) + { + if (null === $dateTime) { + return ''; + } + + if (!$dateTime instanceof \DateTime) { + throw new UnexpectedTypeException($dateTime, '\DateTime'); + } + + if ($this->inputTimezone !== $this->outputTimezone) { + $dateTime = clone $dateTime; + $dateTime->setTimezone(new \DateTimeZone($this->outputTimezone)); + } + + return preg_replace('/\+00:00$/', 'Z', $dateTime->format('c')); + } + + /** + * {@inheritDoc} + */ + public function reverseTransform($rfc3339) + { + if (!is_string($rfc3339)) { + throw new UnexpectedTypeException($rfc3339, 'string'); + } + + if ('' === $rfc3339) { + return null; + } + + + $dateTime = new \DateTime($rfc3339); + + if ($this->outputTimezone !== $this->inputTimezone) { + try { + $dateTime->setTimezone(new \DateTimeZone($this->inputTimezone)); + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + } + + if (preg_match('/(\d{4})-(\d{2})-(\d{2})/', $rfc3339, $matches)) { + if (!checkdate($matches[2], $matches[3], $matches[1])) { + throw new TransformationFailedException(sprintf( + 'The date "%s-%s-%s" is not a valid date.', + $matches[1], + $matches[2], + $matches[3] + )); + } + } + + return $dateTime; + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php b/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php index 233c2294d744..eff633e09f72 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php @@ -22,6 +22,7 @@ use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer; use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToLocalizedStringTransformer; use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToRfc3339Transformer; use Symfony\Component\Form\Extension\Core\DataTransformer\ArrayToPartsTransformer; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolverInterface; @@ -44,8 +45,17 @@ class DateTimeType extends AbstractType * http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax * http://www.w3.org/TR/html-markup/input.datetime.html * http://tools.ietf.org/html/rfc3339 + * + * An ICU ticket was created: + * http://icu-project.org/trac/ticket/9421 + * + * To temporarily circumvent this issue, DateTimeToRfc3339Transformer is used + * when the format matches this constant. + * + * ("ZZZZZZ" is not recognized by ICU and used here to differentiate this + * pattern from custom patterns). */ - const HTML5_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; + const HTML5_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZZZZZZ"; private static $acceptedFormats = array( \IntlDateFormatter::FULL, @@ -78,18 +88,25 @@ public function buildForm(FormBuilderInterface $builder, array $options) } if (null !== $pattern && (false === strpos($pattern, 'y') || false === strpos($pattern, 'M') || false === strpos($pattern, 'd') || false === strpos($pattern, 'H') || false === strpos($pattern, 'm'))) { - throw new InvalidOptionsException(sprintf('The "format" option should contain the patterns "y", "M", "d", "H" and "m". Its current value is "%s".', $pattern)); + throw new InvalidOptionsException(sprintf('The "format" option should contain the letters "y", "M", "d", "H" and "m". Its current value is "%s".', $pattern)); } if ('single_text' === $options['widget']) { - $builder->addViewTransformer(new DateTimeToLocalizedStringTransformer( - $options['data_timezone'], - $options['user_timezone'], - $dateFormat, - $timeFormat, - $calendar, - $pattern - )); + if (self::HTML5_FORMAT === $pattern) { + $builder->addViewTransformer(new DateTimeToRfc3339Transformer( + $options['data_timezone'], + $options['user_timezone'] + )); + } else { + $builder->addViewTransformer(new DateTimeToLocalizedStringTransformer( + $options['data_timezone'], + $options['user_timezone'], + $dateFormat, + $timeFormat, + $calendar, + $pattern + )); + } } else { // Only pass a subset of the options to children $dateOptions = array_intersect_key($options, array_flip(array( diff --git a/src/Symfony/Component/Form/Extension/Core/Type/DateType.php b/src/Symfony/Component/Form/Extension/Core/Type/DateType.php index 4d86fdac4b57..d3d3b091362a 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/DateType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/DateType.php @@ -53,7 +53,7 @@ public function buildForm(FormBuilderInterface $builder, array $options) } if (null !== $pattern && (false === strpos($pattern, 'y') || false === strpos($pattern, 'M') || false === strpos($pattern, 'd'))) { - throw new InvalidOptionsException(sprintf('The "format" option should contain the patterns "y", "M" and "d". Its current value is "%s".', $pattern)); + throw new InvalidOptionsException(sprintf('The "format" option should contain the letters "y", "M" and "d". Its current value is "%s".', $pattern)); } if ('single_text' === $options['widget']) { diff --git a/src/Symfony/Component/Form/Tests/AbstractLayoutTest.php b/src/Symfony/Component/Form/Tests/AbstractLayoutTest.php index c9cc5e9135c4..549af044abbe 100644 --- a/src/Symfony/Component/Form/Tests/AbstractLayoutTest.php +++ b/src/Symfony/Component/Form/Tests/AbstractLayoutTest.php @@ -970,7 +970,7 @@ public function testDateTimeWithWidgetSingleText() '/input [@type="datetime"] [@name="name"] - [@value="2011-02-03T04:05:06"] + [@value="2011-02-03T04:05:06+01:00"] ' ); } @@ -988,7 +988,7 @@ public function testDateTimeWithWidgetSingleTextIgnoreDateAndTimeWidgets() '/input [@type="datetime"] [@name="name"] - [@value="2011-02-03T04:05:06"] + [@value="2011-02-03T04:05:06+01:00"] ' ); } diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/DateTimeToRfc3339TransformerTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/DateTimeToRfc3339TransformerTest.php new file mode 100644 index 000000000000..40195cb517d3 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/DateTimeToRfc3339TransformerTest.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToRfc3339Transformer; + +class DateTimeToRfc3339TransformerTest extends DateTimeTestCase +{ + protected $dateTime; + protected $dateTimeWithoutSeconds; + + protected function setUp() + { + parent::setUp(); + + $this->dateTime = new \DateTime('2010-02-03 04:05:06 UTC'); + $this->dateTimeWithoutSeconds = new \DateTime('2010-02-03 04:05:00 UTC'); + } + + protected function tearDown() + { + $this->dateTime = null; + $this->dateTimeWithoutSeconds = null; + } + + public static function assertEquals($expected, $actual, $message = '', $delta = 0, $maxDepth = 10, $canonicalize = FALSE, $ignoreCase = FALSE) + { + if ($expected instanceof \DateTime && $actual instanceof \DateTime) { + $expected = $expected->format('c'); + $actual = $actual->format('c'); + } + + parent::assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase); + } + + public function allProvider() + { + return array( + array('UTC', 'UTC', '2010-02-03 04:05:06 UTC', '2010-02-03T04:05:06Z'), + array('UTC', 'UTC', null, ''), + array('America/New_York', 'Asia/Hong_Kong', '2010-02-03 04:05:06 America/New_York', '2010-02-03T17:05:06+08:00'), + array('America/New_York', 'Asia/Hong_Kong', null, ''), + array('UTC', 'Asia/Hong_Kong', '2010-02-03 04:05:06 UTC', '2010-02-03T12:05:06+08:00'), + array('America/New_York', 'UTC', '2010-02-03 04:05:06 America/New_York', '2010-02-03T09:05:06Z'), + ); + } + + public function transformProvider() + { + return $this->allProvider(); + } + + public function reverseTransformProvider() + { + return array_merge($this->allProvider(), array( + // format without seconds, as appears in some browsers + array('UTC', 'UTC', '2010-02-03 04:05:00 UTC', '2010-02-03T04:05Z'), + array('America/New_York', 'Asia/Hong_Kong', '2010-02-03 04:05:00 America/New_York', '2010-02-03T17:05+08:00'), + )); + } + + /** + * @dataProvider transformProvider + */ + public function testTransform($fromTz, $toTz, $from, $to) + { + $transformer = new DateTimeToRfc3339Transformer($fromTz, $toTz); + + $this->assertSame($to, $transformer->transform(null !== $from ? new \DateTime($from) : null)); + } + + /** + * @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException + */ + public function testTransformRequiresValidDateTime() + { + $transformer = new DateTimeToRfc3339Transformer(); + $transformer->transform('2010-01-01'); + } + + /** + * @dataProvider reverseTransformProvider + */ + public function testReverseTransform($toTz, $fromTz, $to, $from) + { + $transformer = new DateTimeToRfc3339Transformer($toTz, $fromTz); + + if (null !== $to) { + $this->assertDateTimeEquals(new \DateTime($to), $transformer->reverseTransform($from)); + } else { + $this->assertSame($to, $transformer->reverseTransform($from)); + } + } + + /** + * @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException + */ + public function testReverseTransformRequiresString() + { + $transformer = new DateTimeToRfc3339Transformer(); + $transformer->reverseTransform(12345); + } + + /** + * @expectedException Symfony\Component\Form\Exception\TransformationFailedException + */ + public function testReverseTransformWithNonExistingDate() + { + $transformer = new DateTimeToRfc3339Transformer('UTC', 'UTC'); + + var_dump($transformer->reverseTransform('2010-04-31T04:05Z')); + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTimeTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTimeTypeTest.php index e83d187fb7dc..ef31d7293595 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTimeTypeTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTimeTypeTest.php @@ -165,12 +165,12 @@ public function testSubmit_differentTimezonesDateTime() $outputTime = new \DateTime('2010-06-02 03:04:00 Pacific/Tahiti'); - $form->bind('2010-06-02T03:04:00'); + $form->bind('2010-06-02T03:04:00-10:00'); $outputTime->setTimezone(new \DateTimeZone('America/New_York')); $this->assertDateTimeEquals($outputTime, $form->getData()); - $this->assertEquals('2010-06-02T03:04:00', $form->getViewData()); + $this->assertEquals('2010-06-02T03:04:00-10:00', $form->getViewData()); } public function testSubmit_stringSingleText() @@ -182,10 +182,10 @@ public function testSubmit_stringSingleText() 'widget' => 'single_text', )); - $form->bind('2010-06-02T03:04:00'); + $form->bind('2010-06-02T03:04:00Z'); $this->assertEquals('2010-06-02 03:04:00', $form->getData()); - $this->assertEquals('2010-06-02T03:04:00', $form->getViewData()); + $this->assertEquals('2010-06-02T03:04:00Z', $form->getViewData()); } public function testSubmit_stringSingleText_withSeconds() @@ -198,10 +198,10 @@ public function testSubmit_stringSingleText_withSeconds() 'with_seconds' => true, )); - $form->bind('2010-06-02T03:04:05'); + $form->bind('2010-06-02T03:04:05Z'); $this->assertEquals('2010-06-02 03:04:05', $form->getData()); - $this->assertEquals('2010-06-02T03:04:05', $form->getViewData()); + $this->assertEquals('2010-06-02T03:04:05Z', $form->getViewData()); } public function testSubmit_differentPattern() @@ -355,4 +355,35 @@ public function testPassEmptyValueAsPartialArray_addNullIfRequired() $this->assertNull($view->get('time')->get('minute')->getVar('empty_value')); $this->assertSame('Empty second', $view->get('time')->get('second')->getVar('empty_value')); } + + public function testPassHtml5TypeIfSingleTextAndHtml5Format() + { + $form = $this->factory->create('datetime', null, array( + 'widget' => 'single_text', + )); + + $view = $form->createView(); + $this->assertSame('datetime', $view->getVar('type')); + } + + public function testDontPassHtml5TypeIfNotHtml5Format() + { + $form = $this->factory->create('datetime', null, array( + 'widget' => 'single_text', + 'format' => 'yyyy-MM-dd HH:mm', + )); + + $view = $form->createView(); + $this->assertNull($view->getVar('datetime')); + } + + public function testDontPassHtml5TypeIfNotSingleText() + { + $form = $this->factory->create('datetime', null, array( + 'widget' => 'text', + )); + + $view = $form->createView(); + $this->assertNull($view->getVar('type')); + } } diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTypeTest.php index f1f117ce26dd..ca7218a9a8ae 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTypeTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/DateTypeTest.php @@ -677,7 +677,7 @@ public function testDontPassHtml5TypeIfNotHtml5Format() $this->assertNull($view->getVar('type')); } - public function testPassHtml5TypeIfNotSingleText() + public function testDontPassHtml5TypeIfNotSingleText() { $form = $this->factory->create('date', null, array( 'widget' => 'text',