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
2 changes: 2 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@
->arg(3, new Reference(CrudControllerRegistry::class))
->arg(4, new Reference(EntityFactory::class))
->arg(5, service(AdminRouteGenerator::class))
->arg(6, service(ActionFactory::class))

->set(AdminUrlGenerator::class)
// I don't know if we truly need the share() method to get a new instance of the
Expand Down Expand Up @@ -318,6 +319,7 @@
->arg(1, new Reference(AuthorizationChecker::class))
->arg(2, new Reference(AdminUrlGenerator::class))
->arg(3, new Reference('security.csrf.token_manager', ContainerInterface::NULL_ON_INVALID_REFERENCE))
->arg(4, tagged_iterator(EasyAdminExtension::TAG_ACTIONS_EXTENSION))

->set(SecurityVoter::class)
->arg(0, service(AuthorizationChecker::class))
Expand Down
59 changes: 59 additions & 0 deletions doc/actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ strings with the action names (``'index'``, ``'detail'``, ``'edit'``, etc.) you
can also use constants for these values: ``Action::INDEX``, ``Action::DETAIL``,
``Action::EDIT``, etc. (they are defined in the ``EasyCorp\Bundle\EasyAdminBundle\Config\Action`` class).

.. _actions-built-in:

Built-in Actions
----------------

Expand Down Expand Up @@ -996,6 +998,63 @@ This is no longer needed in modern EasyAdmin versions and is now a discouraged
practice that you should avoid in your applications. Instead, see the previous
section about :ref:`how to integrate custom Symfony controllers into EasyAdmin dashboards <actions-integrating-symfony>`.

Actions Extensions
------------------

Applications using EasyAdmin define their actions in the ``configureActions()``
method of the :doc:`CRUD controllers </crud>`. You can enable, disable, or modify
:ref:`built-in actions <actions-built-in>`, and also create your own
:ref:`custom actions <actions-custom>`.

EasyAdmin provides an additional feature to add, remove, or change actions
(built-in or custom) dynamically at runtime: **action extensions**. They allow
your application (or third-party bundles installed in it) to modify the actions
defined for your controllers.

Action extensions are PHP classes that receive the full configuration of
actions in your backend so they can add, remove, or update any of them.

For example, imagine you need a **Duplicate** action across most of your
backends. Instead of defining it repeatedly, you can create a reusable package
(such as a `Symfony bundle`_) and add the following class::

// <your-package>/src/DuplicateActionExtension.php
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Action\ActionsExtensionInterface;

final class DuplicateActionExtension implements ActionsExtension
{
// return true in this method to enable the extension for
// the current backend request
public function supports(AdminContext $context): bool
{
// enable the extension only on some pages
return $context->getCrud()->getCurrentPage() === Crud::PAGE_DETAIL;

// enable it on all except some entities
$entityFqcn = $context->getCrud()->getEntityFqcn();
return null !== $entityFqcn && !\in_array(entityFqcn, ['...'], true);

// or use any other admin context data to make the decision
}

public function extend(Actions $actions, AdminContext $context): void
{
$duplicate = Action::new('duplicate', 'Duplicate', 'fa fa-clone')
->linkToCrudAction('duplicate')
->asSuccessAction();

$actions->add(Crud::PAGE_DETAIL, $duplicate);

// you can add single actions, groups of actions, etc.
// you can also remove or update existing actions
}
}

.. _`FontAwesome`: https://fontawesome.com/
.. _`Symfony base controller class`: https://symfony.com/doc/current/controller.html#the-base-controller-class-services
.. _`Symfony controllers`: https://symfony.com/doc/current/controller.html
.. _`Symfony bundle`: https://symfony.com/doc/current/bundles.html
18 changes: 18 additions & 0 deletions src/Contracts/Action/ActionsExtensionInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Contracts\Action;

use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;

/**
* Contract for services that modify EasyAdmin actions at runtime.
*
* @author Javier Eguiluz <[email protected]>
*/
interface ActionsExtensionInterface
{
public function supports(AdminContext $context): bool;

public function extend(Actions $actions, AdminContext $context): void;
}
5 changes: 5 additions & 0 deletions src/DependencyInjection/EasyAdminExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace EasyCorp\Bundle\EasyAdminBundle\DependencyInjection;

use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Action\ActionsExtensionInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\CrudControllerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\DashboardControllerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface;
Expand All @@ -24,6 +25,7 @@ class EasyAdminExtension extends Extension implements PrependExtensionInterface
public const TAG_ADMIN_ROUTE_CONTROLLER = 'ea.admin_route_controller';
public const TAG_FIELD_CONFIGURATOR = 'ea.field_configurator';
public const TAG_FILTER_CONFIGURATOR = 'ea.filter_configurator';
public const TAG_ACTIONS_EXTENSION = 'ea.actions_extension';

public function load(array $configs, ContainerBuilder $container): void
{
Expand All @@ -45,6 +47,9 @@ static function (Definition $definition, AdminRoute $attribute, \ReflectionClass
$container->registerForAutoconfiguration(FilterConfiguratorInterface::class)
->addTag(self::TAG_FILTER_CONFIGURATOR);

$container->registerForAutoconfiguration(ActionsExtensionInterface::class)
->addTag(self::TAG_ACTIONS_EXTENSION);

$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config'));
$loader->load('services.php');
}
Expand Down
43 changes: 43 additions & 0 deletions src/Factory/ActionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
use EasyCorp\Bundle\EasyAdminBundle\Collection\ActionCollection;
use EasyCorp\Bundle\EasyAdminBundle\Collection\EntityCollection;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Action\ActionsExtensionInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\CrudControllerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\DashboardControllerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Provider\AdminContextProviderInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\ActionConfigDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\ActionDto;
Expand All @@ -29,11 +33,15 @@
*/
final class ActionFactory
{
/**
* @param iterable<ActionsExtensionInterface> $actionsExtensions
*/
public function __construct(
private readonly AdminContextProviderInterface $adminContextProvider,
private readonly AuthorizationCheckerInterface $authChecker,
private readonly AdminUrlGeneratorInterface $adminUrlGenerator,
private readonly ?CsrfTokenManagerInterface $csrfTokenManager = null,
private readonly iterable $actionsExtensions = [],
) {
}

Expand Down Expand Up @@ -409,4 +417,39 @@ private function processActionGroup(string $pageName, ActionGroupDto $groupDto,

return $newGroupDto;
}

/**
* Builds the complete actions configuration by:
* 1. Getting actions from dashboard and CRUD controllers
* 2. Applying registered actions extensions
* 3. Converting to ActionConfigDto
*/
public function buildActionsConfig(DashboardControllerInterface $dashboardController, ?CrudControllerInterface $crudController, AdminContext $context, ?string $pageName): ActionConfigDto
{
if (null === $crudController) {
return new ActionConfigDto();
}

$defaultActionConfig = $dashboardController->configureActions();
$actions = $crudController->configureActions($defaultActionConfig);

$this->applyExtensions($actions, $context);

return $actions->getAsDto($pageName);
}

/**
* Applies all registered action extensions to the original Actions configuration
* so they can modify it adding/updating/removing actions.
*/
private function applyExtensions(Actions $actions, AdminContext $context): void
{
foreach ($this->actionsExtensions as $extension) {
if (!$extension->supports($context)) {
continue;
}

$extension->extend($actions, $context);
}
}
}
28 changes: 13 additions & 15 deletions src/Factory/AdminContextFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public function __construct(
private readonly CrudControllerRegistry $crudControllers,
private readonly EntityFactory $entityFactory,
private readonly AdminRouteGeneratorInterface $adminRouteGenerator,
private readonly ActionFactory $actionFactory,
) {
}

Expand All @@ -51,19 +52,27 @@ public function create(Request $request, DashboardControllerInterface $dashboard

$dashboardDto = $this->getDashboardDto($request, $dashboardController);
$assetDto = $this->getAssetDto($dashboardController, $crudController, $pageName);
$actionConfigDto = $this->getActionConfig($dashboardController, $crudController, $pageName);
$filters = $this->getFilters($dashboardController, $crudController);

$crudDto = $this->getCrudDto($this->crudControllers, $dashboardController, $crudController, $actionConfigDto, $filters, $crudAction, $pageName);
// build a first version of CrudDto without actions so we can create AdminContext, which is
// needed for action extensions; later, we'll update the CrudDto object with the full action config
$crudDto = $this->getCrudDto($this->crudControllers, $dashboardController, $crudController, new ActionConfigDto(), $filters, $crudAction, $pageName);
$entityDto = $this->getEntityDto($request, $crudDto);
$searchDto = $this->getSearchDto($request, $crudDto);
$i18nDto = $this->getI18nDto($request, $dashboardDto, $crudDto, $entityDto);
$templateRegistry = $this->getTemplateRegistry($dashboardController, $crudDto);
$user = $this->getUser($this->tokenStorage);

$usePrettyUrls = $this->adminRouteGenerator->usesPrettyUrls();

return new AdminContext($request, $user, $i18nDto, $this->crudControllers, $dashboardDto, $dashboardController, $assetDto, $crudDto, $entityDto, $searchDto, $this->menuFactory, $templateRegistry, $usePrettyUrls);
// create AdminContext (with empty actions initially)
$adminContext = new AdminContext($request, $user, $i18nDto, $this->crudControllers, $dashboardDto, $dashboardController, $assetDto, $crudDto, $entityDto, $searchDto, $this->menuFactory, $templateRegistry, $usePrettyUrls);

// build actions with extensions and update the CrudDto
// (ActionFactory needs the full AdminContext to apply extensions)
$actionConfigDto = $this->actionFactory->buildActionsConfig($dashboardController, $crudController, $adminContext, $pageName);
$crudDto?->setActionsConfig($actionConfigDto);

return $adminContext;
}

private function getDashboardDto(Request $request, DashboardControllerInterface $dashboardControllerInstance): DashboardDto
Expand Down Expand Up @@ -125,17 +134,6 @@ private function getCrudDto(CrudControllerRegistry $crudControllers, DashboardCo
return $crudDto;
}

private function getActionConfig(DashboardControllerInterface $dashboardController, ?CrudControllerInterface $crudController, ?string $pageName): ActionConfigDto
{
if (null === $crudController) {
return new ActionConfigDto();
}

$defaultActionConfig = $dashboardController->configureActions();

return $crudController->configureActions($defaultActionConfig)->getAsDto($pageName);
}

private function getFilters(DashboardControllerInterface $dashboardController, ?CrudControllerInterface $crudController): FilterConfigDto
{
if (null === $crudController) {
Expand Down