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
23 changes: 23 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,29 @@ There is no theme registry, Tailwind theme, CSS asset package or icon provider a

The current strategy and limitations are documented in `docs/theming.md`.

### Configurable datatable control layout

Datatable controls support a layout option.

Current modes:

```text
default
split
```

Default layout keeps page size and column visibility controls in the top toolbar.

Split layout keeps search, filters, export and global actions near the top, while moving page size, column visibility and summary below the table.

Example:

```twig
{{ zhortein_datatable('users', {
controlsLayout: 'split'
}) }}
```

---

## 9. Action rendering layer
Expand Down
18 changes: 18 additions & 0 deletions docs/table-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,24 @@ The default fragments URL uses the bundle route.

Applications can override the fragments URL at runtime, but route prefix configuration is not implemented yet.

## Control layout

The datatable controls can use a split layout.

```twig
{{ zhortein_datatable('users', {
controlsLayout: 'split'
}) }}
```

Split layout moves these controls below the table:

- column visibility;
- page size selector;
- summary.

Search, filters, export and global actions remain in the top toolbar.

## Related documentation

- [`stimulus-assetmapper.md`](stimulus-assetmapper.md)
Expand Down
44 changes: 44 additions & 0 deletions templates/bootstrap/_bottom_controls.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{% set page_size = options.pageSize is defined ? options.pageSize : 25 %}
{% set page_size_selector_enabled = options.pageSizeSelector is defined ? options.pageSizeSelector : true %}
{% set allowed_page_sizes = options.allowedPageSizes is defined ? options.allowedPageSizes : [10, 25, 50, 100] %}
{% set column_visibility_enabled = options.columnVisibility is defined ? options.columnVisibility : true %}
{% set runtime_visible_columns = options.visibleColumns is defined ? options.visibleColumns : [] %}
{% set runtime_hidden_columns = options.hiddenColumns is defined ? options.hiddenColumns : [] %}

<div class="zhortein-datatable__bottom-controls d-flex flex-wrap justify-content-between align-items-center gap-3 mt-2">
<div class="d-flex flex-wrap align-items-center gap-2">
{% if column_visibility_enabled %}
{% include '@ZhorteinDatatable/bootstrap/_column_visibility.html.twig' with {
definition: definition,
htmlId: htmlId,
runtime_visible_columns: runtime_visible_columns,
runtime_hidden_columns: runtime_hidden_columns
} only %}
{% endif %}

{% if page_size_selector_enabled %}
<label class="text-body-secondary small" for="{{ htmlId }}_page_size">
{{ 'zhortein_datatable.page_size.label'|trans({}, 'zhortein_datatable') }}
</label>
<select
id="{{ htmlId }}_page_size"
class="form-select form-select-sm w-auto"
aria-label="{{ 'zhortein_datatable.page_size.label'|trans({}, 'zhortein_datatable') }}"
data-zhortein--datatable-bundle--datatable-target="pageSizeInput"
data-action="change->zhortein--datatable-bundle--datatable#changePageSize"
>
{% for page_size_option in allowed_page_sizes %}
<option value="{{ page_size_option }}"{% if page_size_option == page_size %} selected{% endif %}>
{{ page_size_option }}
</option>
{% endfor %}
</select>
{% endif %}
</div>

<div
class="zhortein-datatable__summary text-body-secondary small ms-auto text-end"
aria-live="polite"
data-zhortein--datatable-bundle--datatable-target="summary"
></div>
</div>
5 changes: 3 additions & 2 deletions templates/bootstrap/_toolbar.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
{% set runtime_visible_columns = options.visibleColumns is defined ? options.visibleColumns : [] %}
{% set runtime_hidden_columns = options.hiddenColumns is defined ? options.hiddenColumns : [] %}
{% set export_enabled = options.export is defined ? options.export : true %}
{% set is_split_layout = controlsLayout is defined and controlsLayout == 'split' %}

<div class="zhortein-datatable__toolbar d-flex flex-wrap justify-content-between align-items-end gap-3 mb-3">
<div class="zhortein-datatable__search">
Expand Down Expand Up @@ -54,7 +55,7 @@
</div>

<div class="zhortein-datatable__controls d-flex flex-wrap align-items-center gap-2">
{% if column_visibility_enabled %}
{% if not is_split_layout and column_visibility_enabled %}
{% include '@ZhorteinDatatable/bootstrap/_column_visibility.html.twig' with {
definition: definition,
htmlId: htmlId,
Expand All @@ -70,7 +71,7 @@
} only %}
{% endif %}

{% if page_size_selector_enabled %}
{% if not is_split_layout and page_size_selector_enabled %}
<label class="text-body-secondary small" for="{{ htmlId }}_page_size">
{{ 'zhortein_datatable.page_size.label'|trans({}, 'zhortein_datatable') }}
</label>
Expand Down
40 changes: 27 additions & 13 deletions templates/bootstrap/datatable.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
{% set page_size = options.pageSize is defined ? options.pageSize : 25 %}
{% set sort_field = options.sortField is defined ? options.sortField : '' %}
{% set sort_direction = options.sortDirection is defined ? options.sortDirection : 'asc' %}
{% set auto_load = options.autoLoad is defined ? options.autoLoad : true %}
{% set controls_layout = options.controlsLayout is defined ? options.controlsLayout : 'default' %}
{% set table_striped = options.tableStriped is defined ? options.tableStriped : true %}
{% set table_hover = options.tableHover is defined ? options.tableHover : true %}
{% set table_bordered = options.tableBordered is defined ? options.tableBordered : false %}
{% set table_borderless = options.tableBorderless is defined ? options.tableBorderless : false %}
{% set table_small = options.tableSmall is defined ? options.tableSmall : false %}
{% set table_responsive = options.tableResponsive is defined ? options.tableResponsive : true %}
{% set table_classes = ['table', 'align-middle', 'mb-0'] %}
{% set auto_load = options.autoLoad is defined ? options.autoLoad : true %}

{% if table_striped %}
{% set table_classes = table_classes|merge(['table-striped']) %}
Expand Down Expand Up @@ -46,23 +47,28 @@
data-zhortein--datatable-bundle--datatable-auto-load-value="{{ auto_load ? 'true' : 'false' }}"
aria-busy="false"
>
{% include '@ZhorteinDatatable/bootstrap/_toolbar.html.twig' %}
{% include '@ZhorteinDatatable/bootstrap/_toolbar.html.twig' with {
definition: definition,
htmlId: htmlId,
globalActions: globalActions,
options: options,
controlsLayout: controls_layout
} only %}

<div
class="alert alert-danger d-none align-items-center gap-2"
role="alert"
aria-live="polite"
aria-hidden="true"
data-zhortein--datatable-bundle--datatable-target="error"
hidden
></div>

<div
class="zhortein-datatable__loading d-none align-items-center gap-2 text-body-secondary small mb-2"
role="status"
aria-live="polite"
aria-hidden="true"
data-zhortein--datatable-bundle--datatable-target="loading"
class="zhortein-datatable__loading d-none align-items-center gap-2 text-body-secondary small mb-2"
role="status"
aria-live="polite"
aria-hidden="true"
data-zhortein--datatable-bundle--datatable-target="loading"
>
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span>{{ 'zhortein_datatable.loading'|trans({}, 'zhortein_datatable') }}</span>
Expand All @@ -78,11 +84,19 @@
</table>
</div>

<div
class="zhortein-datatable__summary text-body-secondary small mt-2"
aria-live="polite"
data-zhortein--datatable-bundle--datatable-target="summary"
></div>
{% if controls_layout == 'split' %}
{% include '@ZhorteinDatatable/bootstrap/_bottom_controls.html.twig' with {
definition: definition,
htmlId: htmlId,
options: options
} only %}
{% else %}
<div
class="zhortein-datatable__summary text-body-secondary small mt-2"
aria-live="polite"
data-zhortein--datatable-bundle--datatable-target="summary"
></div>
{% endif %}

{% include '@ZhorteinDatatable/bootstrap/_pagination.html.twig' %}
</div>
131 changes: 131 additions & 0 deletions tests/Unit/Renderer/DatatableRendererControlLayoutTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace Zhortein\DatatableBundle\Tests\Unit\Renderer;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RequestContext;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Renderer\DatatableRenderer;

final class DatatableRendererControlLayoutTest extends TestCase
{
use TranslatableRendererTestTrait;

public function test_default_layout_keeps_page_size_selector_in_toolbar(): void
{
$html = $this->createRenderer()->render($this->createDefinition());

self::assertStringContainsString('zhortein-datatable__toolbar', $html);
self::assertStringNotContainsString('zhortein-datatable__bottom-controls', $html);
self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="pageSizeInput"', $html);
}

public function test_split_layout_moves_page_size_and_column_visibility_to_bottom_controls(): void
{
$html = $this->createRenderer()->render($this->createDefinition(), [
'controlsLayout' => 'split',
]);

self::assertStringContainsString('zhortein-datatable__bottom-controls', $html);
self::assertStringContainsString('zhortein-datatable__column-visibility', $html);
self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="pageSizeInput"', $html);
self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="summary"', $html);
}

public function test_split_layout_keeps_export_and_global_actions_in_top_toolbar(): void
{
$definition = $this->createDefinition();
$definition->addGlobalAction(
name: 'create',
route: 'app_user_create',
label: 'Create user',
);

$html = $this->createRenderer(new ControlLayoutTestUrlGenerator())->render($definition, [
'controlsLayout' => 'split',
]);

self::assertStringContainsString('zhortein-datatable__toolbar', $html);
self::assertStringContainsString('CSV current view', $html);
self::assertStringContainsString('Create user', $html);
}

public function test_split_layout_can_disable_bottom_controls(): void
{
$html = $this->createRenderer()->render($this->createDefinition(), [
'controlsLayout' => 'split',
'columnVisibility' => false,
'pageSizeSelector' => false,
]);

self::assertStringContainsString('zhortein-datatable__bottom-controls', $html);
self::assertStringNotContainsString('zhortein-datatable__column-visibility', $html);
self::assertStringNotContainsString('data-zhortein--datatable-bundle--datatable-target="pageSizeInput"', $html);
}

private function createDefinition(): DatatableDefinition
{
$definition = new DatatableDefinition('users');

$definition
->addColumn('e.email', label: 'Email')
->addColumn('e.displayName', label: 'Display name')
;

return $definition;
}

private function createRenderer(?UrlGeneratorInterface $urlGenerator = null): DatatableRenderer
{
return new DatatableRenderer(
twig: $this->createTwigEnvironment(),
urlGenerator: $urlGenerator,
);
}

private function createTwigEnvironment(): Environment
{
$loader = new FilesystemLoader();
$loader->addPath(__DIR__.'/../../../templates', 'ZhorteinDatatable');

$twig = new Environment($loader, [
'strict_variables' => true,
'autoescape' => 'html',
]);

$this->addTranslationExtension($twig);

return $twig;
}
}

final class ControlLayoutTestUrlGenerator implements UrlGeneratorInterface
{
/**
* @param array<mixed> $parameters
*/
public function generate(
string $name,
array $parameters = [],
int $referenceType = self::ABSOLUTE_PATH,
): string {
return match ($name) {
'app_user_create' => '/users/create',
default => '/'.$name,
};
}

public function setContext(RequestContext $context): void
{
}

public function getContext(): RequestContext
{
return new RequestContext();
}
}