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
55 changes: 46 additions & 9 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class App {
this.#createFilters();
this.#createAutoCompleteFields();
this.#createBatchActions();
this.#createModalWindowsForDeleteActions();
this.#createActionConfirmationModals();
this.#createPopovers();
this.#createTooltips();

Expand Down Expand Up @@ -394,17 +394,54 @@ class App {
});
}

#createModalWindowsForDeleteActions() {
document.querySelectorAll('[data-action-name="delete"]').forEach((actionElement) => {
#createActionConfirmationModals() {
const modalTitle = document.querySelector('#action-confirmation-title');
const modalButton = document.querySelector('#modal-action-confirmation-button');
const defaultTitleTemplate = modalTitle?.textContent;
const defaultButtonLabel = modalButton?.textContent;

document.querySelectorAll('[data-action-confirmation="true"]').forEach((actionElement) => {
actionElement.addEventListener('click', (event) => {
event.preventDefault();

document.querySelector('#modal-delete-button').addEventListener('click', () => {
const deleteFormAction = actionElement.getAttribute('formaction');
const deleteForm = document.querySelector('#delete-form');
deleteForm.setAttribute('action', deleteFormAction);
deleteForm.submit();
});
const actionName = actionElement.textContent.trim() || actionElement.getAttribute('title');
const entityName = actionElement.getAttribute('data-action-entity-name') || '';
const entityId = actionElement.getAttribute('data-action-entity-id') || '';

// use custom message if provided, otherwise use default modal title
const customMessage = actionElement.getAttribute('data-action-confirmation-message');
const messageTemplate = customMessage ?? defaultTitleTemplate;

modalTitle.textContent = messageTemplate
.replace('%action_name%', actionName)
.replace('%entity_name%', entityName)
.replace('%entity_id%', entityId);

// use custom button label if provided, otherwise use default
const customButtonLabel = actionElement.getAttribute('data-action-confirmation-button');
modalButton.textContent = customButtonLabel ?? defaultButtonLabel;

modalButton.addEventListener(
'click',
() => {
// check if this is a POST action (like DELETE with formaction) or GET (link href)
const formAction = actionElement.getAttribute('formaction');

if (formAction) {
// POST action: use the hidden form with CSRF token (like DELETE)
const form = document.querySelector('#action-confirmation-form');
form.setAttribute('action', formAction);
form.submit();
} else {
// GET action: navigate to the href URL
const href = actionElement.getAttribute('href');
if (href) {
window.location.href = href;
}
}
},
{ once: true }
);
});
});
}
Expand Down
70 changes: 70 additions & 0 deletions doc/actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,76 @@ to users::
However, your closure won't receive the object that represents the current
entity because global actions are not associated to any specific entity.

Action Confirmation
-------------------

By default, actions are executed immediately when clicked. The only exception
is the built-in ``delete`` action, which shows a confirmation message. For potentially
destructive or important actions, you can require user confirmation before execution.

To enable confirmation for any action, use the ``askConfirmation()`` method::

use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;

public function configureActions(Actions $actions): Actions
{
$archiveAction = Action::new('archive', 'Archive')
->linkToCrudAction('archive')
->askConfirmation();

return $actions
->add(Crud::PAGE_INDEX, $archiveAction);
}

This will display a confirmation modal with a generic message before executing
the action. You can customize the confirmation message by passing a string::

$archiveAction = Action::new('archive', 'Archive')
->linkToCrudAction('archive')
->askConfirmation('Are you sure you want to archive this item?');

The confirmation message supports placeholders that are replaced with actual
values: ``%action_name%`` (the action label), ``%entity_name%`` (the entity
label in singular), and ``%entity_id%`` (the entity ID)::

$archiveAction = Action::new('archive', 'Archive')
->linkToCrudAction('archive')
->askConfirmation('Are you sure you want to %action_name% "%entity_name%" #%entity_id%?');

For translatable messages, pass a ``TranslatableInterface`` object::

use function Symfony\Component\Translation\t;

$archiveAction = Action::new('archive', 'Archive')
->linkToCrudAction('archive')
->askConfirmation(t('action.archive.confirm'));

You can also customize the confirmation button label by passing a second parameter::

$publishAction = Action::new('publish', 'Publish')
->linkToCrudAction('publish')
->askConfirmation('Do you accept publishing this article?', 'Accept');

This is useful when the default "Confirm" label doesn't match the action context.
Both parameters support translatable messages::

$publishAction = Action::new('publish', 'Publish')
->linkToCrudAction('publish')
->askConfirmation(t('action.publish.confirm'), t('action.publish.button'));

The ``delete`` action shows a confirmation message by default. Although it's
strongly recommended to keep this behavior, you can disable the confirmation dialog::

public function configureActions(Actions $actions): Actions
{
return $actions
->update(Crud::PAGE_INDEX, Action::DELETE, function (Action $action) {
return $action->askConfirmation(false);
});
}

Disabling Actions
-----------------

Expand Down
2 changes: 0 additions & 2 deletions public/app.8222474a.js

This file was deleted.

2 changes: 2 additions & 0 deletions public/app.fe02cdfe.js

Large diffs are not rendered by default.

File renamed without changes.
2 changes: 1 addition & 1 deletion public/entrypoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"/app.f9f0ca7b.css"
],
"js": [
"/app.8222474a.js"
"/app.fe02cdfe.js"
]
},
"form": {
Expand Down
2 changes: 1 addition & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"app.css": "app.f9f0ca7b.css",
"app.js": "app.8222474a.js",
"app.js": "app.fe02cdfe.js",
"form.js": "form.875c88d4.js",
"page-layout.js": "page-layout.6e9fe55d.js",
"page-color-scheme.js": "page-color-scheme.30cb23c2.js",
Expand Down
17 changes: 17 additions & 0 deletions src/Config/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,23 @@ public function asTextLink(bool $asTextLink = true): self
return $this;
}

/**
* By default, actions are executed immediately when clicked.
* Set to true to show a confirmation modal with a generic message.
* Set to a string (or TranslatableInterface) to show a custom confirmation message.
* The message can use placeholders: %action_name%, %entity_name%, and %entity_id%.
* Optionally, set a custom label for the confirmation button.
*/
public function askConfirmation(
bool|string|TranslatableInterface $confirmation = true,
string|TranslatableInterface|null $buttonLabel = null,
): self {
$this->dto->setConfirmationMessage($confirmation);
$this->dto->setConfirmationButtonLabel($buttonLabel);

return $this;
}

public function getAsDto(): ActionDto
{
if ((!$this->dto->isDynamicLabel() && null === $this->dto->getLabel()) && null === $this->dto->getIcon()) {
Expand Down
1 change: 1 addition & 0 deletions src/Config/Actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ private function createBuiltInAction(string $pageName, string $actionName): Acti
->linkToCrudAction(Action::DELETE)
->asDangerAction()
->asTextLink()
->askConfirmation()
;
}

Expand Down
42 changes: 42 additions & 0 deletions src/Dto/ActionDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ final class ActionDto
private ButtonType $buttonType = ButtonType::Submit;
private ButtonVariant $variant = ButtonVariant::Default;
private ButtonStyle $style = ButtonStyle::Solid;
private bool|string|TranslatableInterface $confirmationMessage = false;
private string|TranslatableInterface|null $displayableConfirmationMessage = null;
private string|TranslatableInterface|null $confirmationButtonLabel = null;

public function getType(): string
{
Expand Down Expand Up @@ -354,6 +357,41 @@ public function usesTextStyle(): bool
return ButtonStyle::Text === $this->style;
}

public function getConfirmationMessage(): bool|string|TranslatableInterface
{
return $this->confirmationMessage;
}

public function setConfirmationMessage(bool|string|TranslatableInterface $message): void
{
$this->confirmationMessage = $message;
}

public function hasConfirmation(): bool
{
return false !== $this->confirmationMessage;
}

public function getDisplayableConfirmationMessage(): string|TranslatableInterface|null
{
return $this->displayableConfirmationMessage;
}

public function setDisplayableConfirmationMessage(string|TranslatableInterface|null $message): void
{
$this->displayableConfirmationMessage = $message;
}

public function getConfirmationButtonLabel(): string|TranslatableInterface|null
{
return $this->confirmationButtonLabel;
}

public function setConfirmationButtonLabel(string|TranslatableInterface|null $label): void
{
$this->confirmationButtonLabel = $label;
}

/**
* @internal
*/
Expand Down Expand Up @@ -407,6 +445,10 @@ public function getAsConfigObject(): Action
$action->displayIf($this->displayCallable);
}

if (false !== $this->confirmationMessage) {
$action->askConfirmation($this->confirmationMessage, $this->confirmationButtonLabel);
}

return $action;
}
}
16 changes: 15 additions & 1 deletion src/Factory/ActionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,23 @@ private function processAction(string $pageName, ActionDto $actionDto, ?EntityDt
if (Action::DELETE === $actionDto->getName()) {
$actionDto->addHtmlAttributes([
'formaction' => $this->adminUrlGenerator->setController($adminContext->getCrud()->getControllerFqcn())->setAction(Action::DELETE)->setEntityId($entityDto->getPrimaryKeyValue())->generateUrl(),
]);
}

// handle action confirmation modals (including DELETE action when askConfirmation is enabled)
if ($actionDto->hasConfirmation()) {
$confirmationMessage = $actionDto->getConfirmationMessage();

$actionDto->addHtmlAttributes([
'data-bs-toggle' => 'modal',
'data-bs-target' => '#modal-delete',
'data-bs-target' => '#modal-action-confirmation',
'data-action-confirmation' => 'true',
]);

// if a custom message is provided (string or TranslatableInterface), store it for Twig to translate
if (true !== $confirmationMessage) {
$actionDto->setDisplayableConfirmationMessage($confirmationMessage);
}
}

if ($actionDto->isBatchAction()) {
Expand Down
20 changes: 19 additions & 1 deletion templates/crud/action.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,29 @@
{# @var action \EasyCorp\Bundle\EasyAdminBundle\Dto\ActionDto #}
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}

{% set html_attributes = action.htmlAttributes %}
{% if action.displayableConfirmationMessage is not null %}
{% set html_attributes = html_attributes|merge({
'data-action-confirmation-message': action.displayableConfirmationMessage|trans,
}) %}
{% endif %}
{% if action.hasConfirmation %}
{% set html_attributes = html_attributes|merge({
'data-action-entity-name': ea().crud.entityLabelInSingular|default('')|trans,
'data-action-entity-id': entity.primaryKeyValueAsString|default(''),
}) %}
{% if action.confirmationButtonLabel is not null %}
{% set html_attributes = html_attributes|merge({
'data-action-confirmation-button': action.confirmationButtonLabel|trans,
}) %}
{% endif %}
{% endif %}

<twig:ea:Button
variant="{{ action.variant }}"
isInvisible="{{ action.usesTextStyle }}"
class="{{ action.htmlElement.isLink and isIncludedInDropdown|default(false) ? 'dropdown-item' }} {{ action.cssClass }}"
htmlAttributes="{{ action.htmlAttributes }}"
htmlAttributes="{{ html_attributes }}"
htmlElement="{{ action.htmlElement }}"
type="{{ action.buttonType.value }}"
icon="{{ action.icon }}"
Expand Down
4 changes: 2 additions & 2 deletions templates/crud/detail.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
{% if item.isActionGroup %}
{{ include(item.templatePath, {group: item, entity: entity}, with_context: false) }}
{% else %}
{{ include(item.templatePath, {action: item}, with_context: false) }}
{{ include(item.templatePath, {action: item, entity: entity}, with_context: false) }}
{% endif %}
{% endfor %}
{% endblock %}
Expand All @@ -63,7 +63,7 @@
{% endblock detail_fields %}

{% block delete_form %}
{{ include('@EasyAdmin/crud/includes/_delete_form.html.twig', {entity_id: entity.primaryKeyValue}, with_context: false) }}
{{ include('@EasyAdmin/crud/includes/_action_confirmation_modal.html.twig', with_context: false) }}
{% endblock delete_form %}
{% endblock %}

Expand Down
4 changes: 2 additions & 2 deletions templates/crud/edit.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
{% if item.isActionGroup %}
{{ include(item.templatePath, {group: item, entity: entity}, with_context: false) }}
{% else %}
{{ include(item.templatePath, {action: item}, with_context: false) }}
{{ include(item.templatePath, {action: item, entity: entity}, with_context: false) }}
{% endif %}
{% endfor %}
{% endblock %}
Expand All @@ -65,6 +65,6 @@
{% endblock edit_form %}

{% block delete_form %}
{{ include('@EasyAdmin/crud/includes/_delete_form.html.twig', {entity_id: entity.primaryKeyValue}, with_context: false) }}
{{ include('@EasyAdmin/crud/includes/_action_confirmation_modal.html.twig', with_context: false) }}
{% endblock delete_form %}
{% endblock %}
26 changes: 26 additions & 0 deletions templates/crud/includes/_action_confirmation_modal.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# Hidden form for POST-based actions (like DELETE) with CSRF protection #}
<form class="d-none" method="post" id="action-confirmation-form">
{% guard function csrf_token %}
<input type="hidden" name="token" value="{{ csrf_token('ea-delete') }}" />
{% endguard %}
</form>

<div id="modal-action-confirmation" class="modal fade" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
<h4 id="action-confirmation-title">{{ 'action_confirmation_modal.title'|trans([], 'EasyAdminBundle') }}</h4>
</div>
<div class="modal-footer">
<twig:ea:Button type="button" data-bs-dismiss="modal">
{{ 'action.cancel'|trans([], 'EasyAdminBundle') }}
</twig:ea:Button>

<twig:ea:Button type="button" variant="danger" data-bs-dismiss="modal" id="modal-action-confirmation-button">
{{ 'action_confirmation_modal.action'|trans([], 'EasyAdminBundle') }}
</twig:ea:Button>
</div>
</div>
</div>
</div>
Loading