Skip to content
Open
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
56 changes: 56 additions & 0 deletions docs/src/docs/features/asynchronicity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<turbo-frame>`. 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

<TurboPrefetchingSection>
Expand Down
16 changes: 15 additions & 1 deletion src/DataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/DataTableConfigBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}
}
2 changes: 2 additions & 0 deletions src/DataTableConfigInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,6 @@ public function getFiltrationParameterName(): string;
public function getPersonalizationParameterName(): string;

public function getExportParameterName(): string;

public function isAsync(): bool;
}
3 changes: 3 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 20 additions & 12 deletions src/Resources/views/themes/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,27 @@
{% set stimulus_controllers = stimulus_controllers|merge(['kreyu--data-table-bundle--batch']) %}
{% endif %}

<turbo-frame id="kreyu_data_table_{{ name }}" target="_top">
<div
data-controller="{{ stimulus_controllers|join(' ') }}"
data-kreyu--data-table-bundle--state-url-query-parameters-value="{{ url_query_parameters|default({})|json_encode(constant('JSON_FORCE_OBJECT')) }}"
>
{{ block('action_bar', theme) }}
{{ block('table', theme) }}
{% if data_table.vars.is_async and not data_table.vars.is_request_from_turbo_frame %}
<turbo-frame id="kreyu_data_table_{{ name }}" src="{{ app.request.uri }}" loading="lazy">
<div>
<progress style="width: 100%">{{ 'Loading'|trans({}, 'KreyuDataTable') }}</progress>
</div>
</turbo-frame>
{% else %}
<turbo-frame id="kreyu_data_table_{{ name }}" target="_top">
<div
data-controller="{{ stimulus_controllers|join(' ') }}"
data-kreyu--data-table-bundle--state-url-query-parameters-value="{{ url_query_parameters|default({})|json_encode(constant('JSON_FORCE_OBJECT')) }}"
>
{{ block('action_bar', theme) }}
{{ block('table', theme) }}

{% if pagination_enabled %}
{{ data_table_pagination(pagination) }}
{% endif %}
</div>
</turbo-frame>
{% if pagination_enabled %}
{{ data_table_pagination(pagination) }}
{% endif %}
</div>
</turbo-frame>
{% endif %}
{% endblock %}

{% block kreyu_data_table_form_aware %}
Expand Down
4 changes: 4 additions & 0 deletions src/Type/DataTableType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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'])
;
}

Expand Down
60 changes: 60 additions & 0 deletions tests/Unit/DataTableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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);
}
}
Loading