Skip to content

Commit ec2e667

Browse files
committed
Allow to define a default action when clicking a grid row
1 parent 5a7d818 commit ec2e667

21 files changed

Lines changed: 679 additions & 17 deletions

AGENTS.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ yarn install # Install JS dependencies
8282
Before submitting changes, run these commands to verify them:
8383

8484
If PHP code changed:
85-
- [ ] `./vendor/bin/phpstan analyse` passes with no errors
86-
- [ ] `php-cs-fixer fix --dry-run` shows no issues
87-
- [ ] Run tests with:
85+
- `./vendor/bin/phpstan analyse` passes with no errors
86+
- `php-cs-fixer fix --dry-run` shows no issues
87+
- Run tests with:
8888
```bash
8989
./vendor/bin/simple-phpunit # All tests
9090
./vendor/bin/simple-phpunit tests/Field/ # Specific directory
@@ -93,18 +93,18 @@ If PHP code changed:
9393
```
9494

9595
If JS/CSS changed:
96-
- [ ] `yarn ci` passes with no errors
97-
- [ ] `yarn biome check --write` shows no issues
98-
- [ ] `make build-assets` completed successfully
96+
- `yarn ci` passes with no errors
97+
- `yarn biome check --write` shows no issues
98+
- `make build-assets` completed successfully
9999

100100
If Twig templates changed:
101-
- [ ] `./vendor/bin/twig-cs-fixer lint templates/` passes
101+
- `./vendor/bin/twig-cs-fixer lint templates/` passes
102102

103103
If documentation changed:
104-
- [ ] `make linter-docs` to validate RST syntax
104+
- `make linter-docs` to validate RST syntax
105105

106106
If translations changed:
107-
- [ ] all locales updated consistently; use English as placeholder if unsure
107+
- all locales updated consistently; use English as placeholder if unsure
108108

109109
## Git and Pull Requests
110110

@@ -167,6 +167,7 @@ make checks-before-pr
167167
- Error messages: concise but precise and actionable (e.g. include class names, file paths)
168168
- Handle exceptions explicitly (no silent catches)
169169
- Config files in PHP format (`config/services.php`, `translations/*.php`)
170+
- Prefer project constants (Action::EDIT, EA::QUERY) over hardcoded strings
170171

171172
### PHPDoc
172173
- No `@return` for void methods
@@ -214,6 +215,7 @@ make checks-before-pr
214215
- Use simple names: 'Action 1', 'Field 1', not realistic data
215216
- Add `void` return type to all test methods
216217
- Name tests descriptively without `test` prefix duplication
218+
- Use `@testWith` and data providers when possible to avoid duplicated tests
217219

218220
### Test Fixtures
219221
- Entity fixtures in `tests/TestApplication/src/Entity/`

assets/css/easyadmin-theme/datagrids.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,21 @@ table.datagrid:not(.datagrid-empty) tr:not(.empty-row) td.actions.actions-as-dro
165165
.datagrid tbody tr.selected-row td ::-moz-selection {
166166
background: transparent;
167167
}
168+
169+
/* clickable rows for default row action */
170+
.datagrid tr.ea-clickable-row {
171+
cursor: pointer;
172+
}
173+
.datagrid tr.ea-clickable-row td.actions,
174+
.datagrid tr.ea-clickable-row td.batch-actions-selector {
175+
cursor: default;
176+
}
177+
.datagrid tr.ea-clickable-row td.actions a,
178+
.datagrid tr.ea-clickable-row td.actions button,
179+
.datagrid tr.ea-clickable-row td.batch-actions-selector .form-check {
180+
cursor: pointer;
181+
}
182+
168183
.datagrid td.actions {
169184
text-align: right;
170185
}

assets/js/app.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class App {
3030
this.#createAutoCompleteFields();
3131
this.#createBatchActions();
3232
this.#createActionConfirmationModals();
33+
this.#createDefaultRowAction();
3334
this.#createPopovers();
3435
this.#createTooltips();
3536
this.#createActionHandlers();
@@ -447,6 +448,85 @@ class App {
447448
});
448449
}
449450

451+
#createDefaultRowAction() {
452+
const clickableRows = document.querySelectorAll('tr.ea-clickable-row[data-default-action-url]');
453+
454+
const interactiveSelectors = [
455+
'a',
456+
'button',
457+
'input',
458+
'select',
459+
'textarea',
460+
'.form-check',
461+
'.dropdown',
462+
'.actions',
463+
'[data-bs-toggle]',
464+
'.btn',
465+
];
466+
467+
const isInteractiveElement = (element) => {
468+
// walk up the DOM tree to check if any ancestor is interactive
469+
// this also handles elements with pointer-events: none whose clicks bubble to parents
470+
let current = element;
471+
while (current && current !== document.body) {
472+
if (interactiveSelectors.some((selector) => current.matches(selector))) {
473+
return true;
474+
}
475+
current = current.parentElement;
476+
}
477+
478+
return false;
479+
};
480+
481+
const navigateToUrl = (url) => {
482+
// create a temporary link and click it to let Turbo (or other libraries) intercept the navigation
483+
const link = document.createElement('a');
484+
link.href = url;
485+
link.style.display = 'none';
486+
document.body.appendChild(link);
487+
link.click();
488+
document.body.removeChild(link);
489+
};
490+
491+
const handleRowActivation = (row, event) => {
492+
// don't navigate if rows are selected (batch mode)
493+
if (row.classList.contains('selected-row')) {
494+
return;
495+
}
496+
497+
const url = row.dataset.defaultActionUrl;
498+
if (url) {
499+
navigateToUrl(url);
500+
}
501+
};
502+
503+
clickableRows.forEach((row) => {
504+
// handle mouse clicks
505+
row.addEventListener('click', (event) => {
506+
if (isInteractiveElement(event.target)) {
507+
return;
508+
}
509+
510+
handleRowActivation(row, event);
511+
});
512+
513+
// handle keyboard navigation (Enter and Space)
514+
row.addEventListener('keydown', (event) => {
515+
if ('Enter' !== event.key && ' ' !== event.key) {
516+
return;
517+
}
518+
519+
// don't activate if focus is on an interactive child element
520+
if (isInteractiveElement(event.target) && event.target !== row) {
521+
return;
522+
}
523+
524+
event.preventDefault();
525+
handleRowActivation(row, event);
526+
});
527+
});
528+
}
529+
450530
#createPopovers() {
451531
document.querySelectorAll('[data-bs-toggle="popover"]').forEach((popoverElement) => {
452532
new bootstrap.Popover(popoverElement);

doc/crud.rst

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,72 @@ Templates and Form Options
509509
;
510510
}
511511

512+
Default Row Action
513+
~~~~~~~~~~~~~~~~~~
514+
515+
By default, when you click on any row of the ``index`` page, you navigate to
516+
the ``edit`` page of that entity. If the ``edit`` action is not available, it
517+
falls back to the ``detail`` action. This behavior is called the "default row action"
518+
and you can configure it with the ``setDefaultRowAction()`` method::
519+
520+
public function configureCrud(Crud $crud): Crud
521+
{
522+
return $crud
523+
// this is the default behavior: first try 'edit', then fallback to 'detail'
524+
->setDefaultRowAction([Action::EDIT, Action::DETAIL])
525+
526+
// use a single action (no fallback)
527+
->setDefaultRowAction(Action::EDIT)
528+
529+
// navigate to 'detail' only
530+
->setDefaultRowAction(Action::DETAIL)
531+
532+
// use any action name, including custom actions
533+
->setDefaultRowAction('review')
534+
535+
// define a custom fallback chain (first available action wins)
536+
->setDefaultRowAction([Action::DETAIL, 'preview', Action::EDIT])
537+
538+
// pass null to disable the row click behavior entirely
539+
->setDefaultRowAction(null)
540+
;
541+
}
542+
543+
.. note::
544+
545+
If none of the configured actions (in the fallback chain) are available for
546+
some entity (disabled action, no permission, or condition not met), the row
547+
won't be clickable for that entity. This also applies to actions defined
548+
inside action groups.
549+
550+
.. tip::
551+
552+
The default row action can be configured globally in your dashboard (so it
553+
applies to all CRUD controllers) and overridden in specific CRUD controllers::
554+
555+
// in your Dashboard
556+
public function configureCrud(): Crud
557+
{
558+
return Crud::new()
559+
// all CRUD controllers will navigate to 'detail' by default
560+
->setDefaultRowAction(Action::DETAIL)
561+
;
562+
}
563+
564+
// in a specific CRUD controller
565+
public function configureCrud(Crud $crud): Crud
566+
{
567+
return $crud
568+
// only this CRUD controller will navigate to 'edit'
569+
->setDefaultRowAction(Action::EDIT)
570+
;
571+
}
572+
573+
The row click behavior is fully accessible via keyboard (using Enter or Space keys).
574+
Clicks on checkboxes, buttons, links, or any action elements within the row won't
575+
trigger the navigation to preserve the expected behavior of those elements.
576+
Also, rows selected in batch mode won't navigate when clicked.
577+
512578
Other Options
513579
~~~~~~~~~~~~~
514580

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/entrypoints.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
"entrypoints": {
33
"app": {
44
"css": [
5-
"/app.897c5adb.css"
5+
"/app.6fe1b7f1.css"
66
],
77
"js": [
8-
"/app.e409b63d.js"
8+
"/app.4cd174dc.js"
99
]
1010
},
1111
"form": {

public/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"app.css": "app.897c5adb.css",
3-
"app.js": "app.e409b63d.js",
2+
"app.css": "app.6fe1b7f1.css",
3+
"app.js": "app.4cd174dc.js",
44
"form.js": "form.5bccac01.js",
55
"page-layout.js": "page-layout.6e9fe55d.js",
66
"page-color-scheme.js": "page-color-scheme.30cb23c2.js",

src/Config/Crud.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,34 @@ public function askConfirmationOnBatchActions(bool|string|TranslatableInterface
450450
return $this;
451451
}
452452

453+
/**
454+
* Sets the action to execute when clicking on a row in the index page.
455+
* Pass a string for a single action, an array for a fallback chain (first available wins),
456+
* or null to disable. By default, it tries [Action::EDIT, Action::DETAIL].
457+
* The action will only work if it's enabled for the CRUD and the user has permission.
458+
*
459+
* @param string|string[]|null $actionName
460+
*/
461+
public function setDefaultRowAction(string|array|null $actionName): self
462+
{
463+
if (\is_string($actionName) && '' === trim($actionName)) {
464+
throw new \InvalidArgumentException('The default row action cannot be an empty string. Use null to disable it.');
465+
}
466+
467+
if (\is_array($actionName)) {
468+
foreach ($actionName as $action) {
469+
/** @phpstan-ignore function.alreadyNarrowedType */
470+
if (!\is_string($action) || '' === trim($action)) {
471+
throw new \InvalidArgumentException('All actions in the fallback chain must be non-empty strings.');
472+
}
473+
}
474+
}
475+
476+
$this->dto->setDefaultRowAction($actionName);
477+
478+
return $this;
479+
}
480+
453481
public function getAsDto(): CrudDto
454482
{
455483
$this->dto->setPaginator(new PaginatorDto($this->paginatorPageSize, $this->paginatorRangeSize, 1, $this->paginatorFetchJoinCollection, $this->paginatorUseOutputWalkers));

0 commit comments

Comments
 (0)