Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

True Custom Action

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

The whole point of this "Pending Approval" section is to allow moderators to approve or delete questions. We can delete questions... but there's no way to approve them. Sure, we could add a little "Is Approved" checkbox to the form. But a true "approve" action with a button on the detail or index pages would be a lot nicer. It would also allow us to run custom code on approval if we need to. So let's create another custom action.

Adding the Action as a Button

Over in QuestionCrudController, say $approveAction = Action::new()... and I'll make up the word approve. Down at the bottom, add that to the detail page: ->add(Crud::PAGE_DETAIL, $approveAction).

... lines 1 - 21
class QuestionCrudController extends AbstractCrudController
{
... lines 24 - 37
public function configureActions(Actions $actions): Actions
{
... lines 40 - 48
$approveAction = Action::new('approve');
... line 50
return parent::configureActions($actions)
... lines 52 - 66
->add(Crud::PAGE_DETAIL, $approveAction);
}
... lines 69 - 155

Before we try that, call ->addCssClass('btn btn-success') and ->setIcon('fa fa-check-circle'). Also add ->displayAsButton().

... lines 1 - 37
public function configureActions(Actions $actions): Actions
{
... lines 40 - 48
$approveAction = Action::new('approve')
->addCssClass('btn btn-success')
->setIcon('fa fa-check-circle')
->displayAsButton();
... lines 53 - 70
}
... lines 72 - 158

By default, an action renders as a link... where the URL is wherever you want it to go. But in this case, we don't want approval to be done with a simple link that makes a "GET" request. Approving something will modify data on the server... and so it should really be a "POST" request. This will cause the action to render as a button instead of a link. We'll see how that works in a minute.

Linking to a CRUD Action

Ok, we have now created the action... but we need to link it to a URL or to a CRUD action. In this case, we need a CRUD action where we can write the approve logic. So say linkToCrudAction() passing the name of a method that we're going to create later. Let's call it approve.

... lines 1 - 37
public function configureActions(Actions $actions): Actions
{
... lines 40 - 48
$approveAction = Action::new('approve')
->linkToCrudAction('approve')
... lines 51 - 71
}
... lines 73 - 159

Sweet! Refresh and... duh! The button won't be here... but if we go to the detail page... got it! "Approve"!

Overriding the Template to Add a Form

Inspect element and check out the source code. Yup! This literally rendered as a button... and that's it. There's no form around this... and no JavaScript magic to make it submit. We can click this all day long and absolutely nothing happens. To make it work, we need to wrap it in a form so that, on click, it submits a POST request to the new action.

How can we do that? By leveraging a custom template. We know that EasyAdmin has lots of templates. Inside EasyAdmin... in its Resources/views/crud/ directory, there's an action.html.twig file. This is the template that's responsible for rendering every action. You can see that it's either an a tag or a button based on our config.

Copy the three lines on top that document the variables we have... and let's go create our own custom template. Inside templates/admin/, add a new file called approve_action.html.twig. Paste in the comments... and then... just to further help us know what's going on, dump that action variable: dump(action).

{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var action \EasyCorp\Bundle\EasyAdminBundle\Dto\ActionDto #}
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
{{ dump(action) }}

To use this template, over in QuestionCrudController... right on the action, add ->setTemplatePath('admin/approve_action.html.twig').

... lines 1 - 21
class QuestionCrudController extends AbstractCrudController
{
... lines 24 - 37
public function configureActions(Actions $actions): Actions
{
... lines 40 - 48
$approveAction = Action::new('approve')
->setTemplatePath('admin/approve_action.html.twig')
... lines 51 - 72
}
... lines 74 - 158
}

Let's try it. Refresh and... cool! We see the dump and all the data on that ActionDto object. The most important thing for us is linkURL. This contains the URL we can use to execute the approve() action that we'll create in a minute.

And because this new template is only being used by our one action... we're free to do whatever we want! All the other actions are still using the core action.html.twig template. Add a form... with action="{{ action.linkUrl }}"... and then method="POST". Inside, we need the button. We could create it ourselves... or we can be lazy and {{ include('@EasyAdmin/crud/action.html.twig') }}.

... lines 1 - 3
<form action="{{ action.linkUrl }}" method="POST">
{{ include('@EasyAdmin/crud/action.html.twig') }}
</form>

That's all we need! Reload the page... and inspect that element to see... exactly what we want: a form with the correct action... and our button inside. Though, we do need to fix the styling a little bit. Add class="me-2".

... lines 1 - 3
<form action="{{ action.linkUrl }}" method="POST" class="me-2">
... lines 5 - 7

Refresh and... looks better!

Try clicking this. We get... a giant error! Progress!

The controller for URI "/admin" is not callable: Expected method "approve" on
[our class].

Let's add that custom controller method next, and learn how to generate URLs to other EasyAdmin pages from inside PHP.

Leave a comment!

17
Login or Register to join the conversation
Rudi-T Avatar
Rudi-T Avatar Rudi-T | posted 2 months ago | edited

Just wanted to share this insight:

If you need access to the current entity on a action data attribute, you can use this trick:

Ex show a modal if you want to block a user, that asks if you are sure to block user x?

entity is a var that lives in the twig action template, so you can abuse it like this:

$blockUserAction = static fn(): Action => Action::new('blockUser', 'Block user')
    ->linkToCrudAction('blockUser')
    ->setTemplatePath($postButtonTemplate)
    ->setHtmlAttributes([ // TODO: translate
        'data-confirmation-message' => 'Do you want to block user {% if entity is defined %}{{ entity.instance.username }}{% else %}{{ea.entity.instance.username}}{% endif %}?'
    ])
    ->displayIf(fn(User $user) => $user->getStatus() !== UserStatus::BLOCKED);

Inside your template you can do this:

{# @var action \EasyCorp\Bundle\EasyAdminBundle\Dto\ActionDto #}
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
{% set confirmationMessage = action.getHtmlAttributes()['data-confirmation-message']|default('') %}
{% if confirmationMessage != '' %}
{% do action.setHtmlAttributes(action.htmlAttributes|merge({'data-confirmation-message': include(template_from_string(confirmationMessage))})) %}
{% endif %}

You still need to activate this function in Symfony:

services:
    Twig\Extension\StringLoaderExtension: ~

More info: https://twig.symfony.com/doc/3.x/functions/template_from_string.html

Reply

Hey @Rudi-T

That's a nice trick, but it's not clear to me why you have to write the Twig template code inside a HTML attribute?

Cheers!

Reply
Rudi-T Avatar

Hey @MolloKhan, It's to connect a stimulus controller and set the message of a modal.
A bit like the idea highlighted in this tutorial, that you could implement.

In our solution we don't need a custom template for each modal we show. Very dynamic with almost no code maintenance.

1 Reply
Rudi-T Avatar
Rudi-T Avatar Rudi-T | posted 3 months ago | edited

Is it possible that this is bugged on the latest version?

Warning: Undefined array key "ea"

{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var action \EasyCorp\Bundle\EasyAdminBundle\Dto\ActionDto #}
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
{# Fix edit bug #}
{% set excludedKey = 'form' %}  
{% set htmlAttributes = action.htmlAttributes|default({}) %} 
{% set filteredHtmlAttributes = htmlAttributes|merge([])|filter((value, key) => key != excludedKey) %} 
{% do action.setHtmlAttributes(filteredHtmlAttributes) %}  
<form action="{{ action.linkUrl }}" method="POST" class="me-2">    
    {{ include('@EasyAdmin/crud/action.html.twig') }} 
</form>

Fix only works if I remove the form attribute of the button

Reply

Hey @Rudi-T!

Hmm, it's possible, though EasyAdmin hasn't gotten any major upgrades, so nothing "should" have broken. Where does this error come from - like what's the stack trace? And, is your above code (the 3x set and do a work-around for the error)?

Cheers!

Reply
Rudi-T Avatar
Rudi-T Avatar Rudi-T | weaverryan | posted 3 months ago | edited
ErrorException:
Warning: Undefined array key "ea"

  at vendor/easycorp/easyadmin-bundle/src/Controller/AbstractCrudController.php:624
  at EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController->getRedirectResponseAfterSave(object(AdminContext), 'edit')
     (vendor/easycorp/easyadmin-bundle/src/Controller/AbstractCrudController.php:263)
  at EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController->edit(object(AdminContext))
     (vendor/symfony/http-kernel/HttpKernel.php:163)
  at Symfony\Component\HttpKernel\HttpKernel->handleRaw(object(Request), 1)
     (vendor/symfony/http-kernel/HttpKernel.php:74)
  at Symfony\Component\HttpKernel\HttpKernel->handle(object(Request), 1, true)
     (vendor/symfony/http-kernel/Kernel.php:184)
  at Symfony\Component\HttpKernel\Kernel->handle(object(Request))
     (vendor/symfony/runtime/Runner/Symfony/HttpKernelRunner.php:35)
  at Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner->run()
     (vendor/autoload_runtime.php:29)
  at require_once('/Users/mister.x/Projects/easyAdmin/vendor/autoload_runtime.php')
     (public/index.php:5)      

SomeWhere from this:

protected function getRedirectResponseAfterSave(AdminContext $context, string $action): RedirectResponse
    {
        $submitButtonName = $context->getRequest()->request->all()['ea']['newForm']['btn'];

I tried adding this manually, but didn't do anything. The only workaround was removing the form attribute.

Reply

Hmm, yea, something isn't right. I'm actually a bit confused. Here's what I see:

1) We add a new action called approve.
2) We create a custom template to render this action's button - approve_action.html.twig. This is ONLY responsible for rendering the "Approve" button and shouldn't affect anything else. This is also where your big fix code lives.
3) This new button is surrounded by a form that will submit to the "approve" action.

The confusing part is that, from your stacktrace, the error is coming from the edit action - from after a form submit. What I can't figure out is how the approve_action.html.twig is affecting this. When you click "Approve", it it executing the edit action (and not the approve action)? If so, I'd love to see what the action attribute looks like in the form in your HTML.

Cheers!

Reply

Hi! In this lesson we added the "Approve" button to approve questions by users with ROLE_MODERATOR. But what I have to do if I want to show Boolean Field "isApproved" on index or other pages for users with lower roles, for example, ROLE_ADMIN in read-only status? I want that admins can see, approved this question or no.

If I did for this field ->setPermission('ROLE_ADMIN') - the field become editable, If I did ->setPermission('ROLE_MODERATOR') - the field disappears, function hideOn... or showOn do not give me the desired result too.

Thanks in advance!

Reply

Hey Ruslan,

Just add a BooleanField and display it on PAGE_INDEX. You can render that field as a a label, see https://symfony.com/bundles/EasyAdminBundle/current/fields/BooleanField.html#renderasswitch - it will mean that users will only be able to read the value. And I suppose you can just show it to anyone, but keep the security role as ROLE_MODERATOR for editing only.

Cheers!

Reply

Thank you, Viktor. It is not so simple. What, if on PAGE_INDEX this field should stay editable for ROLE_MODERATOR and not editable for ROLE_ADMIN?

Reply

Hey Ruslan,

I think it's pretty easy actually... just inject the Security service into your controller's constructor and use it to decide either the field should be rendered as a switch or as a label in the configureFields(). And you can wrap in an if to hide it or make it only readonly on edit page for example.

Cheers!

1 Reply
Scott-1 Avatar
Scott-1 Avatar Scott-1 | posted 1 year ago | edited

He there

Is there any other way to get the current entity in configureActions without using

->linkToUrl()
->linkToRoute()
->linkToCrudAction()

The reason i am asking this is because i need a few parameters that is in the entity. I also have a custom template because the call is made by my stimulus controller. The only method i have found now is using callable on one of the link methods:

->linkToUrl(function (Blocks $block) {
    return $block->getUrl();
})

I have tried $this->getContext()->getEntity() but getContext() is null. I'm a bit stuck here, hope you can help.

Reply

Hey Scott S.

You can only get the entity instance on the detail or edit page, so, you have to check that first, then you can get the instance through the AdminContext, or you can do it manually by getting the entityId from the request

Cheers!

Reply
Fabrice Avatar
Fabrice Avatar Fabrice | posted 1 year ago

Hello ! Unfortunately with this kind of action in POST, it causes something bad at the front-end. The action becomes a form as agreed, but it will take up a whole line, and the other actions will be shifted below. See for yourself

https://www.noelshack.com/2...

Reply

Hey Fabrice!

Hmm, you might be right! But, where/how are you seeing the icons that you posted? On the "index" page, by default, these should be rendered as a drop-down. And if I call showEntityActionsInlined() to expand them, I still don't see the bad behavior. Is this on some other page?

Cheers!

Reply
Fabrice Avatar

Hello! Yes, this is on one of my projects, in which the actions are rendered inlined. So maybe it's no wonder it doesn't do it for you. Yet the method of application is the same.

Reply

Hi!

Yea... that's super weird. I can't explain why your version looks bad but mine does not. But, in theory, it makes sense: a form will often be styled differently (often with display: block) than a normal button, which could cause this. In that case, you'll need to add some custom CSS, likely to set the form to display: inline. I'm doing some guessing, but that's the most likely culprit!

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0
    }
}
userVoice