Skip to content

Commit 1593cc4

Browse files
committed
feature #6599 Allow to compute action label dynamically with a callable (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
2 parents 2307da5 + d1e8742 commit 1593cc4

File tree

6 files changed

+233
-7
lines changed

6 files changed

+233
-7
lines changed

doc/actions.rst

+58
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,64 @@ and EasyAdmin passes the action to it automatically::
122122
;
123123
}
124124

125+
Generating Dynamic Action Labels
126+
--------------------------------
127+
128+
Action labels can be dynamically generated based on the related entity they
129+
belong to. For example, an ``Invoice`` entity can be paid with multiple payments.
130+
On the top of each ``Invoice`` details page, administrators want to have an action
131+
link (or button) that brings them to a custom page that shows the received payments
132+
for that invoice. In order to provide a better user experience, the action link
133+
(or button) label must display the current number of received payments
134+
(i.e: ``3 payments``)::
135+
136+
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
137+
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
138+
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
139+
140+
public function configureActions(Actions $actions): Actions
141+
{
142+
$viewPayments = Action::new('payments')
143+
->setLabel(function (Invoice $invoice)) {
144+
return \count($invoice->getPayments()) . ' payments';
145+
});
146+
147+
// in PHP 7.4 and newer you can use arrow functions
148+
// ->setLabel(fn (Invoice $invoice) => \count($invoice->getPayments()) . ' payments')
149+
150+
return $actions
151+
// ...
152+
->add(Crud::PAGE_DETAIL, $viewPayments);
153+
}
154+
155+
When the related entity object isn't enough for computing the action label,
156+
then any more specific service object can be used as a delegator. For example,
157+
a Doctrine repository service object can be used for counting the related number
158+
of payments for the administrated invoice::
159+
160+
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
161+
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
162+
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
163+
164+
private InvoicePaymentRepository $invoicePaymentRepository;
165+
166+
public function __construct(InvoicePaymentRepository $invoicePaymentRepository)
167+
{
168+
$this->invoicePaymentRepository = $invoicePaymentRepository;
169+
}
170+
171+
public function configureActions(Actions $actions): Actions
172+
{
173+
$viewPayments = Action::new('payments')
174+
->setLabel(function (Invoice $invoice)) {
175+
return $this->invoicePaymentRepository->countByInvoice($invoice) . ' payments';
176+
});
177+
178+
return $actions
179+
// ...
180+
->add(Crud::PAGE_DETAIL, $viewPayments);
181+
}
182+
125183
Displaying Actions Conditionally
126184
--------------------------------
127185

src/Config/Action.php

+8-6
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,14 @@ public function __toString()
4242
}
4343

4444
/**
45-
* @param TranslatableInterface|string|false|null $label Use FALSE to hide the label; use NULL to autogenerate it
46-
* @param string|null $icon The full CSS classes of the FontAwesome icon to render (see https://fontawesome.com/v6/search?m=free)
45+
* @param TranslatableInterface|string|(callable(object $entity): string)|false|null $label Use FALSE to hide the label; use NULL to autogenerate it
46+
* @param string|null $icon The full CSS classes of the FontAwesome icon to render (see https://fontawesome.com/v6/search?m=free)
4747
*/
4848
public static function new(string $name, $label = null, ?string $icon = null): self
4949
{
5050
if (!\is_string($label)
5151
&& !$label instanceof TranslatableInterface
52+
&& !\is_callable($label)
5253
&& false !== $label
5354
&& null !== $label) {
5455
trigger_deprecation(
@@ -57,7 +58,7 @@ public static function new(string $name, $label = null, ?string $icon = null): s
5758
'Argument "%s" for "%s" must be one of these types: %s. Passing type "%s" will cause an error in 5.0.0.',
5859
'$label',
5960
__METHOD__,
60-
sprintf('"%s", "string", "false" or "null"', TranslatableInterface::class),
61+
sprintf('"%s", "string", "callable", "false" or "null"', TranslatableInterface::class),
6162
\gettype($label)
6263
);
6364
}
@@ -89,12 +90,13 @@ public function createAsBatchAction(): self
8990
}
9091

9192
/**
92-
* @param TranslatableInterface|string|false|null $label Use FALSE to hide the label; use NULL to autogenerate it
93+
* @param TranslatableInterface|string|(callable(object $entity): string)|false|null $label Use FALSE to hide the label; use NULL to autogenerate it
9394
*/
9495
public function setLabel($label): self
9596
{
9697
if (!\is_string($label)
9798
&& !$label instanceof TranslatableInterface
99+
&& !\is_callable($label)
98100
&& false !== $label
99101
&& null !== $label) {
100102
trigger_deprecation(
@@ -103,7 +105,7 @@ public function setLabel($label): self
103105
'Argument "%s" for "%s" must be one of these types: %s. Passing type "%s" will cause an error in 5.0.0.',
104106
'$label',
105107
__METHOD__,
106-
'"string", "false" or "null"',
108+
sprintf('"%s", "string", "callable", "false" or "null"', TranslatableInterface::class),
107109
\gettype($label)
108110
);
109111
}
@@ -229,7 +231,7 @@ public function displayIf(callable $callable): self
229231

230232
public function getAsDto(): ActionDto
231233
{
232-
if (null === $this->dto->getLabel() && null === $this->dto->getIcon()) {
234+
if ((!$this->dto->isDynamicLabel() && null === $this->dto->getLabel()) && null === $this->dto->getIcon()) {
233235
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()));
234236
}
235237

src/Dto/ActionDto.php

+41-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ final class ActionDto
1313
private ?string $type = null;
1414
private ?string $name = null;
1515
private TranslatableInterface|string|null $label = null;
16+
17+
/**
18+
* @var (callable(object): string)|null
19+
*/
20+
private $labelCallable;
21+
1622
private ?string $icon = null;
1723
private string $cssClass = '';
1824
private string $addedCssClass = '';
@@ -63,13 +69,28 @@ public function setName(string $name): void
6369
$this->name = $name;
6470
}
6571

72+
public function isDynamicLabel(): bool
73+
{
74+
return \is_callable($this->labelCallable);
75+
}
76+
6677
public function getLabel(): TranslatableInterface|string|false|null
6778
{
6879
return $this->label;
6980
}
7081

71-
public function setLabel(TranslatableInterface|string|false|null $label): void
82+
/**
83+
* @param TranslatableInterface|string|(callable(object $entity): string)|false|null $label
84+
*/
85+
public function setLabel(TranslatableInterface|string|callable|false|null $label): void
7286
{
87+
if (\is_callable($label)) {
88+
$this->labelCallable = $label;
89+
$this->label = null;
90+
91+
return;
92+
}
93+
7394
$this->label = $label;
7495
}
7596

@@ -261,6 +282,25 @@ public function setDisplayCallable(callable $displayCallable): void
261282
$this->displayCallable = $displayCallable;
262283
}
263284

285+
public function computeLabel(EntityDto $entityDto): void
286+
{
287+
if (null !== $this->label) {
288+
return;
289+
}
290+
291+
if (!\is_callable($this->labelCallable)) {
292+
return;
293+
}
294+
295+
$label = \call_user_func_array($this->labelCallable, array_filter([$entityDto->getInstance()]));
296+
297+
if (!\is_string($label) && !$label instanceof TranslatableInterface) {
298+
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)));
299+
}
300+
301+
$this->label = $label;
302+
}
303+
264304
/**
265305
* @internal
266306
*/

src/Factory/ActionFactory.php

+2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ public function processEntityActions(EntityDto $entityDto, ActionConfigDto $acti
4949
continue;
5050
}
5151

52+
$actionDto->computeLabel($entityDto);
53+
5254
// if CSS class hasn't been overridden, apply the default ones
5355
if ('' === $actionDto->getCssClass()) {
5456
$defaultCssClass = 'action-'.$actionDto->getName();

tests/Config/ActionTest.php

+54
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,39 @@
33
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Config;
44

55
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
6+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
67
use PHPUnit\Framework\TestCase;
78

89
class ActionTest extends TestCase
910
{
11+
public function testStringLabelForStaticLabelGeneration()
12+
{
13+
$actionConfig = Action::new(Action::DELETE)
14+
->setLabel('Delete Me!')
15+
->linkToCrudAction('');
16+
17+
$this->assertSame('Delete Me!', $actionConfig->getAsDto()->getLabel());
18+
}
19+
20+
public function testCallableLabelForDynamicLabelGeneration()
21+
{
22+
$callable = static function (object $entity) {
23+
return sprintf('Delete %s', $entity);
24+
};
25+
26+
$actionConfig = Action::new(Action::DELETE)
27+
->setLabel($callable)
28+
->linkToCrudAction('');
29+
30+
$dto = $actionConfig->getAsDto();
31+
32+
$this->assertNull($dto->getLabel());
33+
34+
$dto->computeLabel($this->getEntityDto('1337'));
35+
36+
$this->assertSame('Delete #1337', $dto->getLabel());
37+
}
38+
1039
public function testDefaultCssClass()
1140
{
1241
$actionConfig = Action::new(Action::DELETE)->linkToCrudAction('');
@@ -50,4 +79,29 @@ public function testSetAndAddCssClassWithSpaces()
5079
$this->assertSame('foo1 foo2', $actionConfig->getAsDto()->getCssClass());
5180
$this->assertSame('bar1 bar2', $actionConfig->getAsDto()->getAddedCssClass());
5281
}
82+
83+
private function getEntityDto(string $entityId): EntityDto
84+
{
85+
$entityDtoMock = $this->createMock(EntityDto::class);
86+
$entityDtoMock
87+
->expects($this->any())
88+
->method('getInstance')
89+
->willReturn(
90+
new class($entityId) {
91+
private $entityId;
92+
93+
public function __construct(string $entityId)
94+
{
95+
$this->entityId = $entityId;
96+
}
97+
98+
public function __toString(): string
99+
{
100+
return sprintf('#%s', $this->entityId);
101+
}
102+
}
103+
);
104+
105+
return $entityDtoMock;
106+
}
53107
}

tests/Dto/ActionDtoTest.php

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Dto;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Dto\ActionDto;
6+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
7+
use PHPUnit\Framework\TestCase;
8+
9+
final class ActionDtoTest extends TestCase
10+
{
11+
public function testComputeLabelFromStaticLabel()
12+
{
13+
$actionDto = new ActionDto();
14+
$actionDto->setLabel('Edit');
15+
16+
$actionDto->computeLabel($this->getEntityDto('42'));
17+
18+
$this->assertSame('Edit', $actionDto->getLabel());
19+
}
20+
21+
public function testComputeLabelFromDynamicLabelCallable()
22+
{
23+
$actionDto = new ActionDto();
24+
$actionDto->setLabel(static function (object $entity) {
25+
return sprintf('Edit %s', $entity);
26+
});
27+
28+
$actionDto->computeLabel($this->getEntityDto('1337'));
29+
30+
$this->assertSame('Edit #1337', $actionDto->getLabel());
31+
}
32+
33+
public function testComputeLabelFailsWithInvalidCallableReturnValueType()
34+
{
35+
$actionDto = new ActionDto();
36+
$actionDto->setLabel(static function (object $entity) {
37+
return 12345;
38+
});
39+
40+
$this->expectException(\RuntimeException::class);
41+
$this->expectExceptionMessage('Action label callable must return a string or a Symfony\Contracts\Translation\TranslatableInterface instance but it returned a(n) "integer" value instead.');
42+
43+
$actionDto->computeLabel($this->getEntityDto('1337'));
44+
}
45+
46+
private function getEntityDto(string $entityId): EntityDto
47+
{
48+
$entityDtoMock = $this->createMock(EntityDto::class);
49+
$entityDtoMock
50+
->expects($this->any())
51+
->method('getInstance')
52+
->willReturn(
53+
new class($entityId) {
54+
private $entityId;
55+
56+
public function __construct(string $entityId)
57+
{
58+
$this->entityId = $entityId;
59+
}
60+
61+
public function __toString(): string
62+
{
63+
return sprintf('#%s', $this->entityId);
64+
}
65+
}
66+
);
67+
68+
return $entityDtoMock;
69+
}
70+
}

0 commit comments

Comments
 (0)