From 08b9462c675d97ac84051d6d134d196fef210acc Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 11 May 2026 13:54:07 +0200 Subject: [PATCH] feat: implement configurable datatable control layout --- docs/architecture.md | 23 +++ docs/table-controls.md | 18 +++ .../bootstrap/_bottom_controls.html.twig | 44 ++++++ templates/bootstrap/_toolbar.html.twig | 5 +- templates/bootstrap/datatable.html.twig | 40 ++++-- .../DatatableRendererControlLayoutTest.php | 131 ++++++++++++++++++ 6 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 templates/bootstrap/_bottom_controls.html.twig create mode 100644 tests/Unit/Renderer/DatatableRendererControlLayoutTest.php diff --git a/docs/architecture.md b/docs/architecture.md index c9b1cc3..6633f1d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/table-controls.md b/docs/table-controls.md index d293ed9..e92928a 100644 --- a/docs/table-controls.md +++ b/docs/table-controls.md @@ -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) diff --git a/templates/bootstrap/_bottom_controls.html.twig b/templates/bootstrap/_bottom_controls.html.twig new file mode 100644 index 0000000..3baa04b --- /dev/null +++ b/templates/bootstrap/_bottom_controls.html.twig @@ -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 : [] %} + +
+
+ {% 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 %} + + + {% endif %} +
+ +
+
diff --git a/templates/bootstrap/_toolbar.html.twig b/templates/bootstrap/_toolbar.html.twig index df77635..e0724d6 100644 --- a/templates/bootstrap/_toolbar.html.twig +++ b/templates/bootstrap/_toolbar.html.twig @@ -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' %}
- {% 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, @@ -70,7 +71,7 @@ } only %} {% endif %} - {% if page_size_selector_enabled %} + {% if not is_split_layout and page_size_selector_enabled %} diff --git a/templates/bootstrap/datatable.html.twig b/templates/bootstrap/datatable.html.twig index 630de1c..788947b 100644 --- a/templates/bootstrap/datatable.html.twig +++ b/templates/bootstrap/datatable.html.twig @@ -3,6 +3,8 @@ {% 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 %} @@ -10,7 +12,6 @@ {% 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']) %} @@ -46,7 +47,13 @@ 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 %} -
+ {% if controls_layout == 'split' %} + {% include '@ZhorteinDatatable/bootstrap/_bottom_controls.html.twig' with { + definition: definition, + htmlId: htmlId, + options: options + } only %} + {% else %} +
+ {% endif %} {% include '@ZhorteinDatatable/bootstrap/_pagination.html.twig' %}
diff --git a/tests/Unit/Renderer/DatatableRendererControlLayoutTest.php b/tests/Unit/Renderer/DatatableRendererControlLayoutTest.php new file mode 100644 index 0000000..b93a2a1 --- /dev/null +++ b/tests/Unit/Renderer/DatatableRendererControlLayoutTest.php @@ -0,0 +1,131 @@ +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 $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(); + } +}