From 61aad4e796f95396d97e59b231e7f2a7a9d4c26b Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 11 May 2026 08:42:50 +0200 Subject: [PATCH] feat: implement action icon rendering options --- .idea/phpunit.xml | 10 + .idea/symfony2.xml | 6 + docs/actions-and-cells.md | 40 ++++ docs/architecture.md | 21 +- src/Definition/ActionDefinition.php | 8 + src/Definition/DatatableDefinition.php | 5 + src/Enum/ActionIconPosition.php | 20 ++ src/Renderer/DatatableRenderer.php | 3 +- templates/bootstrap/_action.html.twig | 24 ++- tests/Unit/Enum/ActionIconPositionTest.php | 25 +++ .../DatatableRendererActionIconTest.php | 179 ++++++++++++++++++ 11 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 .idea/phpunit.xml create mode 100644 .idea/symfony2.xml create mode 100644 src/Enum/ActionIconPosition.php create mode 100644 tests/Unit/Enum/ActionIconPositionTest.php create mode 100644 tests/Unit/Renderer/DatatableRendererActionIconTest.php diff --git a/.idea/phpunit.xml b/.idea/phpunit.xml new file mode 100644 index 0000000..4f8104c --- /dev/null +++ b/.idea/phpunit.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/symfony2.xml b/.idea/symfony2.xml new file mode 100644 index 0000000..bd98e40 --- /dev/null +++ b/.idea/symfony2.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/docs/actions-and-cells.md b/docs/actions-and-cells.md index 054aafa..8efe3b0 100644 --- a/docs/actions-and-cells.md +++ b/docs/actions-and-cells.md @@ -223,6 +223,46 @@ The host application must load the icon CSS if it wants icons to appear. More details are available in [`icons.md`](icons.md). +## Action icon rendering + +Actions can define an optional icon CSS class: + +```php +$definition->addRowAction( + name: 'view', + route: 'app_user_show', + label: 'View', + icon: 'bi bi-eye', + routeParameters: [ + 'id' => 'e.id', + ], +); +``` + +Icons render before the label by default. + +To render the icon after the label: + +```php +use Zhortein\DatatableBundle\Enum\ActionIconPosition; + +$definition->addRowAction( + name: 'view', + route: 'app_user_show', + label: 'View', + icon: 'bi bi-arrow-right', + iconPosition: ActionIconPosition::After, + routeParameters: [ + 'id' => 'e.id', + ], +); +``` + +The bundle does not require a specific icon library. + +The host application must load the relevant icon CSS. + + ## Typed cell templates The renderer supports type-specific cell templates. diff --git a/docs/architecture.md b/docs/architecture.md index 09c495c..2e8268b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1111,7 +1111,26 @@ The bundle does not require Bootstrap Icons, FontAwesome, Symfony UX Icons or an Action icons render as decorative spans with `aria-hidden="true"`, while the action label remains visible and accessible. -The strategy is documented in `docs/icons.md`. +The strategy is documented in [`icons.md`](icons.md). + +### Action icon rendering options + +Actions can render optional CSS-class based icons. + +The current strategy remains dependency-free: + +- no mandatory Bootstrap Icons dependency; +- no mandatory FontAwesome dependency; +- no SVG icon provider yet. + +Action icons render as decorative spans with `aria-hidden="true"`. + +Supported icon positions: + +- before label; +- after label. + +Labels remain visible to preserve accessibility. ### Cell template reference diff --git a/src/Definition/ActionDefinition.php b/src/Definition/ActionDefinition.php index edc80a5..cafb446 100644 --- a/src/Definition/ActionDefinition.php +++ b/src/Definition/ActionDefinition.php @@ -4,6 +4,8 @@ namespace Zhortein\DatatableBundle\Definition; +use Zhortein\DatatableBundle\Enum\ActionIconPosition; + final readonly class ActionDefinition { /** @@ -15,6 +17,7 @@ public function __construct( private string $route, private ?string $label = null, private ?string $icon = null, + private ActionIconPosition $iconPosition = ActionIconPosition::Before, private string $httpMethod = 'GET', private ?string $confirmationMessage = null, private ?string $className = null, @@ -43,6 +46,11 @@ public function getIcon(): ?string return $this->icon; } + public function getIconPosition(): ActionIconPosition + { + return $this->iconPosition; + } + public function getHttpMethod(): string { return $this->httpMethod; diff --git a/src/Definition/DatatableDefinition.php b/src/Definition/DatatableDefinition.php index 090de90..5e734f6 100644 --- a/src/Definition/DatatableDefinition.php +++ b/src/Definition/DatatableDefinition.php @@ -4,6 +4,7 @@ namespace Zhortein\DatatableBundle\Definition; +use Zhortein\DatatableBundle\Enum\ActionIconPosition; use Zhortein\DatatableBundle\Enum\FilterOperator; use Zhortein\DatatableBundle\Enum\FilterType; use Zhortein\DatatableBundle\Enum\JoinType; @@ -198,6 +199,7 @@ public function addRowAction( string $route, ?string $label = null, ?string $icon = null, + ActionIconPosition $iconPosition = ActionIconPosition::Before, string $httpMethod = 'GET', ?string $confirmationMessage = null, ?string $className = null, @@ -209,6 +211,7 @@ public function addRowAction( route: $route, label: $label, icon: $icon, + iconPosition: $iconPosition, httpMethod: $httpMethod, confirmationMessage: $confirmationMessage, className: $className, @@ -236,6 +239,7 @@ public function addGlobalAction( string $route, ?string $label = null, ?string $icon = null, + ActionIconPosition $iconPosition = ActionIconPosition::Before, string $httpMethod = 'GET', ?string $confirmationMessage = null, ?string $className = null, @@ -247,6 +251,7 @@ public function addGlobalAction( route: $route, label: $label, icon: $icon, + iconPosition: $iconPosition, httpMethod: $httpMethod, confirmationMessage: $confirmationMessage, className: $className, diff --git a/src/Enum/ActionIconPosition.php b/src/Enum/ActionIconPosition.php new file mode 100644 index 0000000..fa2db8d --- /dev/null +++ b/src/Enum/ActionIconPosition.php @@ -0,0 +1,20 @@ +} + * @return array{name: string, label: string|null, icon: string|null, iconPosition: string, url: string, httpMethod: string, confirmationMessage: string|null, csrfToken: string|null, className: string|null, attributes: array} */ private function normalizeAction(ActionDefinition $action, string $url): array { @@ -288,6 +288,7 @@ private function normalizeAction(ActionDefinition $action, string $url): array 'name' => $action->getName(), 'label' => $action->getLabel(), 'icon' => $action->getIcon(), + 'iconPosition' => $action->getIconPosition()->value, 'url' => $url, 'httpMethod' => $httpMethod, 'confirmationMessage' => $action->getConfirmationMessage(), diff --git a/templates/bootstrap/_action.html.twig b/templates/bootstrap/_action.html.twig index 764c4ef..c651d8d 100644 --- a/templates/bootstrap/_action.html.twig +++ b/templates/bootstrap/_action.html.twig @@ -1,3 +1,7 @@ +{% set has_icon = action.icon is not null and action.icon is not empty %} +{% set icon_position = action.iconPosition ?? 'before' %} +{% set label = action.label ?? action.name %} + {% if action.httpMethod == 'GET' %} - {% if action.icon is not null %} - + {% if has_icon and icon_position == 'before' %} + {% endif %} - {{ action.label ?? action.name }} + {{ label }} + + {% if has_icon and icon_position == 'after' %} + + {% endif %} {% else %}
- {% if action.icon is not null %} - + {% if has_icon and icon_position == 'before' %} + {% endif %} - {{ action.label ?? action.name }} + {{ label }} + + {% if has_icon and icon_position == 'after' %} + + {% endif %}
{% endif %} diff --git a/tests/Unit/Enum/ActionIconPositionTest.php b/tests/Unit/Enum/ActionIconPositionTest.php new file mode 100644 index 0000000..36c3cad --- /dev/null +++ b/tests/Unit/Enum/ActionIconPositionTest.php @@ -0,0 +1,25 @@ +addColumn('e.email', label: 'Email') + ->addRowAction( + name: 'view', + route: 'app_user_show', + label: 'View', + icon: 'bi bi-eye', + routeParameters: ['id' => 'e.id'], + ) + ; + + $html = $this->createRenderer()->renderBody($definition, $this->createResult()); + + self::assertStringContainsString('', $html); + self::assertMatchesRegularExpression('/bi bi-eye me-1.*View<\/span>/s', $html); + } + + public function test_it_renders_icon_after_action_label_when_configured(): void + { + $definition = new DatatableDefinition('users'); + + $definition + ->addColumn('e.email', label: 'Email') + ->addRowAction( + name: 'view', + route: 'app_user_show', + label: 'View', + icon: 'bi bi-arrow-right', + iconPosition: ActionIconPosition::After, + routeParameters: ['id' => 'e.id'], + ) + ; + + $html = $this->createRenderer()->renderBody($definition, $this->createResult()); + + self::assertStringContainsString('', $html); + self::assertMatchesRegularExpression('/View<\/span>.*bi bi-arrow-right ms-1/s', $html); + } + + public function test_it_renders_action_without_icon(): void + { + $definition = new DatatableDefinition('users'); + + $definition + ->addColumn('e.email', label: 'Email') + ->addRowAction( + name: 'view', + route: 'app_user_show', + label: 'View', + routeParameters: ['id' => 'e.id'], + ) + ; + + $html = $this->createRenderer()->renderBody($definition, $this->createResult()); + + self::assertStringContainsString('View', $html); + self::assertStringNotContainsString('aria-hidden="true"', $html); + } + + public function test_it_renders_global_action_icon(): void + { + $definition = new DatatableDefinition('users'); + + $definition + ->addColumn('e.email', label: 'Email') + ->addGlobalAction( + name: 'create', + route: 'app_user_create', + label: 'Create user', + icon: 'bi bi-plus-lg', + ) + ; + + $html = $this->createRenderer()->render($definition, [ + 'columnVisibility' => false, + 'export' => false, + ]); + + self::assertStringContainsString('bi bi-plus-lg me-1', $html); + self::assertStringContainsString('Create user', $html); + } + + private function createRenderer(): DatatableRenderer + { + return new DatatableRenderer( + twig: $this->createTwigEnvironment(), + urlGenerator: new IconTestUrlGenerator(), + routeParameterResolver: new RowActionRouteParameterResolver(), + actionVisibilityChecker: new AllowAllActionVisibilityChecker(), + ); + } + + private function createResult(): DatatableResult + { + return new DatatableResult( + rows: [ + [ + 'e_id' => 42, + 'e_email' => 'alice@example.test', + ], + ], + totalItems: 1, + ); + } + + private function createTwigEnvironment(): Environment + { + $loader = new FilesystemLoader(); + $loader->addPath(__DIR__.'/../../../templates', 'ZhorteinDatatable'); + + $twig = new Environment($loader, [ + 'strict_variables' => true, + 'autoescape' => 'html', + ]); + + $this->addTranslationExtension($twig); + + return $twig; + } +} + +final class IconTestUrlGenerator implements UrlGeneratorInterface +{ + /** + * @param array $parameters + */ + public function generate( + string $name, + array $parameters = [], + int $referenceType = self::ABSOLUTE_PATH, + ): string { + $id = $parameters['id'] ?? null; + + if ('app_user_show' === $name && (is_string($id) || is_int($id))) { + return '/users/'.$id; + } + + if ('app_user_create' === $name) { + return '/users/create'; + } + + return '/'.$name; + } + + public function setContext(RequestContext $context): void + { + } + + public function getContext(): RequestContext + { + return new RequestContext(); + } +}