Skip to content

Commit

Permalink
feature #6599 Allow to compute action label dynamically with a callab…
Browse files Browse the repository at this point in the history
…le (hhamon)

This PR was merged into the 4.x branch.

Discussion
----------

Allow to compute action label dynamically with a callable

The goal of this MR is to allow computing dynamic label when adding new custom actions at the top of an entity page (i.e. edit, details, etc.).

For instance, I have an entity model on which I can list and add internal notes. At the top of my entity model details page, I've configured a new custom action that points to another controller that enables to view and add internal notes.

For a sake of improved user experience, I want the action label to display the number of related internal notes that have been added for the entity model I'm currently administrating.

<img width="379" alt="Screenshot 2024-11-28 at 20 32 57" src="https://github.com/user-attachments/assets/55082738-92f9-4d9e-ac2d-767e977b9de7">

<img width="373" alt="Screenshot 2024-11-28 at 20 36 40" src="https://github.com/user-attachments/assets/6e9703cb-02d1-4c18-a6e5-826bcebb181b">

The current implementation only enables to define a static string label for an action. Using a similar approach to the `->displayIf()`, the `Action::new()` and `Action::setLabel()` methods now supports receiving a callable that will be evaluated later by the `ActionFactory` service.

The callable receives the entity model instance and is evaluated only once. The computed label gets stored in the `ActionDto::$label` property automatically.

The good part of using the callable is that the label can be dynamically computed thanks to:

1. The received entity model instance
2. Any extra parameters imported/used by the `Closure` object
3. Any injected service object that the `Closure` has access to within its scope

WDYT?

Commits
-------

d1e8742 Allow to compute action label dynamically with a callable
  • Loading branch information
javiereguiluz committed Dec 2, 2024
2 parents 2307da5 + d1e8742 commit 1593cc4
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 7 deletions.
58 changes: 58 additions & 0 deletions doc/actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,64 @@ and EasyAdmin passes the action to it automatically::
;
}

Generating Dynamic Action Labels
--------------------------------

Action labels can be dynamically generated based on the related entity they
belong to. For example, an ``Invoice`` entity can be paid with multiple payments.
On the top of each ``Invoice`` details page, administrators want to have an action
link (or button) that brings them to a custom page that shows the received payments
for that invoice. In order to provide a better user experience, the action link
(or button) label must display the current number of received payments
(i.e: ``3 payments``)::

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

public function configureActions(Actions $actions): Actions
{
$viewPayments = Action::new('payments')
->setLabel(function (Invoice $invoice)) {
return \count($invoice->getPayments()) . ' payments';
});

// in PHP 7.4 and newer you can use arrow functions
// ->setLabel(fn (Invoice $invoice) => \count($invoice->getPayments()) . ' payments')

return $actions
// ...
->add(Crud::PAGE_DETAIL, $viewPayments);
}

When the related entity object isn't enough for computing the action label,
then any more specific service object can be used as a delegator. For example,
a Doctrine repository service object can be used for counting the related number
of payments for the administrated invoice::

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

private InvoicePaymentRepository $invoicePaymentRepository;

public function __construct(InvoicePaymentRepository $invoicePaymentRepository)
{
$this->invoicePaymentRepository = $invoicePaymentRepository;
}

public function configureActions(Actions $actions): Actions
{
$viewPayments = Action::new('payments')
->setLabel(function (Invoice $invoice)) {
return $this->invoicePaymentRepository->countByInvoice($invoice) . ' payments';
});

return $actions
// ...
->add(Crud::PAGE_DETAIL, $viewPayments);
}

Displaying Actions Conditionally
--------------------------------

Expand Down
14 changes: 8 additions & 6 deletions src/Config/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ public function __toString()
}

/**
* @param TranslatableInterface|string|false|null $label Use FALSE to hide the label; use NULL to autogenerate it
* @param string|null $icon The full CSS classes of the FontAwesome icon to render (see https://fontawesome.com/v6/search?m=free)
* @param TranslatableInterface|string|(callable(object $entity): string)|false|null $label Use FALSE to hide the label; use NULL to autogenerate it
* @param string|null $icon The full CSS classes of the FontAwesome icon to render (see https://fontawesome.com/v6/search?m=free)
*/
public static function new(string $name, $label = null, ?string $icon = null): self
{
if (!\is_string($label)
&& !$label instanceof TranslatableInterface
&& !\is_callable($label)
&& false !== $label
&& null !== $label) {
trigger_deprecation(
Expand All @@ -57,7 +58,7 @@ public static function new(string $name, $label = null, ?string $icon = null): s
'Argument "%s" for "%s" must be one of these types: %s. Passing type "%s" will cause an error in 5.0.0.',
'$label',
__METHOD__,
sprintf('"%s", "string", "false" or "null"', TranslatableInterface::class),
sprintf('"%s", "string", "callable", "false" or "null"', TranslatableInterface::class),
\gettype($label)
);
}
Expand Down Expand Up @@ -89,12 +90,13 @@ public function createAsBatchAction(): self
}

/**
* @param TranslatableInterface|string|false|null $label Use FALSE to hide the label; use NULL to autogenerate it
* @param TranslatableInterface|string|(callable(object $entity): string)|false|null $label Use FALSE to hide the label; use NULL to autogenerate it
*/
public function setLabel($label): self
{
if (!\is_string($label)
&& !$label instanceof TranslatableInterface
&& !\is_callable($label)
&& false !== $label
&& null !== $label) {
trigger_deprecation(
Expand All @@ -103,7 +105,7 @@ public function setLabel($label): self
'Argument "%s" for "%s" must be one of these types: %s. Passing type "%s" will cause an error in 5.0.0.',
'$label',
__METHOD__,
'"string", "false" or "null"',
sprintf('"%s", "string", "callable", "false" or "null"', TranslatableInterface::class),
\gettype($label)
);
}
Expand Down Expand Up @@ -229,7 +231,7 @@ public function displayIf(callable $callable): self

public function getAsDto(): ActionDto
{
if (null === $this->dto->getLabel() && null === $this->dto->getIcon()) {
if ((!$this->dto->isDynamicLabel() && null === $this->dto->getLabel()) && null === $this->dto->getIcon()) {
throw new \InvalidArgumentException(sprintf('The label and icon of an action cannot be null at the same time. Either set the label, the icon or both for the "%s" action.', $this->dto->getName()));
}

Expand Down
42 changes: 41 additions & 1 deletion src/Dto/ActionDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ final class ActionDto
private ?string $type = null;
private ?string $name = null;
private TranslatableInterface|string|null $label = null;

/**
* @var (callable(object): string)|null
*/
private $labelCallable;

private ?string $icon = null;
private string $cssClass = '';
private string $addedCssClass = '';
Expand Down Expand Up @@ -63,13 +69,28 @@ public function setName(string $name): void
$this->name = $name;
}

public function isDynamicLabel(): bool
{
return \is_callable($this->labelCallable);
}

public function getLabel(): TranslatableInterface|string|false|null
{
return $this->label;
}

public function setLabel(TranslatableInterface|string|false|null $label): void
/**
* @param TranslatableInterface|string|(callable(object $entity): string)|false|null $label
*/
public function setLabel(TranslatableInterface|string|callable|false|null $label): void
{
if (\is_callable($label)) {
$this->labelCallable = $label;
$this->label = null;

return;
}

$this->label = $label;
}

Expand Down Expand Up @@ -261,6 +282,25 @@ public function setDisplayCallable(callable $displayCallable): void
$this->displayCallable = $displayCallable;
}

public function computeLabel(EntityDto $entityDto): void
{
if (null !== $this->label) {
return;
}

if (!\is_callable($this->labelCallable)) {
return;
}

$label = \call_user_func_array($this->labelCallable, array_filter([$entityDto->getInstance()]));

if (!\is_string($label) && !$label instanceof TranslatableInterface) {
throw new \RuntimeException(sprintf('Action label callable must return a string or a %s instance but it returned a(n) "%s" value instead.', TranslatableInterface::class, \gettype($label)));
}

$this->label = $label;
}

/**
* @internal
*/
Expand Down
2 changes: 2 additions & 0 deletions src/Factory/ActionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public function processEntityActions(EntityDto $entityDto, ActionConfigDto $acti
continue;
}

$actionDto->computeLabel($entityDto);

// if CSS class hasn't been overridden, apply the default ones
if ('' === $actionDto->getCssClass()) {
$defaultCssClass = 'action-'.$actionDto->getName();
Expand Down
54 changes: 54 additions & 0 deletions tests/Config/ActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,39 @@
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Config;

use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use PHPUnit\Framework\TestCase;

class ActionTest extends TestCase
{
public function testStringLabelForStaticLabelGeneration()
{
$actionConfig = Action::new(Action::DELETE)
->setLabel('Delete Me!')
->linkToCrudAction('');

$this->assertSame('Delete Me!', $actionConfig->getAsDto()->getLabel());
}

public function testCallableLabelForDynamicLabelGeneration()
{
$callable = static function (object $entity) {
return sprintf('Delete %s', $entity);
};

$actionConfig = Action::new(Action::DELETE)
->setLabel($callable)
->linkToCrudAction('');

$dto = $actionConfig->getAsDto();

$this->assertNull($dto->getLabel());

$dto->computeLabel($this->getEntityDto('1337'));

$this->assertSame('Delete #1337', $dto->getLabel());
}

public function testDefaultCssClass()
{
$actionConfig = Action::new(Action::DELETE)->linkToCrudAction('');
Expand Down Expand Up @@ -50,4 +79,29 @@ public function testSetAndAddCssClassWithSpaces()
$this->assertSame('foo1 foo2', $actionConfig->getAsDto()->getCssClass());
$this->assertSame('bar1 bar2', $actionConfig->getAsDto()->getAddedCssClass());
}

private function getEntityDto(string $entityId): EntityDto
{
$entityDtoMock = $this->createMock(EntityDto::class);
$entityDtoMock
->expects($this->any())
->method('getInstance')
->willReturn(
new class($entityId) {
private $entityId;

public function __construct(string $entityId)
{
$this->entityId = $entityId;
}

public function __toString(): string
{
return sprintf('#%s', $this->entityId);
}
}
);

return $entityDtoMock;
}
}
70 changes: 70 additions & 0 deletions tests/Dto/ActionDtoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Dto;

use EasyCorp\Bundle\EasyAdminBundle\Dto\ActionDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use PHPUnit\Framework\TestCase;

final class ActionDtoTest extends TestCase
{
public function testComputeLabelFromStaticLabel()
{
$actionDto = new ActionDto();
$actionDto->setLabel('Edit');

$actionDto->computeLabel($this->getEntityDto('42'));

$this->assertSame('Edit', $actionDto->getLabel());
}

public function testComputeLabelFromDynamicLabelCallable()
{
$actionDto = new ActionDto();
$actionDto->setLabel(static function (object $entity) {
return sprintf('Edit %s', $entity);
});

$actionDto->computeLabel($this->getEntityDto('1337'));

$this->assertSame('Edit #1337', $actionDto->getLabel());
}

public function testComputeLabelFailsWithInvalidCallableReturnValueType()
{
$actionDto = new ActionDto();
$actionDto->setLabel(static function (object $entity) {
return 12345;
});

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Action label callable must return a string or a Symfony\Contracts\Translation\TranslatableInterface instance but it returned a(n) "integer" value instead.');

$actionDto->computeLabel($this->getEntityDto('1337'));
}

private function getEntityDto(string $entityId): EntityDto
{
$entityDtoMock = $this->createMock(EntityDto::class);
$entityDtoMock
->expects($this->any())
->method('getInstance')
->willReturn(
new class($entityId) {
private $entityId;

public function __construct(string $entityId)
{
$this->entityId = $entityId;
}

public function __toString(): string
{
return sprintf('#%s', $this->entityId);
}
}
);

return $entityDtoMock;
}
}

0 comments on commit 1593cc4

Please sign in to comment.