Skip to content
Open
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
46 changes: 28 additions & 18 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -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 `<twig:ea:Flag>` component has changed slightly. There's a
new `<span class="country-flag-wrapper">` element that wraps the `<svg>` 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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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<MenuItemDto> $menuItems, Request $request)` method
has been added.

### `Contracts\Router\AdminRouteGeneratorInterface`
#### `Contracts\Router\AdminRouteGeneratorInterface`

```php
// Before (4.x)
Expand All @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions doc/fields/CountryField.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ Basic Information

<twig:ea:Flag countryCode="CR" height="18"/>

{# use 'showName' to display the localized country name next to the flag #}
<twig:ea:Flag countryCode="CR" showName/>

{# any other HTML attribute is applied to the element that wraps the flag #}
<twig:ea:Flag countryCode="CR" class="rounded shadow-sm" title="Shipping destination"/>

{# when the flag of the given country is not available, EasyAdmin renders
a generic red flag; use the 'fallback' block to customize it #}
<twig:ea:Flag countryCode="{{ some_var.country }}">
<twig:block name="fallback">🏳️</twig:block>
</twig:ea:Flag>

Options
-------

Expand Down
30 changes: 22 additions & 8 deletions src/Twig/Component/Flag.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,49 @@ 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) {
return '';
}
}

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('<svg xmlns="http://www.w3.org/2000/svg" class="country-flag" height="%d" viewBox="0 0 25 17"><title>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.</title><rect width="100%%" height="%d" fill="#ff0000"/></svg>', $this->height, $escapedCountryCode, $escapedCountryCode, $this->height);
}

private function getFlagFilePath(): string
{
return sprintf('%s/../../../assets/icons/flags/%s.svg', __DIR__, $this->countryCode);
}
}
9 changes: 8 additions & 1 deletion templates/components/Flag.html.twig
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
{{ this.getFlagAsSvg|raw }}
<span {{- attributes.defaults({class: 'country-flag-wrapper'}) }}>
{%- if this.flagExists -%}
{{- this.getFlagAsSvg|raw -}}
{%- else -%}
{%- block fallback %}{{ this.getFallbackSvg|raw }}{% endblock -%}
{%- endif -%}
{%- if this.showName %}{{ this.getCountryName() }}{% endif -%}
</span>
24 changes: 6 additions & 18 deletions templates/crud/field/country.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<twig:ea:Flag countryCode="{{ flag_code }}" height="17" />
{% 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 %}
<span>
{%- if show_flag -%}
{%- if flag_code is not null -%}
<twig:ea:Flag countryCode="{{ flag_code }}" height="17" />
{%- endif -%}
{%- endif -%}

{%- if show_name -%}
{{- country_name ?? '' -}}
{%- endif -%}
</span>
{% if flag_code is not null %}
<twig:ea:Flag countryCode="{{ flag_code }}" countryName="{{ country_name }}" :showName="show_name" />
{% elseif show_name %}
<span>{{ country_name ?? '' }}</span>
{% endif %}
{% endfor %}
{% endif %}
1 change: 1 addition & 0 deletions tests/Functional/Fields/Intl/CountryFieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions tests/Functional/Twig/Component/FlagComponentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Twig\Component;

use EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\DefaultApp\Kernel;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents;

/**
* Renders the ea:Flag component through the real Twig + TwigComponent stack
* (no mocks) to test the behavior defined in its template.
*/
class FlagComponentTest extends KernelTestCase
{
use InteractsWithTwigComponents;

protected static function getKernelClass(): string
{
return Kernel::class;
}

public function testRendersFlagSvgInsideWrapper(): void
{
$html = (string) $this->renderTwigComponent('ea:Flag', ['countryCode' => 'ES']);

$this->assertStringContainsString('<span class="country-flag-wrapper">', $html);
$this->assertStringContainsString('<svg', $html);
$this->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('</svg>Spain</span>', $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('<title>%s</title>', $localizedName), $html);
$this->assertStringContainsString(sprintf('</svg>%s</span>', $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('</svg></span>', $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' => '<span class="unknown-flag">?</span>']
);

$this->assertStringContainsString('<span class="unknown-flag">?</span>', $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' => '<span class="unknown-flag">?</span>']
);

$this->assertStringContainsString('<svg', $html);
$this->assertStringNotContainsString('unknown-flag', $html);
}
}
Loading
Loading