Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/actions-and-cells.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/smoke-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
21 changes: 21 additions & 0 deletions src/Enum/ActionDisplayMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Zhortein\DatatableBundle\Enum;

enum ActionDisplayMode: string
{
case Inline = 'inline';
case Dropdown = 'dropdown';
case List = 'list';

public static function fromNullableString(?string $mode): self
{
if (null === $mode || '' === trim($mode)) {
return self::Inline;
}

return self::tryFrom(strtolower(trim($mode))) ?? self::Inline;
}
}
18 changes: 18 additions & 0 deletions src/Renderer/DatatableRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Zhortein\DatatableBundle\Definition\ActionDefinition;
use Zhortein\DatatableBundle\Definition\ColumnDefinition;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Enum\ActionDisplayMode;
use Zhortein\DatatableBundle\Enum\CellType;
use Zhortein\DatatableBundle\Result\DatatableResult;

Expand Down Expand Up @@ -56,6 +57,7 @@ public function render(DatatableDefinition $definition, array $options = []): st
'hasRowActions' => [] !== $definition->getRowActions(),
'htmlId' => $this->createHtmlId($definition),
'options' => $options,
'rowActionDisplayMode' => $this->resolveRowActionDisplayMode($definition, $options)->value,
]);
}

Expand Down Expand Up @@ -298,6 +300,22 @@ private function normalizeAction(ActionDefinition $action, string $url): array
];
}

/**
* @param array<string, mixed> $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) {
Expand Down
23 changes: 16 additions & 7 deletions templates/bootstrap/_actions.html.twig
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
{% set resolved_row_action_display_mode = rowActionDisplayMode ?? 'inline' %}
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
{% for action in actions %}
{% include '@ZhorteinDatatable/bootstrap/_action.html.twig' with {
action: action
} only %}
{% endfor %}
</div>
{% 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 %}
</td>
3 changes: 2 additions & 1 deletion templates/bootstrap/_body.html.twig
Original file line number Diff line number Diff line change
@@ -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 %}
55 changes: 55 additions & 0 deletions templates/bootstrap/_dropdown_action.html.twig
Original file line number Diff line number Diff line change
@@ -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' %}
<a
href="{{ action.url }}"
class="dropdown-item"
data-turbo-prefetch="false"
{% if action.confirmationMessage is not null %}
data-zhortein--datatable-bundle--datatable-confirmation-message="{{ action.confirmationMessage }}"
data-action="click->zhortein--datatable-bundle--datatable#confirmAction"
{% endif %}
{% for attribute_name, attribute_value in action.attributes %}
{{ attribute_name }}="{{ attribute_value }}"
{% endfor %}
>
{% if has_icon and icon_position == 'before' %}
<span class="{{ action.icon }} me-1" aria-hidden="true"></span>
{% endif %}
<span>{{ label }}</span>
{% if has_icon and icon_position == 'after' %}
<span class="{{ action.icon }} ms-1" aria-hidden="true"></span>
{% endif %}
</a>
{% else %}
<form
method="post"
action="{{ action.url }}"
{% if action.confirmationMessage is not null %}
data-zhortein--datatable-bundle--datatable-confirmation-message="{{ action.confirmationMessage }}"
data-action="submit->zhortein--datatable-bundle--datatable#confirmAction"
{% endif %}
>
<input type="hidden" name="_method" value="{{ action.httpMethod }}">
{% if action.csrfToken is not null %}
<input type="hidden" name="_token" value="{{ action.csrfToken }}">
{% endif %}
<button
type="submit"
class="dropdown-item"
{% for attribute_name, attribute_value in action.attributes %}
{{ attribute_name }}="{{ attribute_value }}"
{% endfor %}
>
{% if has_icon and icon_position == 'before' %}
<span class="{{ action.icon }} me-1" aria-hidden="true"></span>
{% endif %}
<span>{{ label }}</span>
{% if has_icon and icon_position == 'after' %}
<span class="{{ action.icon }} ms-1" aria-hidden="true"></span>
{% endif %}
</button>
</form>
{% endif %}
2 changes: 1 addition & 1 deletion templates/bootstrap/_header.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@

{% if hasRowActions %}
<th class="text-end" scope="col">
{{ 'zhortein_datatable.actions'|trans({}, 'zhortein_datatable') }}
{{ 'zhortein_datatable.actions.more'|trans({}, 'zhortein_datatable') }}
</th>
{% endif %}
</tr>
Expand Down
3 changes: 2 additions & 1 deletion templates/bootstrap/_row.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
</tr>
19 changes: 19 additions & 0 deletions templates/bootstrap/_row_actions_dropdown.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% if actions is not empty %}
{% set dropdown_id = htmlId ~ '_row_actions_' ~ rowIndex %}
<div class="dropdown zhortein-datatable__row-actions-dropdown">
<button
class="btn btn-sm btn-outline-secondary dropdown-toggle"
type="button"
id="{{ dropdown_id }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{{ 'zhortein_datatable.actions.more'|trans({}, 'zhortein_datatable') }}
</button>
<div class="dropdown-menu dropdown-menu-end" aria-labelledby="{{ dropdown_id }}">
{% for action in actions %}
{{ include('@ZhorteinDatatable/bootstrap/_dropdown_action.html.twig', {action: action}, with_context=false) }}
{% endfor %}
</div>
</div>
{% endif %}
7 changes: 7 additions & 0 deletions templates/bootstrap/_row_actions_inline.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% if actions is not empty %}
<div class="btn-group btn-group-sm" role="group" aria-label="{{ 'zhortein_datatable.actions.row'|trans({}, 'zhortein_datatable') }}">
{% for action in actions %}
{{ include('@ZhorteinDatatable/bootstrap/_action.html.twig', {action: action}, with_context=false) }}
{% endfor %}
</div>
{% endif %}
7 changes: 7 additions & 0 deletions templates/bootstrap/_row_actions_list.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% if actions is not empty %}
<div class="d-flex flex-column gap-1 zhortein-datatable__row-actions-list">
{% for action in actions %}
{{ include('@ZhorteinDatatable/bootstrap/_action.html.twig', {action: action}, with_context=false) }}
{% endfor %}
</div>
{% endif %}
26 changes: 26 additions & 0 deletions tests/Unit/Enum/ActionDisplayModeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Zhortein\DatatableBundle\Tests\Unit\Enum;

use PHPUnit\Framework\TestCase;
use Zhortein\DatatableBundle\Enum\ActionDisplayMode;

final class ActionDisplayModeTest extends TestCase
{
public function test_it_creates_display_mode_from_valid_string(): void
{
self::assertSame(ActionDisplayMode::Inline, ActionDisplayMode::fromNullableString('inline'));
self::assertSame(ActionDisplayMode::Dropdown, ActionDisplayMode::fromNullableString('dropdown'));
self::assertSame(ActionDisplayMode::List, ActionDisplayMode::fromNullableString('list'));
self::assertSame(ActionDisplayMode::Dropdown, ActionDisplayMode::fromNullableString(' DROPDOWN '));
}

public function test_it_falls_back_to_inline_for_null_empty_or_unknown_value(): void
{
self::assertSame(ActionDisplayMode::Inline, ActionDisplayMode::fromNullableString(null));
self::assertSame(ActionDisplayMode::Inline, ActionDisplayMode::fromNullableString(''));
self::assertSame(ActionDisplayMode::Inline, ActionDisplayMode::fromNullableString('unknown'));
}
}
2 changes: 1 addition & 1 deletion tests/Unit/Translation/TranslationCatalogTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function test_english_catalog_contains_builtin_messages(): void
self::assertSame('Loading...', $translator->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'));
Expand Down
6 changes: 4 additions & 2 deletions translations/zhortein_datatable.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.'
filtered_multiple: 'Showing %start% to %end% of %filtered% results, filtered from %total% total.'
4 changes: 3 additions & 1 deletion translations/zhortein_datatable.fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down