From 7b3e3f281a0dbc8149bff95ad13ffd8fb75a8c87 Mon Sep 17 00:00:00 2001 From: Javier Eguiluz Date: Thu, 11 Jun 2026 21:29:59 +0200 Subject: [PATCH] Improve the Flag Twig component --- UPGRADE.md | 46 ++++--- doc/fields/CountryField.rst | 12 ++ src/Twig/Component/Flag.php | 30 +++-- templates/components/Flag.html.twig | 9 +- templates/crud/field/country.html.twig | 24 +--- .../Fields/Intl/CountryFieldTest.php | 1 + .../Twig/Component/FlagComponentTest.php | 121 ++++++++++++++++++ tests/Unit/Field/CountryFieldTest.php | 2 +- tests/Unit/Twig/Component/FlagTest.php | 68 ++++++++++ 9 files changed, 267 insertions(+), 46 deletions(-) create mode 100644 tests/Functional/Twig/Component/FlagComponentTest.php diff --git a/UPGRADE.md b/UPGRADE.md index 721c2fb4cb..a93689cb88 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,7 +1,17 @@ -UPGRADE FROM EASYADMIN 4.X to 5.X -================================= +Upgrade Guide +============= -## Pretty URLs +## EasyAdmin 5.0.14 + +The HTML markup of the `` component has changed slightly. There's a +new `` element that wraps the `` image +and the optional country name text. If your custom CSS or JavaScript selects +country flags with direct-child or sibling selectors (e.g. `td > svg.country-flag`), +update them; descendant selectors (e.g. `td svg.country-flag`) keep working as before. + +## Upgrading from Symfony 4.x to 5.x + +### Pretty URLs Using pretty URLs is now mandatory. They are created with a custom route loader that must be enabled in your application. If you use Symfony Flex, this file is @@ -14,7 +24,7 @@ easyadmin: type: easyadmin.routes ``` -## Admin Context +### Admin Context The global `ea` variable injected in all templates is removed in favor of the equivalent `ea()` Twig function, which returns the current context of the @@ -28,7 +38,7 @@ EasyAdmin application: {{ ea().i18n.translationDomain }} ``` -## Main Menus +### Main Menus The `linkToCrud()` method used to link to CRUD controllers from the main menu of the dashboard was removed in favor of the new `linkTo()` method: @@ -45,7 +55,7 @@ yield MenuItem::linkTo(BlogPostCrudController::class, 'Blog Posts', 'fa fa-file- yield MenuItem::linkTo(CommentCrudController::class); ``` -## Custom CRUD Actions +### Custom CRUD Actions Custom CRUD actions now require to apply the `#[AdminRoute]` attribute to them. Otherwise, they are ignored when generating routes for the backend and code @@ -115,7 +125,7 @@ class CommentCrudController extends AbstractCrudController } ``` -## Actions +### Actions Some methods related to actions have been removed in favor of equivalent methods with better names: @@ -132,7 +142,7 @@ $action->renderAsButton()->... $action->renderAsForm()->... ``` -## Referrers +### Referrers EasyAdmin URLs no longer include the `referrer` query parameter, and the `AdminContext:getReferrer()` method was removed. @@ -152,7 +162,7 @@ return $this->redirect($batchActionDto->getReferrer()); return $this->redirect($adminContext->getRequest()->headers->get('referer')); ``` -## Forms +### Forms Form panels are now called Form fieldsets and the `FormField::addPanel()` method was removed: @@ -165,16 +175,16 @@ yield FormField::addPanel('...'); yield FormField::addFieldset('...'); ``` -## Attributes +### Attributes The `#[AdminCrud]` and `#[AdminAction]` attributes have been removed in favor of the `#[AdminRoute]` attribute. -## Contracts +### Contracts The following contract interfaces changed: -### `Contracts\Context\AdminContextInterface` +#### `Contracts\Context\AdminContextInterface` ```php // Before (4.x) @@ -186,7 +196,7 @@ public function getAdminControllers(): AdminControllerRegistry; The `getSignedUrls()` and `getReferrer()` methods are removed. -### `Contracts\Controller\CrudControllerInterface` +#### `Contracts\Controller\CrudControllerInterface` ```php // Before (4.x) @@ -196,7 +206,7 @@ public function createEntity(string $entityFqcn); public function createEntity(string $entityFqcn): object; ``` -### `Contracts\Orm\EntityPaginatorInterface` +#### `Contracts\Orm\EntityPaginatorInterface` ```php // Before (4.x) @@ -206,7 +216,7 @@ public function getResultsAsJson(): string; public function getResultsAsJson(?callable $callback = null, ?string $twigTemplate = null, bool $renderAsHtml = false): string; ``` -### `Contracts\Provider\AdminContextInterface` +#### `Contracts\Provider\AdminContextInterface` ```php // Before (4.x) @@ -217,13 +227,13 @@ public function hasContext(): bool; // alternative: check if getContext() return value is null ``` -### `Contracts\Menu\MenuItemMatcherInterface` +#### `Contracts\Menu\MenuItemMatcherInterface` The `isSelected()` and `isExpanded()` methods were removed. A new `markSelectedMenuItem(array $menuItems, Request $request)` method has been added. -### `Contracts\Router\AdminRouteGeneratorInterface` +#### `Contracts\Router\AdminRouteGeneratorInterface` ```php // Before (4.x) @@ -235,7 +245,7 @@ public function findRouteName(string|null $dashboardFqcn = null, string|null $cr The `usesPrettyUrls()` method was removed. -## Static Analysis +### Static Analysis In 5.x, PHPStan will report an error if a class extends `AbstractCrudController` without specifying the entity type: diff --git a/doc/fields/CountryField.rst b/doc/fields/CountryField.rst index cd61f0ba05..7b3beeba44 100644 --- a/doc/fields/CountryField.rst +++ b/doc/fields/CountryField.rst @@ -32,6 +32,18 @@ Basic Information + {# use 'showName' to display the localized country name next to the flag #} + + + {# any other HTML attribute is applied to the element that wraps the flag #} + + + {# when the flag of the given country is not available, EasyAdmin renders + a generic red flag; use the 'fallback' block to customize it #} + + 🏳️ + + Options ------- diff --git a/src/Twig/Component/Flag.php b/src/Twig/Component/Flag.php index daed6692af..8a8edc74e6 100644 --- a/src/Twig/Component/Flag.php +++ b/src/Twig/Component/Flag.php @@ -9,9 +9,15 @@ class Flag { public string $countryCode; public int $height = 17; + public bool $showName = false; + public ?string $countryName = null; public function getCountryName(): string { + if (null !== $this->countryName) { + return $this->countryName; + } + try { return Countries::getName($this->countryCode); } catch (MissingResourceException) { @@ -19,25 +25,33 @@ public function getCountryName(): string } } + public function flagExists(): bool + { + // checking the country code first is essential to prevent path traversal attacks + return Countries::exists($this->countryCode) && is_file($this->getFlagFilePath()); + } + public function getFlagAsSvg(): string { - if (!Countries::exists($this->countryCode)) { + if (!$this->flagExists()) { return $this->getFallbackSvg(); } - $flagSvgFilePath = sprintf('%s/../../../assets/icons/flags/%s.svg', __DIR__, $this->countryCode); - $content = @file_get_contents($flagSvgFilePath); - if (false === $content) { - return $this->getFallbackSvg(); - } + $content = file_get_contents($this->getFlagFilePath()); + $escapedCountryName = htmlspecialchars($this->getCountryName(), \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); - return str_replace(['__HEIGHT__', '__COUNTRY_NAME__'], [(string) $this->height, $this->getCountryName()], $content); + return str_replace(['__HEIGHT__', '__COUNTRY_NAME__'], [(string) $this->height, $escapedCountryName], $content); } - private function getFallbackSvg(): string + public function getFallbackSvg(): string { $escapedCountryCode = htmlspecialchars($this->countryCode, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); return sprintf('You are not seeing a country flag here because the "%s.svg" file associated to given the "%s" country code does not exist in the assets/icons/flags/ directory of EasyAdmin.', $this->height, $escapedCountryCode, $escapedCountryCode, $this->height); } + + private function getFlagFilePath(): string + { + return sprintf('%s/../../../assets/icons/flags/%s.svg', __DIR__, $this->countryCode); + } } diff --git a/templates/components/Flag.html.twig b/templates/components/Flag.html.twig index 94c14970b9..324aaad5f6 100644 --- a/templates/components/Flag.html.twig +++ b/templates/components/Flag.html.twig @@ -1 +1,8 @@ -{{ this.getFlagAsSvg|raw }} + + {%- if this.flagExists -%} + {{- this.getFlagAsSvg|raw -}} + {%- else -%} + {%- block fallback %}{{ this.getFallbackSvg|raw }}{% endblock -%} + {%- endif -%} + {%- if this.showName %}{{ this.getCountryName() }}{% endif -%} + diff --git a/templates/crud/field/country.html.twig b/templates/crud/field/country.html.twig index 6eb3bb30c1..b68e7f1c88 100644 --- a/templates/crud/field/country.html.twig +++ b/templates/crud/field/country.html.twig @@ -6,26 +6,14 @@ {% set show_flag = field.customOptions.get('showFlag') %} {% set show_name = field.customOptions.get('showName') %} -{% if show_flag and not show_name %} - {% for flag_code, country_name in field.formattedValue %} - {% if flag_code is not null %} - - {% endif %} - {% endfor %} -{% elseif show_name and not show_flag %} +{% if show_name and not show_flag %} {{ field.formattedValue|join(', ') }} {% else %} {% for flag_code, country_name in field.formattedValue %} - - {%- if show_flag -%} - {%- if flag_code is not null -%} - - {%- endif -%} - {%- endif -%} - - {%- if show_name -%} - {{- country_name ?? '' -}} - {%- endif -%} - + {% if flag_code is not null %} + + {% elseif show_name %} + {{ country_name ?? '' }} + {% endif %} {% endfor %} {% endif %} diff --git a/tests/Functional/Fields/Intl/CountryFieldTest.php b/tests/Functional/Fields/Intl/CountryFieldTest.php index 46ab9695e7..a441bfe717 100644 --- a/tests/Functional/Fields/Intl/CountryFieldTest.php +++ b/tests/Functional/Fields/Intl/CountryFieldTest.php @@ -25,6 +25,7 @@ public function testCountryFieldDisplaysOnIndex(): void str_contains($cellText, 'United States') || str_contains($cellText, 'US') || str_contains($cellText, 'Estados Unidos'), sprintf('CountryField should display country name, got: %s', $cellText) ); + static::assertCount(1, $countryFieldCell->filter('span.country-flag-wrapper svg.country-flag'), 'CountryField should render the flag inside the wrapper of the ea:Flag component'); } public function testCountryFieldDisplaysOnDetail(): void diff --git a/tests/Functional/Twig/Component/FlagComponentTest.php b/tests/Functional/Twig/Component/FlagComponentTest.php new file mode 100644 index 0000000000..ee65a99330 --- /dev/null +++ b/tests/Functional/Twig/Component/FlagComponentTest.php @@ -0,0 +1,121 @@ +renderTwigComponent('ea:Flag', ['countryCode' => 'ES']); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('assertStringContainsString('class="country-flag"', $html); + $this->assertStringContainsString('height="17"', $html); + } + + public function testHtmlAttributesAreAppliedToTheWrapper(): void + { + $html = (string) $this->renderTwigComponent('ea:Flag', [ + 'countryCode' => 'ES', + 'class' => 'rounded shadow-sm', + 'title' => 'Shipping destination', + 'data-country' => 'ES', + ]); + + $this->assertStringContainsString('class="country-flag-wrapper rounded shadow-sm"', $html); + $this->assertStringContainsString('title="Shipping destination"', $html); + $this->assertStringContainsString('data-country="ES"', $html); + } + + public function testShowNameRendersTheCountryNameNextToTheFlag(): void + { + $html = (string) $this->renderTwigComponent('ea:Flag', [ + 'countryCode' => 'ES', + 'countryName' => 'Spain', + 'showName' => true, + ]); + + $this->assertStringContainsString('Spain', $html); + } + + /** + * @dataProvider provideLocalizedCountryNames + */ + public function testLocalizedCountryNamesAreRendered(string $countryCode, string $localizedName): void + { + $html = (string) $this->renderTwigComponent('ea:Flag', [ + 'countryCode' => $countryCode, + 'countryName' => $localizedName, + 'showName' => true, + ]); + + $this->assertStringContainsString(sprintf('%s', $localizedName), $html); + $this->assertStringContainsString(sprintf('%s', $localizedName), $html); + } + + public static function provideLocalizedCountryNames(): iterable + { + yield 'Spanish' => ['ES', 'España']; + yield 'Ukrainian' => ['UA', 'Україна']; + yield 'Ukrainian name of another country' => ['ES', 'Іспанія']; + } + + public function testNameIsNotRenderedByDefault(): void + { + $html = (string) $this->renderTwigComponent('ea:Flag', [ + 'countryCode' => 'ES', + 'countryName' => 'Spain', + ]); + + $this->assertStringContainsString('', $html); + } + + public function testUnknownCountryRendersTheDefaultFallback(): void + { + $html = (string) $this->renderTwigComponent('ea:Flag', ['countryCode' => 'ZZ']); + + $this->assertStringContainsString('class="country-flag-wrapper"', $html); + $this->assertStringContainsString('You are not seeing a country flag here', $html); + $this->assertStringContainsString('fill="#ff0000"', $html); + } + + public function testFallbackBlockReplacesTheDefaultFallback(): void + { + $html = (string) $this->renderTwigComponent( + 'ea:Flag', + ['countryCode' => 'ZZ'], + blocks: ['fallback' => '?'] + ); + + $this->assertStringContainsString('?', $html); + $this->assertStringNotContainsString('You are not seeing a country flag here', $html); + } + + public function testFallbackBlockIsIgnoredWhenTheFlagExists(): void + { + $html = (string) $this->renderTwigComponent( + 'ea:Flag', + ['countryCode' => 'ES'], + blocks: ['fallback' => '?'] + ); + + $this->assertStringContainsString('assertStringNotContainsString('unknown-flag', $html); + } +} diff --git a/tests/Unit/Field/CountryFieldTest.php b/tests/Unit/Field/CountryFieldTest.php index 215fd42e77..bb26465b6a 100644 --- a/tests/Unit/Field/CountryFieldTest.php +++ b/tests/Unit/Field/CountryFieldTest.php @@ -42,7 +42,7 @@ public function testDefaultOptionsForFormPages(): void $formSelectChoices = $fieldDto->getFormTypeOption(ChoiceField::OPTION_CHOICES); $equatorialGuineaChoiceEntryHtml = <<Equatorial Guinea\n Equatorial Guinea +
Equatorial Guinea\n Equatorial Guinea
HTML; self::assertCount(self::NUM_COUNTRIES_AND_REGIONS, $formSelectChoices); diff --git a/tests/Unit/Twig/Component/FlagTest.php b/tests/Unit/Twig/Component/FlagTest.php index 40c81577c5..6fd84c81af 100644 --- a/tests/Unit/Twig/Component/FlagTest.php +++ b/tests/Unit/Twig/Component/FlagTest.php @@ -86,4 +86,72 @@ public static function provideXssPayloads(): iterable '" onload="alert(1)', ]; } + + public function testCountryNamePropOverridesIntlName(): void + { + $flag = new Flag(); + $flag->countryCode = 'ES'; + $flag->countryName = 'Custom Name'; + + $this->assertSame('Custom Name', $flag->getCountryName()); + $this->assertStringContainsString('Custom Name', $flag->getFlagAsSvg()); + } + + public function testCountryNameIsLocalizedUsingTheDefaultLocale(): void + { + $previousLocale = \Locale::getDefault(); + + try { + $flag = new Flag(); + $flag->countryCode = 'ES'; + + \Locale::setDefault('es'); + $this->assertSame('España', $flag->getCountryName()); + $this->assertStringContainsString('España', $flag->getFlagAsSvg()); + + \Locale::setDefault('uk'); + $this->assertSame('Іспанія', $flag->getCountryName()); + $this->assertStringContainsString('Іспанія', $flag->getFlagAsSvg()); + } finally { + \Locale::setDefault($previousLocale); + } + } + + /** + * @dataProvider provideXssPayloads + */ + public function testXssPayloadInCountryNameIsEscapedInSvg(string $payload, string $mustNotAppear): void + { + $flag = new Flag(); + $flag->countryCode = 'ES'; + $flag->countryName = $payload; + + $this->assertStringNotContainsString($mustNotAppear, $flag->getFlagAsSvg()); + } + + public function testFlagExists(): void + { + $flag = new Flag(); + $flag->countryCode = 'ES'; + $this->assertTrue($flag->flagExists()); + + $flag = new Flag(); + $flag->countryCode = 'XX'; + $this->assertFalse($flag->flagExists()); + + $flag = new Flag(); + $flag->countryCode = '../../../etc/passwd'; + $this->assertFalse($flag->flagExists()); + } + + public function testFallbackSvgIsPubliclyAvailableAndEscaped(): void + { + $flag = new Flag(); + $flag->countryCode = '">'; + + $svg = $flag->getFallbackSvg(); + + $this->assertStringContainsString('You are not seeing a country flag here', $svg); + $this->assertStringNotContainsString('', $svg); + } }