From e68aa4bf8a80944f46f86e620df11970efacc65c Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 11 May 2026 09:52:18 +0200 Subject: [PATCH] feat: implement action display modes --- docs/actions-and-cells.md | 45 +++++++++++++++ docs/configuration.md | 3 +- docs/smoke-test.md | 3 +- src/Enum/ActionDisplayMode.php | 21 +++++++ src/Renderer/DatatableRenderer.php | 18 ++++++ templates/bootstrap/_actions.html.twig | 23 +++++--- templates/bootstrap/_body.html.twig | 3 +- .../bootstrap/_dropdown_action.html.twig | 55 +++++++++++++++++++ templates/bootstrap/_header.html.twig | 2 +- templates/bootstrap/_row.html.twig | 3 +- .../bootstrap/_row_actions_dropdown.html.twig | 19 +++++++ .../bootstrap/_row_actions_inline.html.twig | 7 +++ .../bootstrap/_row_actions_list.html.twig | 7 +++ tests/Unit/Enum/ActionDisplayModeTest.php | 26 +++++++++ .../Translation/TranslationCatalogTest.php | 2 +- translations/zhortein_datatable.en.yaml | 6 +- translations/zhortein_datatable.fr.yaml | 4 +- 17 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 src/Enum/ActionDisplayMode.php create mode 100644 templates/bootstrap/_dropdown_action.html.twig create mode 100644 templates/bootstrap/_row_actions_dropdown.html.twig create mode 100644 templates/bootstrap/_row_actions_inline.html.twig create mode 100644 templates/bootstrap/_row_actions_list.html.twig create mode 100644 tests/Unit/Enum/ActionDisplayModeTest.php diff --git a/docs/actions-and-cells.md b/docs/actions-and-cells.md index 8efe3b0..3fd7aa6 100644 --- a/docs/actions-and-cells.md +++ b/docs/actions-and-cells.md @@ -601,6 +601,50 @@ Icons are rendered as CSS classes only. There is no icon provider abstraction yet. +## Row action display modes + +Row actions can be rendered using different display modes. + +Supported modes: + +```text +inline +dropdown +list +``` + +### Inline mode + +Inline mode is the default. + +It renders actions as a compact Bootstrap button group. + +```php +$definition->setOption('rowActionDisplayMode', 'inline'); +``` + +### Dropdown mode + +Dropdown mode renders row actions inside a Bootstrap dropdown. + +This is useful when a table has several actions and inline buttons would make the row too noisy. + +```php +$definition->setOption('rowActionDisplayMode', 'dropdown'); +``` + +Bootstrap JavaScript must be loaded by the host application. + +### List mode + +List mode renders actions vertically. + +```php +$definition->setOption('rowActionDisplayMode', 'list'); +``` + +This can be useful for custom layouts or narrow responsive displays. + ## Recommended usage Use the current action system for simple back-office actions: @@ -613,3 +657,4 @@ Use the current action system for simple back-office actions: Use custom cell templates when one column needs specific markup. Keep complex permission logic outside the bundle until dedicated visibility/security hooks are introduced. + diff --git a/docs/configuration.md b/docs/configuration.md index 2d1068b..5ceafd8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -290,7 +290,8 @@ zhortein_datatable.search.label zhortein_datatable.search.placeholder zhortein_datatable.loading zhortein_datatable.empty -zhortein_datatable.actions +zhortein_datatable.actions.row +zhortein_datatable.actions.more zhortein_datatable.pagination.label zhortein_datatable.pagination.previous zhortein_datatable.pagination.next diff --git a/docs/smoke-test.md b/docs/smoke-test.md index 5ef9fa5..691eab2 100644 --- a/docs/smoke-test.md +++ b/docs/smoke-test.md @@ -167,7 +167,8 @@ Expected keys include: ```text zhortein_datatable.search.label zhortein_datatable.empty -zhortein_datatable.actions +zhortein_datatable.actions.row +zhortein_datatable.actions.more zhortein_datatable.export.label ``` diff --git a/src/Enum/ActionDisplayMode.php b/src/Enum/ActionDisplayMode.php new file mode 100644 index 0000000..a3a0238 --- /dev/null +++ b/src/Enum/ActionDisplayMode.php @@ -0,0 +1,21 @@ + [] !== $definition->getRowActions(), 'htmlId' => $this->createHtmlId($definition), 'options' => $options, + 'rowActionDisplayMode' => $this->resolveRowActionDisplayMode($definition, $options)->value, ]); } @@ -298,6 +300,22 @@ private function normalizeAction(ActionDefinition $action, string $url): array ]; } + /** + * @param array $options + */ + private function resolveRowActionDisplayMode(DatatableDefinition $definition, array $options): ActionDisplayMode + { + $runtimeMode = $options['rowActionDisplayMode'] ?? null; + + if (is_string($runtimeMode)) { + return ActionDisplayMode::fromNullableString($runtimeMode); + } + + $definitionMode = $definition->getOption('rowActionDisplayMode'); + + return ActionDisplayMode::fromNullableString(is_string($definitionMode) ? $definitionMode : null); + } + private function generateCsrfToken(ActionDefinition $action, string $httpMethod): ?string { if ('GET' === $httpMethod || null === $this->csrfTokenManager) { diff --git a/templates/bootstrap/_actions.html.twig b/templates/bootstrap/_actions.html.twig index 82290ad..e14c303 100644 --- a/templates/bootstrap/_actions.html.twig +++ b/templates/bootstrap/_actions.html.twig @@ -1,9 +1,18 @@ +{% set resolved_row_action_display_mode = rowActionDisplayMode ?? 'inline' %} -
- {% for action in actions %} - {% include '@ZhorteinDatatable/bootstrap/_action.html.twig' with { - action: action - } only %} - {% endfor %} -
+ {% if resolved_row_action_display_mode == 'dropdown' %} + {{ include('@ZhorteinDatatable/bootstrap/_row_actions_dropdown.html.twig', { + actions: actions, + htmlId: htmlId, + rowIndex: row_index + }, with_context=false) }} + {% elseif resolved_row_action_display_mode == 'list' %} + {{ include('@ZhorteinDatatable/bootstrap/_row_actions_list.html.twig', { + actions: actions + }, with_context=false) }} + {% else %} + {{ include('@ZhorteinDatatable/bootstrap/_row_actions_inline.html.twig', { + actions: actions + }, with_context=false) }} + {% endif %} diff --git a/templates/bootstrap/_body.html.twig b/templates/bootstrap/_body.html.twig index a9c48ce..35dc5ad 100644 --- a/templates/bootstrap/_body.html.twig +++ b/templates/bootstrap/_body.html.twig @@ -1,3 +1,4 @@ {% for row in rows %} - {% include '@ZhorteinDatatable/bootstrap/_row.html.twig' with {row: row} only %} + {% set row_index = loop.index %} + {% include '@ZhorteinDatatable/bootstrap/_row.html.twig' with {row: row, row_index: row_index} only %} {% endfor %} diff --git a/templates/bootstrap/_dropdown_action.html.twig b/templates/bootstrap/_dropdown_action.html.twig new file mode 100644 index 0000000..c72090f --- /dev/null +++ b/templates/bootstrap/_dropdown_action.html.twig @@ -0,0 +1,55 @@ +{% set has_icon = action.icon is not null and action.icon is not empty %} +{% set icon_position = action.iconPosition ?? 'before' %} +{% set label = action.label ?? action.name %} + +{% if action.httpMethod == 'GET' %} + + {% if has_icon and icon_position == 'before' %} + + {% endif %} + {{ label }} + {% if has_icon and icon_position == 'after' %} + + {% endif %} + +{% else %} +
+ + {% if action.csrfToken is not null %} + + {% endif %} + +
+{% endif %} diff --git a/templates/bootstrap/_header.html.twig b/templates/bootstrap/_header.html.twig index 3d3a9ea..e9a5e1d 100644 --- a/templates/bootstrap/_header.html.twig +++ b/templates/bootstrap/_header.html.twig @@ -42,7 +42,7 @@ {% if hasRowActions %} - {{ 'zhortein_datatable.actions'|trans({}, 'zhortein_datatable') }} + {{ 'zhortein_datatable.actions.more'|trans({}, 'zhortein_datatable') }} {% endif %} diff --git a/templates/bootstrap/_row.html.twig b/templates/bootstrap/_row.html.twig index a0f1eb4..672e25c 100644 --- a/templates/bootstrap/_row.html.twig +++ b/templates/bootstrap/_row.html.twig @@ -10,7 +10,8 @@ {% if row.actions is not empty %} {% include '@ZhorteinDatatable/bootstrap/_actions.html.twig' with { - actions: row.actions + actions: row.actions, + row_index: row_index } only %} {% endif %} diff --git a/templates/bootstrap/_row_actions_dropdown.html.twig b/templates/bootstrap/_row_actions_dropdown.html.twig new file mode 100644 index 0000000..9dad904 --- /dev/null +++ b/templates/bootstrap/_row_actions_dropdown.html.twig @@ -0,0 +1,19 @@ +{% if actions is not empty %} + {% set dropdown_id = htmlId ~ '_row_actions_' ~ rowIndex %} + +{% endif %} diff --git a/templates/bootstrap/_row_actions_inline.html.twig b/templates/bootstrap/_row_actions_inline.html.twig new file mode 100644 index 0000000..e66b439 --- /dev/null +++ b/templates/bootstrap/_row_actions_inline.html.twig @@ -0,0 +1,7 @@ +{% if actions is not empty %} +
+ {% for action in actions %} + {{ include('@ZhorteinDatatable/bootstrap/_action.html.twig', {action: action}, with_context=false) }} + {% endfor %} +
+{% endif %} diff --git a/templates/bootstrap/_row_actions_list.html.twig b/templates/bootstrap/_row_actions_list.html.twig new file mode 100644 index 0000000..21cf2e6 --- /dev/null +++ b/templates/bootstrap/_row_actions_list.html.twig @@ -0,0 +1,7 @@ +{% if actions is not empty %} +
+ {% for action in actions %} + {{ include('@ZhorteinDatatable/bootstrap/_action.html.twig', {action: action}, with_context=false) }} + {% endfor %} +
+{% endif %} diff --git a/tests/Unit/Enum/ActionDisplayModeTest.php b/tests/Unit/Enum/ActionDisplayModeTest.php new file mode 100644 index 0000000..93132e5 --- /dev/null +++ b/tests/Unit/Enum/ActionDisplayModeTest.php @@ -0,0 +1,26 @@ +trans('zhortein_datatable.loading', [], 'zhortein_datatable')); self::assertSame('Unable to load datatable data.', $translator->trans('zhortein_datatable.error.generic', [], 'zhortein_datatable')); self::assertSame('No data available.', $translator->trans('zhortein_datatable.empty', [], 'zhortein_datatable')); - self::assertSame('Actions', $translator->trans('zhortein_datatable.actions', [], 'zhortein_datatable')); + self::assertSame('Actions', $translator->trans('zhortein_datatable.actions.more', [], 'zhortein_datatable')); self::assertSame('Sort by Email', $translator->trans('zhortein_datatable.sort.label', ['%column%' => 'Email'], 'zhortein_datatable')); self::assertSame('sorted ascending', $translator->trans('zhortein_datatable.sort.sorted_ascending', [], 'zhortein_datatable')); self::assertSame('Previous', $translator->trans('zhortein_datatable.pagination.previous', [], 'zhortein_datatable')); diff --git a/translations/zhortein_datatable.en.yaml b/translations/zhortein_datatable.en.yaml index 3ce575c..deb1762 100644 --- a/translations/zhortein_datatable.en.yaml +++ b/translations/zhortein_datatable.en.yaml @@ -17,7 +17,9 @@ zhortein_datatable: error: generic: 'Unable to load datatable data.' empty: 'No data available.' - actions: 'Actions' + actions: + row: 'Row actions' + more: 'Actions' sort: label: 'Sort by %column%' sorted_ascending: 'sorted ascending' @@ -35,4 +37,4 @@ zhortein_datatable: single: '1 result.' multiple: 'Showing %start% to %end% of %total% results.' filtered_single: '1 result found, filtered from %total% total.' - filtered_multiple: 'Showing %start% to %end% of %filtered% results, filtered from %total% total.' \ No newline at end of file + filtered_multiple: 'Showing %start% to %end% of %filtered% results, filtered from %total% total.' diff --git a/translations/zhortein_datatable.fr.yaml b/translations/zhortein_datatable.fr.yaml index b316eff..3623c46 100644 --- a/translations/zhortein_datatable.fr.yaml +++ b/translations/zhortein_datatable.fr.yaml @@ -17,7 +17,9 @@ zhortein_datatable: error: generic: 'Impossible de charger les données du tableau.' empty: 'Aucune donnée disponible.' - actions: 'Actions' + actions: + row: 'Actions de ligne' + more: 'Actions' sort: label: 'Trier par %column%' sorted_ascending: 'tri croissant'