diff --git a/config/services.php b/config/services.php index 0fcb6a806f..56af15a13b 100644 --- a/config/services.php +++ b/config/services.php @@ -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 @@ -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)) diff --git a/doc/actions.rst b/doc/actions.rst index a89c4ae87a..d4c5816fff 100644 --- a/doc/actions.rst +++ b/doc/actions.rst @@ -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 ---------------- @@ -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 Extensions +------------------ + +Applications using EasyAdmin define their actions in the ``configureActions()`` +method of the :doc:`CRUD controllers `. You can enable, disable, or modify +:ref:`built-in actions `, and also create your own +:ref:`custom actions `. + +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:: + + // /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 diff --git a/src/Contracts/Action/ActionsExtensionInterface.php b/src/Contracts/Action/ActionsExtensionInterface.php new file mode 100644 index 0000000000..0d06e6475e --- /dev/null +++ b/src/Contracts/Action/ActionsExtensionInterface.php @@ -0,0 +1,18 @@ + + */ +interface ActionsExtensionInterface +{ + public function supports(AdminContext $context): bool; + + public function extend(Actions $actions, AdminContext $context): void; +} diff --git a/src/DependencyInjection/EasyAdminExtension.php b/src/DependencyInjection/EasyAdminExtension.php index c61452d9de..fd6027784c 100644 --- a/src/DependencyInjection/EasyAdminExtension.php +++ b/src/DependencyInjection/EasyAdminExtension.php @@ -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; @@ -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 { @@ -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'); } diff --git a/src/Factory/ActionFactory.php b/src/Factory/ActionFactory.php index f258a321ad..9c378656ae 100644 --- a/src/Factory/ActionFactory.php +++ b/src/Factory/ActionFactory.php @@ -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; @@ -29,11 +33,15 @@ */ final class ActionFactory { + /** + * @param iterable $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 = [], ) { } @@ -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); + } + } } diff --git a/src/Factory/AdminContextFactory.php b/src/Factory/AdminContextFactory.php index 6eadb07f39..4333fd1718 100644 --- a/src/Factory/AdminContextFactory.php +++ b/src/Factory/AdminContextFactory.php @@ -40,6 +40,7 @@ public function __construct( private readonly CrudControllerRegistry $crudControllers, private readonly EntityFactory $entityFactory, private readonly AdminRouteGeneratorInterface $adminRouteGenerator, + private readonly ActionFactory $actionFactory, ) { } @@ -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 @@ -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) {