diff --git a/docs/src/docs/features/asynchronicity.md b/docs/src/docs/features/asynchronicity.md index 517125c8..b0e5160d 100644 --- a/docs/src/docs/features/asynchronicity.md +++ b/docs/src/docs/features/asynchronicity.md @@ -85,6 +85,62 @@ 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 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 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 +{ + // ... + 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 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). + +### 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 c48a3303..b0559e82 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; @@ -112,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( @@ -627,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); @@ -670,7 +673,18 @@ public function getPagination(): PaginationInterface private function getResultSet(): ResultSetInterface { - return $this->resultSet ??= $this->query->getResult(); + if ( + !$this->config->isAsync() + || $this->isRequestFromTurboFrame() + || $this->exporting + ) { + return $this->resultSet ??= $this->query->getResult(); + } + + // 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); } private function createPagination(): PaginationInterface 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/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/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index 37534b7f..b38f3432 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']) ; } 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); + } }