From 7f3306c312e61890ca18d7963ea4c8f466571952 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 21 Aug 2025 15:27:48 +0200 Subject: [PATCH 1/3] Add the possibility to create async datatable --- docs/src/docs/features/asynchronicity.md | 30 +++++++++++++++++++++ src/DataTableConfigBuilder.php | 5 ++++ src/DataTableConfigInterface.php | 2 ++ src/Resources/views/themes/base.html.twig | 32 ++++++++++++++--------- src/Type/DataTableType.php | 4 +++ 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/docs/src/docs/features/asynchronicity.md b/docs/src/docs/features/asynchronicity.md index 33b9b2f7..c05a037f 100644 --- a/docs/src/docs/features/asynchronicity.md +++ b/docs/src/docs/features/asynchronicity.md @@ -85,6 +85,36 @@ Notes: - Turbo sends the Turbo-Frame header with the frame id; the bundle reads it for you. You don't need to access headers directly. - The trait requires Twig to be available in your controller service (it is auto-wired by Symfony via the #[Required] setter). +## Asynchronous Data Table Loading + +You can enable asynchronous loading for your data tables using the `async` option. This feature is especially useful for tables with slow data sources or when displaying multiple tables on a single page. + +### Enabling Asynchronous Loading + +To enable asynchronous loading, set the async option to true in your data table type: +```php +class ProductDataTableType extends AbstractDataTableType +{ + // ... + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'async' => true, + ]); + } +} +``` + +With this option enabled, the data table will not load its content immediately when the page is rendered. Instead, it will trigger a backend request to fetch and display the table data. + +### How It Works + +- When 'async' is enabled, the table's initial HTML does not include its data rows. +- The table content is loaded asynchronously via a backend call after the page loads. +- If the data table is not visible in the user's viewport, its content is not loaded until the user scrolls to it. This optimizes performance, especially on pages with multiple or heavy tables. +- If you need the table to load immediately regardless of visibility, keep async set to false (the default). + + ## Prefetching diff --git a/src/DataTableConfigBuilder.php b/src/DataTableConfigBuilder.php index 4e65af54..3178d7b3 100755 --- a/src/DataTableConfigBuilder.php +++ b/src/DataTableConfigBuilder.php @@ -876,4 +876,9 @@ private function createBuilderLockedException(): BadMethodCallException { return new BadMethodCallException('DataTableConfigBuilder methods cannot be accessed anymore once the builder is turned into a DataTableConfigInterface instance.'); } + + public function isAsync(): bool + { + return $this->options['async']; + } } diff --git a/src/DataTableConfigInterface.php b/src/DataTableConfigInterface.php index c78eba6a..ffebcbce 100755 --- a/src/DataTableConfigInterface.php +++ b/src/DataTableConfigInterface.php @@ -134,4 +134,6 @@ public function getFiltrationParameterName(): string; public function getPersonalizationParameterName(): string; public function getExportParameterName(): string; + + public function isAsync(): bool; } diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index 53dcf297..d4faedc9 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -9,19 +9,27 @@ {% set stimulus_controllers = stimulus_controllers|merge(['kreyu--data-table-bundle--batch']) %} {% endif %} - -
- {{ block('action_bar', theme) }} - {{ block('table', theme) }} + {% if data_table.vars.is_async and not data_table.vars.is_request_from_turbo_frame %} + +
+ {{ 'Loading'|trans({}, 'KreyuDataTable') }} +
+
+ {% else %} + +
+ {{ block('action_bar', theme) }} + {{ block('table', theme) }} - {% if pagination_enabled %} - {{ data_table_pagination(pagination) }} - {% endif %} -
-
+ {% if pagination_enabled %} + {{ data_table_pagination(pagination) }} + {% endif %} +
+
+ {% endif %} {% endblock %} {% block kreyu_data_table_form_aware %} diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index e76e935a..960cbc67 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -107,6 +107,8 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar 'sorting_clearable' => $dataTable->getConfig()->isSortingClearable(), 'has_batch_actions' => !empty($dataTable->getBatchActions()), 'per_page_choices' => $options['per_page_choices'], + 'is_request_from_turbo_frame' => $dataTable->isRequestFromTurboFrame(), + 'is_async' => $dataTable->getConfig()->isAsync(), ]); $view->headerRow = $this->createHeaderRowView($view, $dataTable, $visibleColumns); @@ -186,6 +188,7 @@ public function configureOptions(OptionsResolver $resolver): void 'personalization_form_factory' => $this->defaults['personalization']['form_factory'] ?? null, 'exporting_enabled' => $this->defaults['exporting']['enabled'] ?? false, 'exporting_form_factory' => $this->defaults['exporting']['form_factory'] ?? null, + 'async' => $this->defaults['async'] ?? false, ]) ->setAllowedTypes('title', ['null', 'string', TranslatableInterface::class]) ->setAllowedTypes('title_translation_parameters', ['array']) @@ -217,6 +220,7 @@ public function configureOptions(OptionsResolver $resolver): void ->setAllowedTypes('personalization_form_factory', ['null', FormFactoryInterface::class]) ->setAllowedTypes('exporting_enabled', 'bool') ->setAllowedTypes('exporting_form_factory', ['null', FormFactoryInterface::class]) + ->setAllowedTypes('async', ['bool']) ; } From 23904889e62132246cf92f3468c03c185681081f Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 21 Aug 2025 16:56:36 +0200 Subject: [PATCH 2/3] Enhance async data handling by returning an empty result set for AJAX requests --- src/DataTable.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/DataTable.php b/src/DataTable.php index 24d4a1a6..9fc402f5 100755 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -38,6 +38,7 @@ use Kreyu\Bundle\DataTableBundle\Personalization\Form\Type\PersonalizationDataType; use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData; use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; +use Kreyu\Bundle\DataTableBundle\Query\ResultSet; use Kreyu\Bundle\DataTableBundle\Query\ResultSetInterface; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Symfony\Component\Form\FormBuilderInterface; @@ -670,7 +671,16 @@ public function getPagination(): PaginationInterface private function getResultSet(): ResultSetInterface { - return $this->resultSet ??= $this->query->getResult(); + if ( + !$this->config->isAsync() + || $this->isRequestFromTurboFrame() + ) { + return $this->resultSet ??= $this->query->getResult(); + } + + // In this case, we don't want to fetch the results immediately, + // but rather return an empty result set that will be filled later through AJAX. + return new ResultSet(new \ArrayIterator([]), 0, 0); } private function createPagination(): PaginationInterface From 5d2de37b5e7c42c82a66168ddfe273884a4df5f3 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 16 Apr 2026 17:17:25 +0200 Subject: [PATCH 3/3] Fix async export bug, add global config, tests and documentation --- docs/src/docs/features/asynchronicity.md | 36 ++++++++++++-- src/DataTable.php | 8 ++- src/DependencyInjection/Configuration.php | 3 ++ tests/Unit/DataTableTest.php | 60 +++++++++++++++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/docs/src/docs/features/asynchronicity.md b/docs/src/docs/features/asynchronicity.md index ea1d0880..b0e5160d 100644 --- a/docs/src/docs/features/asynchronicity.md +++ b/docs/src/docs/features/asynchronicity.md @@ -87,11 +87,21 @@ Notes: ## Asynchronous Data Table Loading -You can enable asynchronous loading for your data tables using the `async` option. This feature is especially useful for tables with slow data sources or when displaying multiple tables on a single page. +You can enable asynchronous loading for your data tables using the `async` option. This feature relies on [Turbo Frames lazy loading](https://turbo.hotwired.dev/reference/frames#lazy-loaded-frame) and requires [Symfony UX Turbo](https://symfony.com/bundles/ux-turbo/current/index.html) to be installed. It is especially useful for tables with slow data sources or when displaying multiple tables on a single page. ### Enabling Asynchronous Loading -To enable asynchronous loading, set the async option to true in your data table type: +To enable asynchronous loading globally, add the following to your configuration: + +```yaml +# config/packages/kreyu_data_table.yaml +kreyu_data_table: + defaults: + async: true +``` + +Or enable it per data table type: + ```php class ProductDataTableType extends AbstractDataTableType { @@ -109,10 +119,26 @@ With this option enabled, the data table will not load its content immediately w ### How It Works -- When 'async' is enabled, the table's initial HTML does not include its data rows. -- The table content is loaded asynchronously via a backend call after the page loads. +- When `async` is enabled, the table's initial HTML does not include its data rows. +- The table content is loaded asynchronously via a Turbo Frame lazy request after the page loads. - If the data table is not visible in the user's viewport, its content is not loaded until the user scrolls to it. This optimizes performance, especially on pages with multiple or heavy tables. -- If you need the table to load immediately regardless of visibility, keep async set to false (the default). +- If you need the table to load immediately regardless of visibility, keep `async` set to `false` (the default). + +### Recommended: Using `DataTableTurboResponseTrait` + +When using `async`, the lazy Turbo Frame request loads the full page and extracts only the matching ``. For better performance, use the `DataTableTurboResponseTrait` in your controller to return only the table's HTML when the request comes from a Turbo Frame: + +```php +if ($dataTable->isRequestFromTurboFrame()) { + return $this->createDataTableTurboResponse($dataTable); +} +``` + +See the [Server-side responses for Turbo Frames](#server-side-responses-for-turbo-frames) section above for a full example. + +### Limitations + +- The async loading mechanism uses a GET request to `app.request.uri`. If the page is served via POST, the POST parameters will not be included in the async request. ## Prefetching diff --git a/src/DataTable.php b/src/DataTable.php index c950f1a2..b0559e82 100755 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -113,6 +113,7 @@ class DataTable implements DataTableInterface private ?ResultSetInterface $resultSet = null; private bool $initialized = false; + private bool $exporting = false; private ?string $turboFrameId = null; public function __construct( @@ -628,6 +629,7 @@ public function export(?ExportData $data = null): ExportFile } $dataTable = clone $this; + $dataTable->exporting = true; $data ??= $this->exportData ?? $this->config->getDefaultExportData() ?? ExportData::fromDataTable($this); @@ -674,12 +676,14 @@ private function getResultSet(): ResultSetInterface if ( !$this->config->isAsync() || $this->isRequestFromTurboFrame() + || $this->exporting ) { return $this->resultSet ??= $this->query->getResult(); } - // In this case, we don't want to fetch the results immediately, - // but rather return an empty result set that will be filled later through AJAX. + // Return an empty result set: the real data will be loaded via a Turbo Frame lazy request. + // Not memoized intentionally — each call within the same request creates a new empty instance, + // which is inconsequential since the async and Turbo Frame paths are separate HTTP requests. return new ResultSet(new \ArrayIterator([]), 0, 0); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e1ec2e20..a735114e 100755 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -51,6 +51,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('request_handler') ->defaultValue('kreyu_data_table.request_handler.http_foundation') ->end() + ->booleanNode('async') + ->defaultFalse() + ->end() ->arrayNode('sorting') ->addDefaultsIfNotSet() ->children() diff --git a/tests/Unit/DataTableTest.php b/tests/Unit/DataTableTest.php index 22ef1fe4..d9cada84 100644 --- a/tests/Unit/DataTableTest.php +++ b/tests/Unit/DataTableTest.php @@ -7,10 +7,13 @@ use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData; use Kreyu\Bundle\DataTableBundle\Test\DataTableIntegrationTestCase; +use Kreyu\Bundle\DataTableBundle\Tests\ReflectionTrait; use Kreyu\Bundle\DataTableBundle\Type\DataTableType; class DataTableTest extends DataTableIntegrationTestCase { + use ReflectionTrait; + public function testGetColumns() { $dataTable = $this->createDataTableBuilder() @@ -354,8 +357,65 @@ public function testGetExportableColumnsIgnoresDisabledPersonalization() $this->assertEquals(['third', 'fourth', 'first'], $columns); } + public function testGetItemsReturnsEmptyWhenAsyncAndNotTurboFrame() + { + $dataTable = $this->createDataTableBuilderWithData( + [['id' => 1], ['id' => 2]], + ['async' => true], + )->getDataTable(); + + $items = iterator_to_array($dataTable->getItems()); + + $this->assertEmpty($items); + } + + public function testGetItemsReturnsDataWhenAsyncAndTurboFrame() + { + $dataTable = $this->createDataTableBuilderWithData( + [['id' => 1], ['id' => 2]], + ['async' => true], + )->getDataTable(); + + $dataTable->setTurboFrameId('kreyu_data_table_'.$dataTable->getName()); + + $items = iterator_to_array($dataTable->getItems()); + + $this->assertCount(2, $items); + } + + public function testGetItemsReturnsDataWhenNotAsync() + { + $dataTable = $this->createDataTableBuilderWithData( + [['id' => 1], ['id' => 2]], + ['async' => false], + )->getDataTable(); + + $items = iterator_to_array($dataTable->getItems()); + + $this->assertCount(2, $items); + } + + public function testGetItemsReturnsDataWhenAsyncAndExporting() + { + $dataTable = $this->createDataTableBuilderWithData( + [['id' => 1], ['id' => 2]], + ['async' => true], + )->getDataTable(); + + $this->setPrivatePropertyValue($dataTable, 'exporting', true); + + $items = iterator_to_array($dataTable->getItems()); + + $this->assertCount(2, $items); + } + private function createDataTableBuilder(array $options = []): DataTableBuilderInterface { return $this->dataTableFactory->createBuilder(DataTableType::class, [], $options); } + + private function createDataTableBuilderWithData(array $data, array $options = []): DataTableBuilderInterface + { + return $this->dataTableFactory->createBuilder(DataTableType::class, $data, $options); + } }