Skip to content

Commit fe185bb

Browse files
committed
Allow to group actions into dropdowns
1 parent 4e877c9 commit fe185bb

33 files changed

+2078
-162
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Welcome, 🤖 AI assistant! Please follow these guidelines when contributing to
8484
- Services configuration must use PHP format (`config/services.php`)
8585
- Translations must be in PHP format (`translations/*.php`)
8686
- Handle exceptions explicitly and avoid silent catch blocks
87+
- In tests, use simple descriptive names like 'Action 1', 'Action 2', 'Group 1', etc. instead of realistic examples
8788

8889
## Twig Templates
8990

assets/css/easyadmin-theme/base.css

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -575,12 +575,15 @@ a.user-menu-wrapper .user-details:hover {
575575
.content-header .datagrid-filters {
576576
margin-inline-end: 10px;
577577
}
578+
.content-header .global-actions,
578579
.content-header .page-actions {
579580
justify-content: right;
580581
flex-wrap: wrap;
581-
row-gap: 1em;
582+
gap: 10px;
582583
display: flex;
583584
flex-direction: row;
585+
}
586+
.content-header .page-actions {
584587
margin: 10px 0 15px;
585588
}
586589
@media (min-width: 768px) {
@@ -592,14 +595,6 @@ a.user-menu-wrapper .user-details:hover {
592595
display: none;
593596
}
594597

595-
.content-header .page-actions :is(.btn, form:has(.btn)) + :is(.btn, form:has(.btn)) {
596-
margin-inline-start: 10px;
597-
}
598-
599-
.content-header .page-actions .btn-group .btn + .btn {
600-
margin-inline-start: 0;
601-
}
602-
603598
.batch-actions form {
604599
display: flex;
605600
}
@@ -718,6 +713,9 @@ a.user-menu-wrapper .user-details:hover {
718713
max-inline-size: 240px;
719714
padding: 5px;
720715
}
716+
.dropdown-menu.dropdown-has-submenus {
717+
padding-inline-start: 25px;
718+
}
721719
.dropdown-menu li {
722720
border-radius: var(--border-radius);
723721
}
@@ -785,6 +783,16 @@ a.user-menu-wrapper .user-details:hover {
785783
outline: none;
786784
padding: 0 4px;
787785
}
786+
.dropdown-menu .dropdown-submenu .dropdown-item.dropdown-toggle {
787+
border: 0;
788+
display: flex;
789+
padding: 0 12px 0 6px;
790+
position: relative;
791+
}
792+
.dropdown-menu .dropdown-submenu .dropdown-item.dropdown-toggle:not(.dropdown-toggle-split):hover {
793+
/* unlike the toggle split, this dropdown item is not clickable, so make that very clear */
794+
cursor: default;
795+
}
788796

789797
.list-pagination {
790798
background: var(--table-footer-bg);

assets/css/easyadmin-theme/buttons.css

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -261,43 +261,18 @@ fieldset:disabled a.btn {
261261
}
262262

263263
/* Button groups */
264-
.btn-group {
265-
display: inline-flex;
266-
position: relative;
267-
vertical-align: middle;
268-
}
269-
270-
.btn-group > .btn {
271-
flex: 1 1 auto;
272-
position: relative;
264+
.btn-group > .btn-group:not(:first-child),
265+
.btn-group > :not(.btn-check:first-child) + .btn {
266+
margin-inline-start: 0;
273267
}
274-
275-
.btn-group > .btn:not(:first-child) {
276-
border-start-start-radius: 0;
277-
border-end-start-radius: 0;
268+
.btn-group > .btn.dropdown-toggle.dropdown-toggle-split {
278269
margin-inline-start: -1px;
270+
padding-inline-end: .5625rem;
271+
padding-inline-start: .5625rem;
279272
}
280-
281-
.btn-group > .btn:not(:last-child) {
282-
border-start-end-radius: 0;
283-
border-end-end-radius: 0;
284-
}
285-
286-
/* Dropdown toggle for split buttons */
287-
.btn-group > .dropdown-toggle-split {
288-
--button-padding-x: calc(var(--button-padding-x-md) * 0.75);
289-
}
290-
291-
.btn-group > .btn-sm + .dropdown-toggle-split {
292-
--button-padding-x: calc(var(--button-padding-x-sm) * 0.75);
293-
}
294-
295-
.btn-group > .btn-lg + .dropdown-toggle-split {
296-
--button-padding-x: calc(var(--button-padding-x-lg) * 0.75);
297-
}
298-
299-
.dropdown-toggle-split::after {
300-
margin-inline-start: 0;
273+
.dropdown-menu .dropdown-submenu .dropdown-toggle.dropdown-toggle-split {
274+
border: 0;
275+
inline-size: auto;
301276
}
302277

303278
/* Block buttons (full width) */

assets/css/easyadmin-theme/datagrids.css

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ table.datagrid:not(.datagrid-empty) tr:not(.empty-row) td.actions.actions-as-dro
179179
font-size: var(--font-size-base);
180180
margin-inline-end: 2px;
181181
}
182-
.datagrid td.actions .dropdown-item-variant-danger:hover {
182+
.datagrid td.actions .dropdown-item-variant-danger:hover,
183+
.page-actions .dropdown-item-variant-danger:hover {
183184
--dropdown-icon-color: var(--dropdown-item-danger-color);
184185

185186
background: var(--dropdown-item-danger-bg);
@@ -194,13 +195,14 @@ table.datagrid:not(.datagrid-empty) tr:not(.empty-row) td.actions.actions-as-dro
194195
.datagrid td.actions-as-dropdown-table-head {
195196
inline-size: 10px;
196197
}
197-
.datagrid tr:not(.selected-row):hover .dropdown-toggle {
198+
.datagrid tr:not(.selected-row):hover .actions-as-dropdown .dropdown-actions > .dropdown-toggle {
198199
background: var(--dropdown-toggle-bg);
199200
border-color: var(--dropdown-toggle-border-color);
200201
}
201-
.datagrid tr:hover .dropdown-toggle:hover {
202+
.datagrid tr:hover .actions-as-dropdown .dropdown-actions > .dropdown-toggle:hover {
202203
border-color: var(--dropdown-toggle-hover-border-color);
203204
}
205+
204206
.datagrid tr:hover .dropdown-toggle:focus,
205207
.datagrid tr:hover .dropdown-toggle:active,
206208
.datagrid tr:hover .dropdown-toggle:active:focus,
@@ -224,6 +226,7 @@ table.datagrid:not(.datagrid-empty) tr:not(.empty-row) td.actions.actions-as-dro
224226
color: var(--dropdown-toggle-color);
225227
display: block;
226228
padding: 1px 5px;
229+
overflow: visible;
227230
}
228231

229232
.datagrid .dropdown-actions .dropdown-toggle .icon {
@@ -236,6 +239,69 @@ table.datagrid:not(.datagrid-empty) tr:not(.empty-row) td.actions.actions-as-dro
236239
z-index: var(--zindex-900);
237240
}
238241

242+
/* Nested dropdown menus (action groups) - always open to the left for better UX */
243+
.datagrid .dropdown-actions .dropdown-menu .dropstart {
244+
position: relative;
245+
}
246+
247+
.datagrid .dropdown-actions .dropstart .dropdown-toggle:before {
248+
margin-inline-start: -20px;
249+
position: absolute;
250+
}
251+
252+
/* Position nested dropdown menu to the left */
253+
.datagrid .dropdown-actions .dropdown-menu .dropstart > .dropdown-menu {
254+
inset-block-start: 0;
255+
inset-inline-end: 100%;
256+
inset-inline-start: auto;
257+
margin-block-end: 0;
258+
margin-block-start: 0;
259+
margin-inline-end: -0.125rem;
260+
margin-inline-start: 0;
261+
}
262+
.datagrid .dropdown-actions .dropdown-menu .dropstart:has(.dropdown-toggle-split) > .dropdown-menu {
263+
margin-inline-end: 1.125rem;
264+
}
265+
266+
/* Submenus without toggle-split */
267+
.datagrid .dropdown-actions .dropdown-menu .dropstart:not(:has(.dropdown-toggle-split)):hover > .dropdown-menu,
268+
.datagrid
269+
.dropdown-actions
270+
.dropdown-menu
271+
.dropstart:not(:has(.dropdown-toggle-split))
272+
> .dropdown-toggle:focus
273+
.dropdown-menu,
274+
.datagrid .dropdown-actions .dropdown-menu .dropstart:not(:has(.dropdown-toggle-split)) > .dropdown-menu:hover,
275+
/* Submenus with toggle-split */
276+
.datagrid
277+
.dropdown-actions
278+
.dropdown-menu
279+
.dropstart:has(.dropdown-toggle-split:hover)
280+
> .dropdown-menu,
281+
.datagrid .dropdown-actions .dropdown-menu .dropstart:has(.dropdown-toggle-split) > .dropdown-menu:hover {
282+
display: block;
283+
}
284+
285+
/* Split button submenu styling */
286+
.datagrid .dropdown-actions .dropdown-menu .dropstart .dropdown-toggle-split {
287+
padding-inline-start: 0.5rem;
288+
padding-inline-end: 0.5rem;
289+
position: absolute;
290+
inset-inline-start: -22px;
291+
}
292+
.datagrid .dropdown-actions .dropdown-menu .dropstart .dropdown-toggle-split:before {
293+
display: none;
294+
}
295+
296+
.datagrid .dropdown-actions .dropdown-menu .dropstart .dropdown-toggle-split .dropdown-toggle-marker {
297+
/* this is a chevron-like icon made out of text with CSS border tricks */
298+
border-block-end: .3em solid transparent;
299+
border-inline-end: .3em solid;
300+
border-block-start: .3em solid transparent;
301+
content: "";
302+
display: inline-block;
303+
}
304+
239305
.datagrid .ea-lightbox-thumbnail img {
240306
background: var(--white);
241307
border: 1px solid transparent;

doc/actions.rst

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,157 @@ contents on each row. If you prefer to display all the actions *inline*
332332
}
333333
}
334334

335+
Grouping Actions
336+
----------------
337+
338+
In addition to individual actions, you can group multiple related actions into
339+
a single button. This is useful when you have many actions and want to organize
340+
them better or save space in the interface. Use the ``ActionGroup`` class
341+
to create these grouped actions::
342+
343+
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
344+
use EasyCorp\Bundle\EasyAdminBundle\Config\ActionGroup;
345+
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
346+
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
347+
348+
public function configureActions(Actions $actions): Actions
349+
{
350+
$publishActions = ActionGroup::new('publish', 'Publish')
351+
->addAction(Action::new('publishNow', 'Publish Now')->linkToCrudAction('...'))
352+
->addAction(Action::new('schedule', 'Schedule...')->linkToCrudAction('...'))
353+
->addAction(Action::new('publishDraft', 'Save as Draft')->linkToCrudAction('...'));
354+
355+
return $actions
356+
// ...
357+
->add(Crud::PAGE_EDIT, $publishActions)
358+
;
359+
}
360+
361+
This is how the action group looks in practice:
362+
363+
.. image:: images/easyadmin-action-group.gif
364+
:alt: An action group that includes three different actions into a single button
365+
366+
Similar to standalone actions, on the index page there are two types of action
367+
groups: those associated with each entity and those associated with the entire page::
368+
369+
public function configureActions(Actions $actions): Actions
370+
{
371+
$createActions = ActionGroup::new('create')
372+
->createAsGlobalActionGroup()
373+
->addAction(Action::new('new', 'Create Post')->linkToCrudAction('...'))
374+
->addAction(Action::new('draft', 'Draft Post')->linkToCrudAction('...'))
375+
->addAction(Action::new('template', 'Create from Template')->linkToCrudAction('...'));
376+
377+
$sendActions = ActionGroup::new('send', 'Send ...')
378+
->addAction(Action::new('sendEmail', 'Send by Email')->linkToCrudAction('...'))
379+
->addAction(Action::new('sendSlack', 'Send to Slack')->linkToCrudAction('...'))
380+
->addAction(Action::new('sendTelegram', 'Send via Telegram')->linkToCrudAction('...'));
381+
382+
return $actions
383+
// ...
384+
->add(Crud::PAGE_INDEX, $createActions)
385+
->add(Crud::PAGE_INDEX, $sendActions)
386+
;
387+
}
388+
389+
The ``createAsGlobalActionGroup()`` method creates an action group associated
390+
with the entire page rather than any specific entity. It appears like the image
391+
shown above for action groups.
392+
393+
When not using the ``createAsGlobalActionGroup()`` method on the index page, the
394+
action group is displayed as a nested dropdown on each entity row (see the image
395+
in the next section below).
396+
397+
Split Button Dropdowns
398+
~~~~~~~~~~~~~~~~~~~~~~
399+
400+
If one of the grouped actions is more common than the others, you can render the
401+
group as a "split button". This displays the **main action** as a clickable button,
402+
with the other actions available in the dropdown::
403+
404+
$publishActions = ActionGroup::new('publish', 'Publish')
405+
->addMainAction(Action::new('publishNow', 'Publish Now')->linkToCrudAction('...'))
406+
->addAction(Action::new('schedule', 'Schedule...')->linkToCrudAction('...'))
407+
->addAction(Action::new('publishDraft', 'Save as Draft')->linkToCrudAction('...'));
408+
409+
Now, the action group will look as follows:
410+
411+
.. image:: images/easyadmin-action-group-split-button.gif
412+
:alt: An action group that defines a main action and a list of secondary actions
413+
414+
On the index page, if the action group is associated with each entity, it's
415+
displayed as a dropdown. In the following image, the first action group is a
416+
simple dropdown because it doesn't define a main action. The second action
417+
group is a split dropdown, where the main action is a clickable element and the
418+
remaining actions appear when hovering over the submenu marker:
419+
420+
.. image:: images/easyadmin-action-group-entity-dropdown.gif
421+
:alt: An action group inside an entity dropdown. The second group defines a main action.
422+
423+
Headers and Dividers
424+
~~~~~~~~~~~~~~~~~~~~
425+
426+
For better organization, especially with many actions in a dropdown, you can add
427+
headers and dividers to create logical groups::
428+
429+
$actionsGroup = ActionGroup::new('actions', 'Actions', 'fa fa-cog')
430+
->addHeader('Quick Actions')
431+
->addAction(Action::new('approve', 'Approve')->linkToCrudAction('approve'))
432+
->addAction(Action::new('reject', 'Reject')->linkToCrudAction('reject'))
433+
->addDivider()
434+
->addHeader('Advanced')
435+
->addAction(Action::new('archive', 'Archive')->linkToCrudAction('archive'))
436+
->addAction(Action::new('delete', 'Delete')->linkToCrudAction('delete')
437+
->addCssClass('text-danger'));
438+
439+
Headers help users understand the purpose of each group, while dividers provide
440+
visual separation between different sections.
441+
442+
Conditional Dropdown Display
443+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
444+
445+
Like regular actions, dropdowns can be displayed conditionally based on the
446+
entity state or user permissions::
447+
448+
$moderationGroup = ActionGroup::new('moderation', 'Moderation')
449+
// the callable receives the current entity instance or null (in the index page)
450+
->displayIf(static function ($entity) {
451+
return null !== $entity && 'pending' === $entity->getStatus();
452+
})
453+
->addAction(Action::new('approve', 'Approve')->linkToCrudAction('approve'))
454+
->addAction(Action::new('reject', 'Reject')->linkToCrudAction('reject'));
455+
456+
The dropdown will only appear when the condition is met. Individual actions
457+
within the dropdown can also have their own display conditions.
458+
459+
Customizing Dropdown Appearance
460+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
461+
462+
Dropdowns support the same customization options as regular actions for styling
463+
and HTML attributes::
464+
465+
$customGroup = ActionGroup::new('custom', 'Options')
466+
// use only an icon, no label
467+
->setLabel(false)
468+
->setIcon('fa fa-ellipsis-v')
469+
470+
// create different variants of action groups
471+
->asPrimaryActionGroup()
472+
->asDefaultActionGroup()
473+
->asSuccessActionGroup()
474+
->asWarningActionGroup()
475+
->asDangerActionGroup()
476+
477+
// add custom CSS classes
478+
->addCssClass('my-custom-dropdown')
479+
480+
// add HTML attributes
481+
->setHtmlAttributes(['data-foo' => 'bar']);
482+
483+
You can also customize individual actions within the dropdown using the standard
484+
action configuration methods.
485+
335486
.. _actions-custom:
336487

337488
Adding Custom Actions
@@ -414,6 +565,14 @@ that will represent the action::
414565
// useful when customizing a built-in action, which already has CSS classes)
415566
->addCssClass('some-custom-css-class text-danger')
416567

568+
This is how the different button style variants look in light and dark mode:
569+
570+
.. image:: images/easyadmin-buttons-light-mode.gif
571+
:alt: EasyAdmin button variants in light mode
572+
573+
.. image:: images/easyadmin-buttons-dark-mode.gif
574+
:alt: EasyAdmin button variants in dark mode
575+
417576
.. note::
418577

419578
When using ``setCssClass()`` or ``addCssClass()`` methods, the action loses
118 KB
Loading
43.3 KB
Loading
38.1 KB
Loading
108 KB
Loading
104 KB
Loading

0 commit comments

Comments
 (0)