Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions UPGRADE-7.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ DomCrawler

* Disabling HTML5 parsing is deprecated; Symfony 8 will unconditionally use the native HTML5 parser

Form
----

* [BC BREAK] The `CurrencyType` returns only the currencies that are active and recognized as [legal tender](https://en.wikipedia.org/wiki/Legal_tender) for the current date; set the `active_at`, and `legal_tender` options to `null` to list all currencies no matter their current state

FrameworkBundle
---------------

Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Form/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CHANGELOG

* Add `input=date_point` to `DateTimeType`, `DateType` and `TimeType`
* Add support for guessing form type of enum properties
* Add `active_at`, `not_active_at` and `legal_tender` options to `CurrencyType`

7.3
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\Component\Form\Exception\LogicException;
use Symfony\Component\Intl\Currencies;
use Symfony\Component\Intl\Intl;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

Expand All @@ -31,15 +32,62 @@ public function configureOptions(OptionsResolver $resolver): void
}

$choiceTranslationLocale = $options['choice_translation_locale'];
$activeAt = $options['active_at'];
$notActiveAt = $options['not_active_at'];
$legalTender = $options['legal_tender'];

return ChoiceList::loader($this, new IntlCallbackChoiceLoader(static fn () => array_flip(Currencies::getNames($choiceTranslationLocale))), $choiceTranslationLocale);
if (null !== $activeAt && null !== $notActiveAt) {
throw new InvalidOptionsException('The "active_at" and "not_active_at" options cannot be used together.');
}

$legalTenderCacheKey = match ($legalTender) {
null => '',
true => '1',
false => '0',
};

return ChoiceList::loader(
$this,
new IntlCallbackChoiceLoader(
static function () use ($choiceTranslationLocale, $activeAt, $notActiveAt, $legalTender) {
if (null === $activeAt && null === $notActiveAt && null === $legalTender) {
return array_flip(Currencies::getNames($choiceTranslationLocale));
}

$filteredCurrencyNames = [];

$active = match (true) {
null !== $activeAt => true,
null !== $notActiveAt => false,
default => null,
};

foreach (Currencies::getCurrencyCodes() as $code) {
if (!Currencies::isValidInAnyCountry($code, $legalTender, $active, $activeAt ?? $notActiveAt)) {
continue;
}

$filteredCurrencyNames[$code] = Currencies::getName($code, $choiceTranslationLocale);
}

return array_flip($filteredCurrencyNames);
},
),
$choiceTranslationLocale.($activeAt ?? $notActiveAt)?->format('Y-m-d\TH:i:s').$legalTenderCacheKey,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Some timezones use unusual offsets, with the smallest official step being 15 minutes. I used the seconds as the smallest units but we could use only minutes if we wanted to.

);
},
'choice_translation_domain' => false,
'choice_translation_locale' => null,
'active_at' => new \DateTimeImmutable('today', new \DateTimeZone('Etc/UTC')),
'not_active_at' => null,
'legal_tender' => true,
'invalid_message' => 'Please select a valid currency.',
]);

$resolver->setAllowedTypes('choice_translation_locale', ['null', 'string']);
$resolver->setAllowedTypes('active_at', [\DateTimeInterface::class, 'null']);
$resolver->setAllowedTypes('not_active_at', [\DateTimeInterface::class, 'null']);
$resolver->setAllowedTypes('legal_tender', ['bool', 'null']);
}

public function getParent(): ?string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Intl\Util\IntlTestHelper;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;

class CurrencyTypeTest extends BaseTypeTestCase
{
Expand All @@ -34,7 +35,6 @@ public function testCurrenciesAreSelectable()

$this->assertContainsEquals(new ChoiceView('EUR', 'EUR', 'Euro'), $choices);
$this->assertContainsEquals(new ChoiceView('USD', 'USD', 'US Dollar'), $choices);
$this->assertContainsEquals(new ChoiceView('SIT', 'SIT', 'Slovenian Tolar'), $choices);
}

#[RequiresPhpExtension('intl')]
Expand All @@ -49,7 +49,6 @@ public function testChoiceTranslationLocaleOption()
// Don't check objects for identity
$this->assertContainsEquals(new ChoiceView('EUR', 'EUR', 'євро'), $choices);
$this->assertContainsEquals(new ChoiceView('USD', 'USD', 'долар США'), $choices);
$this->assertContainsEquals(new ChoiceView('SIT', 'SIT', 'словенський толар'), $choices);
}

public function testSubmitNull($expected = null, $norm = null, $view = null)
Expand All @@ -61,4 +60,63 @@ public function testSubmitNullUsesDefaultEmptyData($emptyData = 'EUR', $expected
{
parent::testSubmitNullUsesDefaultEmptyData($emptyData, $expectedData);
}

#[RequiresPhpExtension('intl')]
public function testAnActiveAndLegalTenderCurrencyIn2006()
{
$choices = $this->factory
->create(static::TESTED_TYPE, null, [
'choice_translation_locale' => 'fr',
'active_at' => new \DateTimeImmutable('2006-01-01', new \DateTimeZone('Etc/UTC')),
'legal_tender' => true,
])
->createView()->vars['choices'];

$this->assertContainsEquals(new ChoiceView('SIT', 'SIT', 'tolar slovène'), $choices);
}

#[RequiresPhpExtension('intl')]
public function testAnExpiredCurrencyIn2007()
{
$choices = $this->factory
->create(static::TESTED_TYPE, null, [
'choice_translation_locale' => 'fr',
'legal_tender' => true,
// The SIT currency expired on 2007-01-14.
'active_at' => new \DateTimeImmutable('2007-01-15', new \DateTimeZone('Etc/UTC')),
])
->createView()->vars['choices'];

$this->assertNotContainsEquals(new ChoiceView('SIT', 'SIT', 'tolar slovène'), $choices);
}

#[RequiresPhpExtension('intl')]
public function testRetrieveExpiredCurrenciesIn2007()
{
$choices = $this->factory
->create(static::TESTED_TYPE, null, [
'choice_translation_locale' => 'fr',
'legal_tender' => true,
'active_at' => null,
// The SIT currency expired on 2007-01-14.
'not_active_at' => new \DateTimeImmutable('2007-01-15', new \DateTimeZone('Etc/UTC')),
])
->createView()->vars['choices'];

$this->assertContainsEquals(new ChoiceView('SIT', 'SIT', 'tolar slovène'), $choices);
}

public function testAnExceptionShouldBeThrownWhenTheActiveAtAndNotActiveAtOptionsAreBothSet()
{
$this->expectException(InvalidOptionsException::class);

$this->expectExceptionMessage('The "active_at" and "not_active_at" options cannot be used together.');

$this->factory
->create(static::TESTED_TYPE, null, [
'active_at' => new \DateTimeImmutable(),
'not_active_at' => new \DateTimeImmutable(),
])
->createView();
}
}
4 changes: 2 additions & 2 deletions src/Symfony/Component/Intl/Currencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,10 @@ private static function isDateActive(string $country, string $currency, array $c
throw new \RuntimeException("Cannot check whether the currency $currency is active or not in $country because they are no validity dates available.");
}

$from = \DateTimeImmutable::createFromFormat('Y-m-d', $currencyMetadata['from']);
$from = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s', $currencyMetadata['from'], new \DateTimeZone('Etc/UTC'));

if (\array_key_exists('to', $currencyMetadata)) {
$to = \DateTimeImmutable::createFromFormat('Y-m-d', $currencyMetadata['to']);
$to = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s', $currencyMetadata['to'], new \DateTimeZone('Etc/UTC'));
} else {
$to = null;
}
Expand Down
Loading