diff --git a/src/Symfony/Component/Form/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformer.php b/src/Symfony/Component/Form/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformer.php new file mode 100644 index 0000000000..9212d24652 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformer.php @@ -0,0 +1,84 @@ + + * + * 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\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a timezone identifier string and a IntlTimeZone object. + * + * @author Roland Franssen + */ +class IntlTimeZoneToStringTransformer implements DataTransformerInterface +{ + private $multiple; + + public function __construct(bool $multiple = false) + { + $this->multiple = $multiple; + } + + /** + * {@inheritdoc} + */ + public function transform($intlTimeZone) + { + if (null === $intlTimeZone) { + return; + } + + if ($this->multiple) { + if (!\is_array($intlTimeZone)) { + throw new TransformationFailedException('Expected an array of \IntlTimeZone objects.'); + } + + return array_map([new self(), 'transform'], $intlTimeZone); + } + + if (!$intlTimeZone instanceof \IntlTimeZone) { + throw new TransformationFailedException('Expected a \IntlTimeZone object.'); + } + + return $intlTimeZone->getID(); + } + + /** + * {@inheritdoc} + */ + public function reverseTransform($value) + { + if (null === $value) { + return; + } + + if ($this->multiple) { + if (!\is_array($value)) { + throw new TransformationFailedException('Expected an array of timezone identifier strings.'); + } + + return array_map([new self(), 'reverseTransform'], $value); + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a timezone identifier string.'); + } + + $intlTimeZone = \IntlTimeZone::createTimeZone($value); + + if ('Etc/Unknown' === $intlTimeZone->getID()) { + throw new TransformationFailedException(sprintf('Unknown timezone identifier "%s".', $value)); + } + + return $intlTimeZone; + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php b/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php index d58019e9bc..02e319d0ec 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php @@ -13,7 +13,9 @@ namespace Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeZoneToStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\IntlTimeZoneToStringTransformer; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -27,6 +29,8 @@ class TimezoneType extends AbstractType { if ('datetimezone' === $options['input']) { $builder->addModelTransformer(new DateTimeZoneToStringTransformer($options['multiple'])); + } elseif ('intltimezone' === $options['input']) { + $builder->addModelTransformer(new IntlTimeZoneToStringTransformer($options['multiple'])); } } @@ -38,9 +42,10 @@ class TimezoneType extends AbstractType $resolver->setDefaults([ 'choice_loader' => function (Options $options) { $regions = $options->offsetGet('regions', false); + $input = $options['input']; - return new CallbackChoiceLoader(function () use ($regions) { - return self::getTimezones($regions); + return new CallbackChoiceLoader(function () use ($regions, $input) { + return self::getTimezones($regions, $input); }); }, 'choice_translation_domain' => false, @@ -48,7 +53,14 @@ class TimezoneType extends AbstractType 'regions' => \DateTimeZone::ALL, ]); - $resolver->setAllowedValues('input', ['string', 'datetimezone']); + $resolver->setAllowedValues('input', ['string', 'datetimezone', 'intltimezone']); + $resolver->setNormalizer('input', function (Options $options, $value) { + if ('intltimezone' === $value && !class_exists(\IntlTimeZone::class)) { + throw new LogicException('Cannot use "intltimezone" input because the PHP intl extension is not available.'); + } + + return $value; + }); $resolver->setAllowedTypes('regions', 'int'); $resolver->setDeprecated('regions', 'The option "%name%" is deprecated since Symfony 4.2.'); @@ -73,11 +85,15 @@ class TimezoneType extends AbstractType /** * Returns a normalized array of timezone choices. */ - private static function getTimezones(int $regions): array + private static function getTimezones(int $regions, string $input): array { $timezones = []; foreach (\DateTimeZone::listIdentifiers($regions) as $timezone) { + if ('intltimezone' === $input && 'Etc/Unknown' === \IntlTimeZone::createTimeZone($timezone)->getID()) { + continue; + } + $parts = explode('/', $timezone); if (\count($parts) > 2) { diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformerTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformerTest.php new file mode 100644 index 0000000000..376c2ccaee --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformerTest.php @@ -0,0 +1,59 @@ + + * + * 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 PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Extension\Core\DataTransformer\IntlTimeZoneToStringTransformer; + +/** + * @requires extension intl + */ +class IntlTimeZoneToStringTransformerTest extends TestCase +{ + public function testSingle() + { + $transformer = new IntlTimeZoneToStringTransformer(); + + $this->assertNull($transformer->transform(null)); + $this->assertNull($transformer->reverseTransform(null)); + + $this->assertSame('Europe/Amsterdam', $transformer->transform(\IntlTimeZone::createTimeZone('Europe/Amsterdam'))); + $this->assertEquals(\IntlTimeZone::createTimeZone('Europe/Amsterdam'), $transformer->reverseTransform('Europe/Amsterdam')); + } + + public function testMultiple() + { + $transformer = new IntlTimeZoneToStringTransformer(true); + + $this->assertNull($transformer->transform(null)); + $this->assertNull($transformer->reverseTransform(null)); + + $this->assertSame(['Europe/Amsterdam'], $transformer->transform([\IntlTimeZone::createTimeZone('Europe/Amsterdam')])); + $this->assertEquals([\IntlTimeZone::createTimeZone('Europe/Amsterdam')], $transformer->reverseTransform(['Europe/Amsterdam'])); + } + + /** + * @expectedException \Symfony\Component\Form\Exception\TransformationFailedException + */ + public function testInvalidTimezone() + { + (new IntlTimeZoneToStringTransformer())->transform(1); + } + + /** + * @expectedException \Symfony\Component\Form\Exception\TransformationFailedException + */ + public function testUnknownTimezone() + { + (new IntlTimeZoneToStringTransformer(true))->reverseTransform(['Foo/Bar']); + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/TimezoneTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/TimezoneTypeTest.php index 621bc2b055..cd64577908 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/Type/TimezoneTypeTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/TimezoneTypeTest.php @@ -65,6 +65,15 @@ class TimezoneTypeTest extends BaseTypeTest $this->assertEquals([new \DateTimeZone('Europe/Amsterdam'), new \DateTimeZone('Europe/Paris')], $form->getData()); } + public function testDateTimeZoneInputWithBc() + { + $form = $this->factory->create(static::TESTED_TYPE, null, ['input' => 'datetimezone']); + $form->submit('Europe/Saratov'); + + $this->assertEquals(new \DateTimeZone('Europe/Saratov'), $form->getData()); + $this->assertContains('Europe/Saratov', $form->getConfig()->getAttribute('choice_list')->getValues()); + } + /** * @group legacy * @expectedDeprecation The option "regions" is deprecated since Symfony 4.2. @@ -76,4 +85,38 @@ class TimezoneTypeTest extends BaseTypeTest $this->assertContains(new ChoiceView('Europe/Amsterdam', 'Europe/Amsterdam', 'Amsterdam'), $choices, '', false, false); } + + /** + * @requires extension intl + */ + public function testIntlTimeZoneInput() + { + $form = $this->factory->create(static::TESTED_TYPE, \IntlTimeZone::createTimeZone('America/New_York'), ['input' => 'intltimezone']); + + $this->assertSame('America/New_York', $form->createView()->vars['value']); + + $form->submit('Europe/Amsterdam'); + + $this->assertEquals(\IntlTimeZone::createTimeZone('Europe/Amsterdam'), $form->getData()); + + $form = $this->factory->create(static::TESTED_TYPE, [\IntlTimeZone::createTimeZone('America/New_York')], ['input' => 'intltimezone', 'multiple' => true]); + + $this->assertSame(['America/New_York'], $form->createView()->vars['value']); + + $form->submit(['Europe/Amsterdam', 'Europe/Paris']); + + $this->assertEquals([\IntlTimeZone::createTimeZone('Europe/Amsterdam'), \IntlTimeZone::createTimeZone('Europe/Paris')], $form->getData()); + } + + /** + * @requires extension intl + */ + public function testIntlTimeZoneInputWithBc() + { + $form = $this->factory->create(static::TESTED_TYPE, null, ['input' => 'intltimezone']); + $form->submit('Europe/Saratov'); + + $this->assertNull($form->getData()); + $this->assertNotContains('Europe/Saratov', $form->getConfig()->getAttribute('choice_list')->getValues()); + } }