diff --git a/src/DataTableConfigBuilder.php b/src/DataTableConfigBuilder.php
index 4e65af54..dd9bd322 100755
--- a/src/DataTableConfigBuilder.php
+++ b/src/DataTableConfigBuilder.php
@@ -16,6 +16,7 @@
use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface;
use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData;
use Kreyu\Bundle\DataTableBundle\Request\RequestHandlerInterface;
+use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupingConfiguration;
use Kreyu\Bundle\DataTableBundle\Sorting\SortingData;
use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -64,6 +65,9 @@ class DataTableConfigBuilder implements DataTableConfigBuilderInterface
private bool $sortingClearable = false;
+ private bool $rowGroupingEnabled = false;
+ private ?RowGroupingConfiguration $rowGroupingConfiguration = null;
+
private array $themes = [];
private array $attributes = [];
private array $headerRowAttributes = [];
@@ -687,6 +691,38 @@ public function setRequestHandler(?RequestHandlerInterface $requestHandler): sta
return $this;
}
+ public function isRowGroupingEnabled(): bool
+ {
+ return $this->rowGroupingEnabled;
+ }
+
+ public function setRowGroupingEnabled(bool $rowGroupingEnabled): static
+ {
+ if ($this->locked) {
+ throw $this->createBuilderLockedException();
+ }
+
+ $this->rowGroupingEnabled = $rowGroupingEnabled;
+
+ return $this;
+ }
+
+ public function getRowGroupingConfiguration(): ?RowGroupingConfiguration
+ {
+ return $this->rowGroupingConfiguration;
+ }
+
+ public function setRowGroupingConfiguration(?RowGroupingConfiguration $rowGroupingConfiguration): static
+ {
+ if ($this->locked) {
+ throw $this->createBuilderLockedException();
+ }
+
+ $this->rowGroupingConfiguration = $rowGroupingConfiguration;
+
+ return $this;
+ }
+
public function getThemes(): array
{
return $this->themes;
diff --git a/src/DataTableConfigBuilderInterface.php b/src/DataTableConfigBuilderInterface.php
index 064499b1..cd1b4d65 100755
--- a/src/DataTableConfigBuilderInterface.php
+++ b/src/DataTableConfigBuilderInterface.php
@@ -15,6 +15,7 @@
use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface;
use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData;
use Kreyu\Bundle\DataTableBundle\Request\RequestHandlerInterface;
+use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupingConfiguration;
use Kreyu\Bundle\DataTableBundle\Sorting\SortingData;
use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -103,6 +104,10 @@ public function setPaginationPersistenceSubjectProvider(?PersistenceSubjectProvi
public function setDefaultPaginationData(?PaginationData $defaultPaginationData): static;
+ public function setRowGroupingEnabled(bool $rowGroupingEnabled): static;
+
+ public function setRowGroupingConfiguration(?RowGroupingConfiguration $rowGroupingConfiguration): static;
+
public function setRequestHandler(?RequestHandlerInterface $requestHandler): static;
public function addTheme(string $theme): static;
diff --git a/src/DataTableConfigInterface.php b/src/DataTableConfigInterface.php
index c78eba6a..b7154e33 100755
--- a/src/DataTableConfigInterface.php
+++ b/src/DataTableConfigInterface.php
@@ -15,6 +15,7 @@
use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface;
use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData;
use Kreyu\Bundle\DataTableBundle\Request\RequestHandlerInterface;
+use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupingConfiguration;
use Kreyu\Bundle\DataTableBundle\Sorting\SortingData;
use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -105,6 +106,10 @@ public function getDefaultPaginationData(): ?PaginationData;
public function getRequestHandler(): ?RequestHandlerInterface;
+ public function isRowGroupingEnabled(): bool;
+
+ public function getRowGroupingConfiguration(): ?RowGroupingConfiguration;
+
public function getAttributes(): array;
public function hasAttribute(string $name): bool;
diff --git a/src/Resources/config/core.php b/src/Resources/config/core.php
index c63bf211..bb80bd50 100755
--- a/src/Resources/config/core.php
+++ b/src/Resources/config/core.php
@@ -14,6 +14,7 @@
use Kreyu\Bundle\DataTableBundle\Persistence\TokenStoragePersistenceSubjectProvider;
use Kreyu\Bundle\DataTableBundle\Query\ArrayProxyQueryFactory;
use Kreyu\Bundle\DataTableBundle\Request\HttpFoundationRequestHandler;
+use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupProcessor;
use Kreyu\Bundle\DataTableBundle\Type\DataTableType;
use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeFactory;
use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeFactoryInterface;
@@ -31,9 +32,14 @@
->alias(ResolvedDataTableTypeFactoryInterface::class, 'kreyu_data_table.resolved_type_factory')
;
+ $services
+ ->set('kreyu_data_table.row_group.processor', RowGroupProcessor::class)
+ ;
+
$services
->set('kreyu_data_table.type.data_table', DataTableType::class)
->arg('$defaults', abstract_arg('Default options, provided by KreyuDataTableExtension and DefaultConfigurationPass'))
+ ->arg('$rowGroupProcessor', service('kreyu_data_table.row_group.processor'))
->tag('kreyu_data_table.type')
;
diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig
index 37534b7f..d321ea27 100755
--- a/src/Resources/views/themes/base.html.twig
+++ b/src/Resources/views/themes/base.html.twig
@@ -90,10 +90,38 @@
{% block table_body_row_results %}
{% for value_row in value_rows %}
-
{{ data_table_value_row(value_row) }}
+ {% if value_row.vars.row_type|default('data') == 'group_header' %}
+ {{ block('table_body_row_group_header', theme) }}
+ {% elseif value_row.vars.row_type|default('data') == 'group_footer' %}
+ {{ block('table_body_row_group_footer', theme) }}
+ {% else %}
+ {{ data_table_value_row(value_row) }}
+ {% endif %}
{% endfor %}
{% endblock %}
+{% block table_body_row_group_header %}
+
+{% endblock %}
+
+{% block table_body_row_group_footer %}
+
+{% endblock %}
+
{% block table_body_row_no_results %}
| {{ 'No results found'|trans({}, 'KreyuDataTable') }} |
diff --git a/src/RowGroup/AggregationCalculator.php b/src/RowGroup/AggregationCalculator.php
new file mode 100644
index 00000000..5dd86d90
--- /dev/null
+++ b/src/RowGroup/AggregationCalculator.php
@@ -0,0 +1,75 @@
+ $rows
+ * @param list $definitions
+ *
+ * @return list
+ */
+ public function calculate(array $rows, array $definitions): array
+ {
+ $results = [];
+
+ foreach ($definitions as $definition) {
+ $values = $this->extractValues($rows, $definition->getPropertyPath());
+ $computed = $this->compute($definition->getType(), $values);
+
+ $results[] = new AggregationResult($definition, $computed);
+ }
+
+ return $results;
+ }
+
+ /**
+ * @param list $rows
+ *
+ * @return list
+ */
+ private function extractValues(array $rows, string $propertyPath): array
+ {
+ $values = [];
+
+ foreach ($rows as $row) {
+ if (is_array($row) || is_object($row)) {
+ $values[] = $this->propertyAccessor->getValue($row, $propertyPath);
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * @param list $values
+ */
+ private function compute(AggregationType $type, array $values): int|float|null
+ {
+ if ([] === $values) {
+ return null;
+ }
+
+ $numericValues = array_map(static fn (mixed $v): float => (float) $v, $values);
+
+ return match ($type) {
+ AggregationType::Count => count($values),
+ AggregationType::Sum => array_sum($numericValues),
+ AggregationType::Average => array_sum($numericValues) / count($numericValues),
+ AggregationType::Min => min($numericValues),
+ AggregationType::Max => max($numericValues),
+ };
+ }
+}
diff --git a/src/RowGroup/AggregationDefinition.php b/src/RowGroup/AggregationDefinition.php
new file mode 100644
index 00000000..7aa5dd0e
--- /dev/null
+++ b/src/RowGroup/AggregationDefinition.php
@@ -0,0 +1,42 @@
+columnName;
+ }
+
+ public function getType(): AggregationType
+ {
+ return $this->type;
+ }
+
+ public function getLabel(): ?string
+ {
+ return $this->label;
+ }
+
+ public function getPropertyPath(): string
+ {
+ return $this->propertyPath ?? $this->columnName;
+ }
+}
diff --git a/src/RowGroup/AggregationResult.php b/src/RowGroup/AggregationResult.php
new file mode 100644
index 00000000..336651a3
--- /dev/null
+++ b/src/RowGroup/AggregationResult.php
@@ -0,0 +1,39 @@
+definition;
+ }
+
+ public function getColumnName(): string
+ {
+ return $this->definition->getColumnName();
+ }
+
+ public function getType(): AggregationType
+ {
+ return $this->definition->getType();
+ }
+
+ public function getLabel(): ?string
+ {
+ return $this->definition->getLabel();
+ }
+
+ public function getValue(): mixed
+ {
+ return $this->value;
+ }
+}
diff --git a/src/RowGroup/AggregationType.php b/src/RowGroup/AggregationType.php
new file mode 100644
index 00000000..c1db05fd
--- /dev/null
+++ b/src/RowGroup/AggregationType.php
@@ -0,0 +1,14 @@
+ $fields
+ */
+ public function __construct(
+ private array $fields = [],
+ private bool $enabled = true,
+ ) {
+ }
+
+ /**
+ * @return list
+ */
+ public function getFields(): array
+ {
+ return $this->fields;
+ }
+
+ /**
+ * @param list $fields
+ */
+ public function setFields(array $fields): void
+ {
+ $this->fields = $fields;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function setEnabled(bool $enabled): void
+ {
+ $this->enabled = $enabled;
+ }
+}
diff --git a/src/RowGroup/RowGroupFooterView.php b/src/RowGroup/RowGroupFooterView.php
new file mode 100644
index 00000000..e250065c
--- /dev/null
+++ b/src/RowGroup/RowGroupFooterView.php
@@ -0,0 +1,41 @@
+ [],
+ 'row_type' => 'group_footer',
+ ];
+
+ /**
+ * @param DataTableView $parent
+ * @param mixed $groupValue The value that identifies this group
+ * @param string $fieldName The field name used for grouping
+ * @param int $level Nesting level (0-based) for multi-level grouping
+ * @param int $columnCount Number of columns in the table (for colspan)
+ * @param list $aggregations Computed aggregation results
+ * @param int $rowCount Number of data rows in this group
+ */
+ public function __construct(
+ public readonly DataTableView $parent,
+ public readonly mixed $groupValue,
+ public readonly string $fieldName,
+ public readonly int $level = 0,
+ public readonly int $columnCount = 0,
+ public readonly array $aggregations = [],
+ public readonly int $rowCount = 0,
+ ) {
+ $this->vars['group_value'] = $groupValue;
+ $this->vars['field_name'] = $fieldName;
+ $this->vars['level'] = $level;
+ $this->vars['column_count'] = $columnCount;
+ $this->vars['aggregations'] = $aggregations;
+ $this->vars['row_count'] = $rowCount;
+ }
+}
diff --git a/src/RowGroup/RowGroupHeaderView.php b/src/RowGroup/RowGroupHeaderView.php
new file mode 100644
index 00000000..1fa3c8b0
--- /dev/null
+++ b/src/RowGroup/RowGroupHeaderView.php
@@ -0,0 +1,38 @@
+ [],
+ 'row_type' => 'group_header',
+ ];
+
+ /**
+ * @param DataTableView $parent
+ * @param mixed $groupValue The value that identifies this group
+ * @param string $fieldName The field name used for grouping
+ * @param int $level Nesting level (0-based) for multi-level grouping
+ * @param int $columnCount Number of columns in the table (for colspan)
+ * @param list $aggregations Computed aggregation results
+ */
+ public function __construct(
+ public readonly DataTableView $parent,
+ public readonly mixed $groupValue,
+ public readonly string $fieldName,
+ public readonly int $level = 0,
+ public readonly int $columnCount = 0,
+ public readonly array $aggregations = [],
+ ) {
+ $this->vars['group_value'] = $groupValue;
+ $this->vars['field_name'] = $fieldName;
+ $this->vars['level'] = $level;
+ $this->vars['column_count'] = $columnCount;
+ $this->vars['aggregations'] = $aggregations;
+ }
+}
diff --git a/src/RowGroup/RowGroupProcessor.php b/src/RowGroup/RowGroupProcessor.php
new file mode 100644
index 00000000..1b3838c4
--- /dev/null
+++ b/src/RowGroup/RowGroupProcessor.php
@@ -0,0 +1,265 @@
+propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
+ $this->aggregationCalculator = new AggregationCalculator($this->propertyAccessor);
+ }
+
+ /**
+ * Wraps value row iteration to insert group header and footer rows.
+ *
+ * @param iterable $valueRows
+ * @param RowGroupingConfiguration $config
+ * @param DataTableView $view
+ * @param array $columns
+ *
+ * @return iterable
+ */
+ public function process(
+ iterable $valueRows,
+ RowGroupingConfiguration $config,
+ DataTableView $view,
+ array $columns,
+ ): iterable {
+ $fields = $config->getFields();
+
+ if ([] === $fields) {
+ yield from $valueRows;
+
+ return;
+ }
+
+ $columnCount = count($columns);
+
+ // For single field grouping, use a streaming approach
+ if (1 === count($fields)) {
+ yield from $this->processSingleLevel($valueRows, $fields[0], $config, $view, $columnCount);
+
+ return;
+ }
+
+ // For multi-level grouping, we need to buffer rows
+ yield from $this->processMultiLevel($valueRows, $fields, $config, $view, $columnCount);
+ }
+
+ /**
+ * @return iterable
+ */
+ private function processSingleLevel(
+ iterable $valueRows,
+ string $field,
+ RowGroupingConfiguration $config,
+ DataTableView $view,
+ int $columnCount,
+ ): iterable {
+ $currentGroupValue = new \stdClass(); // sentinel - no group started
+ $groupRows = [];
+ $groupStarted = false;
+
+ foreach ($valueRows as $valueRow) {
+ $rowData = $valueRow->data;
+ $groupValue = $this->resolveFieldValue($rowData, $field);
+
+ if (!$groupStarted || $groupValue !== $currentGroupValue) {
+ // Close previous group
+ if ($groupStarted && $config->hasEndRenderer()) {
+ yield $this->createFooterView($view, $currentGroupValue, $field, 0, $columnCount, $config, $groupRows);
+ }
+
+ // Emit header for new group
+ $currentGroupValue = $groupValue;
+ $groupRows = [];
+ $groupStarted = true;
+
+ if ($config->hasStartRenderer()) {
+ yield $this->createHeaderView($view, $currentGroupValue, $field, 0, $columnCount, $config, []);
+ }
+ }
+
+ $groupRows[] = $rowData;
+ yield $valueRow;
+ }
+
+ // Close the last group
+ if ($groupStarted && $config->hasEndRenderer()) {
+ yield $this->createFooterView($view, $currentGroupValue, $field, 0, $columnCount, $config, $groupRows);
+ }
+ }
+
+ /**
+ * @param list $fields
+ *
+ * @return iterable
+ */
+ private function processMultiLevel(
+ iterable $valueRows,
+ array $fields,
+ RowGroupingConfiguration $config,
+ DataTableView $view,
+ int $columnCount,
+ ): iterable {
+ // For multi-level grouping we need to track current group values at each level
+ $currentValues = array_fill(0, count($fields), new \stdClass()); // sentinels
+ $groupRowsByLevel = array_fill(0, count($fields), []);
+ $started = array_fill(0, count($fields), false);
+
+ foreach ($valueRows as $valueRow) {
+ $rowData = $valueRow->data;
+
+ for ($level = 0; $level < count($fields); ++$level) {
+ $groupValue = $this->resolveFieldValue($rowData, $fields[$level]);
+
+ if (!$started[$level] || $groupValue !== $currentValues[$level]) {
+ // Close all deeper levels first (reverse order)
+ for ($closeLevel = count($fields) - 1; $closeLevel > $level; --$closeLevel) {
+ if ($started[$closeLevel] && $config->hasEndRenderer()) {
+ yield $this->createFooterView(
+ $view, $currentValues[$closeLevel], $fields[$closeLevel],
+ $closeLevel, $columnCount, $config, $groupRowsByLevel[$closeLevel],
+ );
+ }
+ $groupRowsByLevel[$closeLevel] = [];
+ $started[$closeLevel] = false;
+ $currentValues[$closeLevel] = new \stdClass();
+ }
+
+ // Close current level
+ if ($started[$level] && $config->hasEndRenderer()) {
+ yield $this->createFooterView(
+ $view, $currentValues[$level], $fields[$level],
+ $level, $columnCount, $config, $groupRowsByLevel[$level],
+ );
+ }
+
+ // Open new group at this level
+ $currentValues[$level] = $groupValue;
+ $groupRowsByLevel[$level] = [];
+ $started[$level] = true;
+
+ if ($config->hasStartRenderer()) {
+ yield $this->createHeaderView(
+ $view, $groupValue, $fields[$level],
+ $level, $columnCount, $config, [],
+ );
+ }
+
+ // Open all deeper levels
+ for ($openLevel = $level + 1; $openLevel < count($fields); ++$openLevel) {
+ $deepValue = $this->resolveFieldValue($rowData, $fields[$openLevel]);
+ $currentValues[$openLevel] = $deepValue;
+ $groupRowsByLevel[$openLevel] = [];
+ $started[$openLevel] = true;
+
+ if ($config->hasStartRenderer()) {
+ yield $this->createHeaderView(
+ $view, $deepValue, $fields[$openLevel],
+ $openLevel, $columnCount, $config, [],
+ );
+ }
+ }
+
+ break; // deeper levels already handled
+ }
+ }
+
+ // Add row data to all level accumulators
+ for ($level = 0; $level < count($fields); ++$level) {
+ $groupRowsByLevel[$level][] = $rowData;
+ }
+
+ yield $valueRow;
+ }
+
+ // Close all remaining groups (deepest first)
+ for ($level = count($fields) - 1; $level >= 0; --$level) {
+ if ($started[$level] && $config->hasEndRenderer()) {
+ yield $this->createFooterView(
+ $view, $currentValues[$level], $fields[$level],
+ $level, $columnCount, $config, $groupRowsByLevel[$level],
+ );
+ }
+ }
+ }
+
+ private function createHeaderView(
+ DataTableView $view,
+ mixed $groupValue,
+ string $fieldName,
+ int $level,
+ int $columnCount,
+ RowGroupingConfiguration $config,
+ array $groupRows,
+ ): RowGroupHeaderView {
+ $aggregations = $this->computeAggregations($config, $groupRows);
+
+ $headerView = new RowGroupHeaderView($view, $groupValue, $fieldName, $level, $columnCount, $aggregations);
+
+ if (null !== $startRenderer = $config->getStartRenderer()) {
+ $headerView->vars = array_merge($headerView->vars, $startRenderer($groupValue, $fieldName, $level, $aggregations));
+ }
+
+ return $headerView;
+ }
+
+ private function createFooterView(
+ DataTableView $view,
+ mixed $groupValue,
+ string $fieldName,
+ int $level,
+ int $columnCount,
+ RowGroupingConfiguration $config,
+ array $groupRows,
+ ): RowGroupFooterView {
+ $aggregations = $this->computeAggregations($config, $groupRows);
+
+ $footerView = new RowGroupFooterView($view, $groupValue, $fieldName, $level, $columnCount, $aggregations, count($groupRows));
+
+ if (null !== $endRenderer = $config->getEndRenderer()) {
+ $footerView->vars = array_merge($footerView->vars, $endRenderer($groupValue, $fieldName, $level, $aggregations, count($groupRows)));
+ }
+
+ return $footerView;
+ }
+
+ /**
+ * @param list $groupRows
+ *
+ * @return list
+ */
+ private function computeAggregations(RowGroupingConfiguration $config, array $groupRows): array
+ {
+ $definitions = $config->getAggregations();
+
+ if ([] === $definitions || [] === $groupRows) {
+ return [];
+ }
+
+ return $this->aggregationCalculator->calculate($groupRows, $definitions);
+ }
+
+ private function resolveFieldValue(mixed $data, string $field): mixed
+ {
+ if (is_array($data) || is_object($data)) {
+ return $this->propertyAccessor->getValue($data, $field);
+ }
+
+ return null;
+ }
+}
diff --git a/src/RowGroup/RowGroupingConfiguration.php b/src/RowGroup/RowGroupingConfiguration.php
new file mode 100644
index 00000000..6620e36c
--- /dev/null
+++ b/src/RowGroup/RowGroupingConfiguration.php
@@ -0,0 +1,113 @@
+ $fields Field names to group by (multi-level supported)
+ * @param bool $enabled Whether grouping is active
+ * @param \Closure|null $startRenderer Custom renderer for group header rows
+ * @param \Closure|null $endRenderer Custom renderer for group footer rows
+ * @param list $aggregations Aggregation definitions for group summaries
+ */
+ public function __construct(
+ private array $fields = [],
+ private bool $enabled = true,
+ private ?\Closure $startRenderer = null,
+ private ?\Closure $endRenderer = null,
+ private array $aggregations = [],
+ ) {
+ }
+
+ /**
+ * @return list
+ */
+ public function getFields(): array
+ {
+ return $this->fields;
+ }
+
+ /**
+ * @param list $fields
+ */
+ public function setFields(array $fields): static
+ {
+ $this->fields = $fields;
+
+ return $this;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function setEnabled(bool $enabled): static
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ public function hasStartRenderer(): bool
+ {
+ return true; // group headers are always rendered
+ }
+
+ public function getStartRenderer(): ?\Closure
+ {
+ return $this->startRenderer;
+ }
+
+ public function setStartRenderer(?callable $startRenderer): static
+ {
+ $this->startRenderer = null !== $startRenderer ? $startRenderer(...) : null;
+
+ return $this;
+ }
+
+ public function hasEndRenderer(): bool
+ {
+ return null !== $this->endRenderer;
+ }
+
+ public function getEndRenderer(): ?\Closure
+ {
+ return $this->endRenderer;
+ }
+
+ public function setEndRenderer(?callable $endRenderer): static
+ {
+ $this->endRenderer = null !== $endRenderer ? $endRenderer(...) : null;
+
+ return $this;
+ }
+
+ /**
+ * @return list
+ */
+ public function getAggregations(): array
+ {
+ return $this->aggregations;
+ }
+
+ /**
+ * @param list $aggregations
+ */
+ public function setAggregations(array $aggregations): static
+ {
+ $this->aggregations = $aggregations;
+
+ return $this;
+ }
+
+ public function addAggregation(AggregationDefinition $aggregation): static
+ {
+ $this->aggregations[] = $aggregation;
+
+ return $this;
+ }
+}
diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php
index e76e935a..5110831b 100755
--- a/src/Type/DataTableType.php
+++ b/src/Type/DataTableType.php
@@ -22,6 +22,8 @@
use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface;
use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface;
use Kreyu\Bundle\DataTableBundle\Request\RequestHandlerInterface;
+use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupingConfiguration;
+use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupProcessor;
use Kreyu\Bundle\DataTableBundle\RowIterator;
use Kreyu\Bundle\DataTableBundle\Util\FormUtil;
use Kreyu\Bundle\DataTableBundle\ValueRowView;
@@ -35,6 +37,7 @@ final class DataTableType implements DataTableTypeInterface
{
public function __construct(
private readonly array $defaults = [],
+ private readonly ?RowGroupProcessor $rowGroupProcessor = null,
) {
}
@@ -68,6 +71,8 @@ public function buildDataTable(DataTableBuilderInterface $builder, array $option
'exporting_enabled' => $builder->setExportingEnabled(...),
'exporting_form_factory' => $builder->setExportFormFactory(...),
'request_handler' => $builder->setRequestHandler(...),
+ 'row_grouping_enabled' => $builder->setRowGroupingEnabled(...),
+ 'row_grouping_configuration' => $builder->setRowGroupingConfiguration(...),
];
foreach ($setters as $option => $setter) {
@@ -111,7 +116,21 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar
$view->headerRow = $this->createHeaderRowView($view, $dataTable, $visibleColumns);
$view->nonPersonalizedHeaderRow = $this->createHeaderRowView($view, $dataTable, $columns);
- $view->valueRows = new RowIterator(fn () => $this->createValueRowsViews($view, $dataTable, $visibleColumns));
+
+ $rowGroupingEnabled = $dataTable->getConfig()->isRowGroupingEnabled();
+ $rowGroupingConfig = $dataTable->getConfig()->getRowGroupingConfiguration();
+
+ if ($rowGroupingEnabled && null !== $rowGroupingConfig && null !== $this->rowGroupProcessor) {
+ $processor = $this->rowGroupProcessor;
+ $cols = $visibleColumns;
+ $view->valueRows = new RowIterator(function () use ($view, $dataTable, $cols, $processor, $rowGroupingConfig) {
+ $innerRows = $this->createValueRowsViews($view, $dataTable, $cols);
+ yield from $processor->process($innerRows, $rowGroupingConfig, $view, $cols);
+ });
+ } else {
+ $view->valueRows = new RowIterator(fn () => $this->createValueRowsViews($view, $dataTable, $visibleColumns));
+ }
+
$view->pagination = $this->createPaginationView($view, $dataTable);
$view->filters = $this->createFilterViews($view, $dataTable);
$view->actions = $this->createActionViews($view, $dataTable);
@@ -124,6 +143,7 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar
'actions' => $view->actions,
'batch_actions' => $this->createBatchActionViews($view, $dataTable),
'column_count' => count($view->headerRow),
+ 'row_grouping_enabled' => $rowGroupingEnabled,
]);
if ($dataTable->getConfig()->isFiltrationEnabled()) {
@@ -186,6 +206,8 @@ 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,
+ 'row_grouping_enabled' => false,
+ 'row_grouping_configuration' => null,
])
->setAllowedTypes('title', ['null', 'string', TranslatableInterface::class])
->setAllowedTypes('title_translation_parameters', ['array'])
@@ -217,6 +239,8 @@ 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('row_grouping_enabled', 'bool')
+ ->setAllowedTypes('row_grouping_configuration', ['null', RowGroupingConfiguration::class])
;
}
diff --git a/tests/Unit/RowGroup/AggregationCalculatorTest.php b/tests/Unit/RowGroup/AggregationCalculatorTest.php
new file mode 100644
index 00000000..880b9805
--- /dev/null
+++ b/tests/Unit/RowGroup/AggregationCalculatorTest.php
@@ -0,0 +1,144 @@
+calculator = new AggregationCalculator(PropertyAccess::createPropertyAccessor());
+ }
+
+ public function testCountAggregation(): void
+ {
+ $rows = [
+ ['name' => 'Alice', 'salary' => 5000],
+ ['name' => 'Bob', 'salary' => 6000],
+ ['name' => 'Charlie', 'salary' => 7000],
+ ];
+
+ $definitions = [
+ new AggregationDefinition('salary', AggregationType::Count, 'Employees', '[salary]'),
+ ];
+
+ $results = $this->calculator->calculate($rows, $definitions);
+
+ $this->assertCount(1, $results);
+ $this->assertSame(3, $results[0]->getValue());
+ $this->assertSame('Employees', $results[0]->getLabel());
+ $this->assertSame('salary', $results[0]->getColumnName());
+ }
+
+ public function testSumAggregation(): void
+ {
+ $rows = [
+ ['name' => 'Alice', 'salary' => 5000],
+ ['name' => 'Bob', 'salary' => 6000],
+ ['name' => 'Charlie', 'salary' => 7000],
+ ];
+
+ $definitions = [
+ new AggregationDefinition('salary', AggregationType::Sum, 'Total Salary', '[salary]'),
+ ];
+
+ $results = $this->calculator->calculate($rows, $definitions);
+
+ $this->assertSame(18000.0, $results[0]->getValue());
+ }
+
+ public function testAverageAggregation(): void
+ {
+ $rows = [
+ ['name' => 'Alice', 'salary' => 4000],
+ ['name' => 'Bob', 'salary' => 6000],
+ ['name' => 'Charlie', 'salary' => 8000],
+ ];
+
+ $definitions = [
+ new AggregationDefinition('salary', AggregationType::Average, propertyPath: '[salary]'),
+ ];
+
+ $results = $this->calculator->calculate($rows, $definitions);
+
+ $this->assertSame(6000.0, $results[0]->getValue());
+ }
+
+ public function testMinMaxAggregation(): void
+ {
+ $rows = [
+ ['salary' => 5000],
+ ['salary' => 3000],
+ ['salary' => 8000],
+ ];
+
+ $definitions = [
+ new AggregationDefinition('salary', AggregationType::Min, 'Min Salary', '[salary]'),
+ new AggregationDefinition('salary', AggregationType::Max, 'Max Salary', '[salary]'),
+ ];
+
+ $results = $this->calculator->calculate($rows, $definitions);
+
+ $this->assertSame(3000.0, $results[0]->getValue());
+ $this->assertSame(8000.0, $results[1]->getValue());
+ }
+
+ public function testEmptyRows(): void
+ {
+ $definitions = [
+ new AggregationDefinition('salary', AggregationType::Sum, propertyPath: '[salary]'),
+ ];
+
+ $results = $this->calculator->calculate([], $definitions);
+
+ $this->assertCount(1, $results);
+ $this->assertNull($results[0]->getValue());
+ }
+
+ public function testMultipleAggregations(): void
+ {
+ $rows = [
+ ['salary' => 1000, 'bonus' => 100],
+ ['salary' => 2000, 'bonus' => 200],
+ ['salary' => 3000, 'bonus' => 300],
+ ];
+
+ $definitions = [
+ new AggregationDefinition('salary', AggregationType::Count, 'Count', '[salary]'),
+ new AggregationDefinition('salary', AggregationType::Sum, 'Total Salary', '[salary]'),
+ new AggregationDefinition('bonus', AggregationType::Average, 'Avg Bonus', '[bonus]'),
+ ];
+
+ $results = $this->calculator->calculate($rows, $definitions);
+
+ $this->assertCount(3, $results);
+ $this->assertSame(3, $results[0]->getValue());
+ $this->assertSame(6000.0, $results[1]->getValue());
+ $this->assertSame(200.0, $results[2]->getValue());
+ }
+
+ public function testCustomPropertyPath(): void
+ {
+ $rows = [
+ ['details' => ['salary' => 5000]],
+ ['details' => ['salary' => 6000]],
+ ];
+
+ $definitions = [
+ new AggregationDefinition('salary', AggregationType::Sum, propertyPath: '[details][salary]'),
+ ];
+
+ $results = $this->calculator->calculate($rows, $definitions);
+
+ $this->assertSame(11000.0, $results[0]->getValue());
+ }
+}
diff --git a/tests/Unit/RowGroup/RowGroupProcessorTest.php b/tests/Unit/RowGroup/RowGroupProcessorTest.php
new file mode 100644
index 00000000..bf60bb16
--- /dev/null
+++ b/tests/Unit/RowGroup/RowGroupProcessorTest.php
@@ -0,0 +1,304 @@
+processor = new RowGroupProcessor();
+ }
+
+ public function testNoGroupingFieldsYieldsOriginalRows(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: []);
+
+ $rows = $this->createValueRows($view, [
+ ['name' => 'Alice', 'office' => 'Tokyo'],
+ ['name' => 'Bob', 'office' => 'London'],
+ ]);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, []));
+
+ $this->assertCount(2, $result);
+ $this->assertInstanceOf(ValueRowView::class, $result[0]);
+ $this->assertInstanceOf(ValueRowView::class, $result[1]);
+ }
+
+ public function testSingleLevelGrouping(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[office]']);
+
+ $data = [
+ ['name' => 'Alice', 'office' => 'Tokyo'],
+ ['name' => 'Bob', 'office' => 'Tokyo'],
+ ['name' => 'Charlie', 'office' => 'London'],
+ ['name' => 'Dave', 'office' => 'London'],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, ['col1' => $this->createMock(ColumnInterface::class), 'col2' => $this->createMock(ColumnInterface::class)]));
+
+ // Expected: Header(Tokyo), Alice, Bob, Header(London), Charlie, Dave
+ $this->assertCount(6, $result);
+ $this->assertInstanceOf(RowGroupHeaderView::class, $result[0]);
+ $this->assertSame('Tokyo', $result[0]->groupValue);
+ $this->assertSame(0, $result[0]->level);
+ $this->assertSame(2, $result[0]->columnCount);
+ $this->assertInstanceOf(ValueRowView::class, $result[1]);
+ $this->assertInstanceOf(ValueRowView::class, $result[2]);
+ $this->assertInstanceOf(RowGroupHeaderView::class, $result[3]);
+ $this->assertSame('London', $result[3]->groupValue);
+ $this->assertInstanceOf(ValueRowView::class, $result[4]);
+ $this->assertInstanceOf(ValueRowView::class, $result[5]);
+ }
+
+ public function testSingleLevelWithFooter(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[office]']);
+ $config->setEndRenderer(fn (mixed $value, string $field, int $level, array $aggs, int $rowCount) => [
+ 'summary' => "$value: $rowCount rows",
+ ]);
+
+ $data = [
+ ['name' => 'Alice', 'office' => 'Tokyo'],
+ ['name' => 'Bob', 'office' => 'Tokyo'],
+ ['name' => 'Charlie', 'office' => 'London'],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, []));
+
+ // Expected: Header(Tokyo), Alice, Bob, Footer(Tokyo), Header(London), Charlie, Footer(London)
+ $this->assertCount(7, $result);
+ $this->assertInstanceOf(RowGroupHeaderView::class, $result[0]);
+ $this->assertInstanceOf(ValueRowView::class, $result[1]);
+ $this->assertInstanceOf(ValueRowView::class, $result[2]);
+ $this->assertInstanceOf(RowGroupFooterView::class, $result[3]);
+ $this->assertSame('Tokyo', $result[3]->groupValue);
+ $this->assertSame(2, $result[3]->rowCount);
+ $this->assertSame('Tokyo: 2 rows', $result[3]->vars['summary']);
+ $this->assertInstanceOf(RowGroupHeaderView::class, $result[4]);
+ $this->assertInstanceOf(ValueRowView::class, $result[5]);
+ $this->assertInstanceOf(RowGroupFooterView::class, $result[6]);
+ $this->assertSame(1, $result[6]->rowCount);
+ }
+
+ public function testSingleLevelWithAggregations(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(
+ fields: ['[office]'],
+ aggregations: [
+ new AggregationDefinition('salary', AggregationType::Sum, 'Total Salary', '[salary]'),
+ new AggregationDefinition('salary', AggregationType::Count, 'Employees', '[salary]'),
+ ],
+ );
+ $config->setEndRenderer(fn () => []);
+
+ $data = [
+ ['name' => 'Alice', 'office' => 'Tokyo', 'salary' => 5000],
+ ['name' => 'Bob', 'office' => 'Tokyo', 'salary' => 6000],
+ ['name' => 'Charlie', 'office' => 'London', 'salary' => 7000],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, []));
+
+ // Find footer views
+ $footers = array_filter($result, fn ($r) => $r instanceof RowGroupFooterView);
+ $footers = array_values($footers);
+
+ $this->assertCount(2, $footers);
+
+ // Tokyo footer
+ $tokyoAggs = $footers[0]->aggregations;
+ $this->assertCount(2, $tokyoAggs);
+ $this->assertSame(11000.0, $tokyoAggs[0]->getValue());
+ $this->assertSame('Total Salary', $tokyoAggs[0]->getLabel());
+ $this->assertSame(2, $tokyoAggs[1]->getValue());
+ $this->assertSame('Employees', $tokyoAggs[1]->getLabel());
+
+ // London footer
+ $londonAggs = $footers[1]->aggregations;
+ $this->assertSame(7000.0, $londonAggs[0]->getValue());
+ $this->assertSame(1, $londonAggs[1]->getValue());
+ }
+
+ public function testMultiLevelGrouping(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[country]', '[city]']);
+
+ $data = [
+ ['name' => 'Alice', 'country' => 'Japan', 'city' => 'Tokyo'],
+ ['name' => 'Bob', 'country' => 'Japan', 'city' => 'Tokyo'],
+ ['name' => 'Charlie', 'country' => 'Japan', 'city' => 'Osaka'],
+ ['name' => 'Dave', 'country' => 'UK', 'city' => 'London'],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, []));
+
+ // Expected:
+ // Header(Japan, level=0), Header(Tokyo, level=1), Alice, Bob,
+ // Header(Osaka, level=1), Charlie,
+ // Header(UK, level=0), Header(London, level=1), Dave
+ $headers = array_filter($result, fn ($r) => $r instanceof RowGroupHeaderView);
+ $headers = array_values($headers);
+
+ $this->assertCount(5, $headers);
+ $this->assertSame('Japan', $headers[0]->groupValue);
+ $this->assertSame(0, $headers[0]->level);
+ $this->assertSame('Tokyo', $headers[1]->groupValue);
+ $this->assertSame(1, $headers[1]->level);
+ $this->assertSame('Osaka', $headers[2]->groupValue);
+ $this->assertSame(1, $headers[2]->level);
+ $this->assertSame('UK', $headers[3]->groupValue);
+ $this->assertSame(0, $headers[3]->level);
+ $this->assertSame('London', $headers[4]->groupValue);
+ $this->assertSame(1, $headers[4]->level);
+ }
+
+ public function testMultiLevelGroupingWithFooters(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[country]', '[city]']);
+ $config->setEndRenderer(fn () => []);
+
+ $data = [
+ ['name' => 'Alice', 'country' => 'Japan', 'city' => 'Tokyo'],
+ ['name' => 'Bob', 'country' => 'Japan', 'city' => 'Osaka'],
+ ['name' => 'Charlie', 'country' => 'UK', 'city' => 'London'],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, []));
+
+ $footers = array_values(array_filter($result, fn ($r) => $r instanceof RowGroupFooterView));
+
+ // Footers: Tokyo(1 row), Osaka(1 row), Japan(2 rows), London(1 row), UK(1 row)
+ $this->assertCount(5, $footers);
+ }
+
+ public function testCustomStartRenderer(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[office]']);
+ $config->setStartRenderer(fn (mixed $value) => [
+ 'custom_label' => "Group: $value",
+ ]);
+
+ $data = [
+ ['name' => 'Alice', 'office' => 'Tokyo'],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, []));
+
+ $this->assertInstanceOf(RowGroupHeaderView::class, $result[0]);
+ $this->assertSame('Group: Tokyo', $result[0]->vars['custom_label']);
+ }
+
+ public function testGroupHeaderViewVars(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[office]']);
+
+ $data = [
+ ['name' => 'Alice', 'office' => 'Tokyo'],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, ['a' => $this->createMock(ColumnInterface::class), 'b' => $this->createMock(ColumnInterface::class)]));
+
+ $header = $result[0];
+ $this->assertInstanceOf(RowGroupHeaderView::class, $header);
+ $this->assertSame('Tokyo', $header->vars['group_value']);
+ $this->assertSame('[office]', $header->vars['field_name']);
+ $this->assertSame(0, $header->vars['level']);
+ $this->assertSame(2, $header->vars['column_count']);
+ $this->assertSame('group_header', $header->vars['row_type']);
+ }
+
+ public function testGroupFooterViewVars(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[office]']);
+ $config->setEndRenderer(fn () => []);
+
+ $data = [
+ ['name' => 'Alice', 'office' => 'Tokyo'],
+ ['name' => 'Bob', 'office' => 'Tokyo'],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, ['a' => $this->createMock(ColumnInterface::class)]));
+
+ $footers = array_values(array_filter($result, fn ($r) => $r instanceof RowGroupFooterView));
+ $this->assertCount(1, $footers);
+
+ $footer = $footers[0];
+ $this->assertSame('Tokyo', $footer->vars['group_value']);
+ $this->assertSame(2, $footer->vars['row_count']);
+ $this->assertSame('group_footer', $footer->vars['row_type']);
+ }
+
+ public function testSingleRowGroup(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[office]']);
+
+ $data = [
+ ['name' => 'Alice', 'office' => 'Tokyo'],
+ ];
+ $rows = $this->createValueRows($view, $data);
+
+ $result = iterator_to_array($this->processor->process($rows, $config, $view, []));
+
+ $this->assertCount(2, $result);
+ $this->assertInstanceOf(RowGroupHeaderView::class, $result[0]);
+ $this->assertInstanceOf(ValueRowView::class, $result[1]);
+ }
+
+ public function testEmptyDataset(): void
+ {
+ $view = new DataTableView();
+ $config = new RowGroupingConfiguration(fields: ['[office]']);
+
+ $result = iterator_to_array($this->processor->process([], $config, $view, []));
+
+ $this->assertCount(0, $result);
+ }
+
+ /**
+ * @return list
+ */
+ private function createValueRows(DataTableView $view, array $datasets): array
+ {
+ $rows = [];
+ foreach ($datasets as $index => $data) {
+ $rows[] = new ValueRowView($view, $index, $data);
+ }
+
+ return $rows;
+ }
+}
diff --git a/tests/Unit/RowGroup/RowGroupingConfigurationTest.php b/tests/Unit/RowGroup/RowGroupingConfigurationTest.php
new file mode 100644
index 00000000..e91145ae
--- /dev/null
+++ b/tests/Unit/RowGroup/RowGroupingConfigurationTest.php
@@ -0,0 +1,116 @@
+assertSame([], $config->getFields());
+ $this->assertTrue($config->isEnabled());
+ $this->assertNull($config->getStartRenderer());
+ $this->assertNull($config->getEndRenderer());
+ $this->assertSame([], $config->getAggregations());
+ $this->assertTrue($config->hasStartRenderer());
+ $this->assertFalse($config->hasEndRenderer());
+ }
+
+ public function testSetFields(): void
+ {
+ $config = new RowGroupingConfiguration();
+ $config->setFields(['office', 'department']);
+
+ $this->assertSame(['office', 'department'], $config->getFields());
+ }
+
+ public function testSetEnabled(): void
+ {
+ $config = new RowGroupingConfiguration();
+ $config->setEnabled(false);
+
+ $this->assertFalse($config->isEnabled());
+ }
+
+ public function testSetStartRenderer(): void
+ {
+ $renderer = fn () => [];
+ $config = new RowGroupingConfiguration();
+ $config->setStartRenderer($renderer);
+
+ $this->assertNotNull($config->getStartRenderer());
+ $this->assertTrue($config->hasStartRenderer());
+ }
+
+ public function testSetEndRenderer(): void
+ {
+ $renderer = fn () => [];
+ $config = new RowGroupingConfiguration();
+ $config->setEndRenderer($renderer);
+
+ $this->assertNotNull($config->getEndRenderer());
+ $this->assertTrue($config->hasEndRenderer());
+ }
+
+ public function testAggregations(): void
+ {
+ $config = new RowGroupingConfiguration();
+
+ $agg1 = new AggregationDefinition('salary', AggregationType::Sum);
+ $agg2 = new AggregationDefinition('salary', AggregationType::Count);
+
+ $config->addAggregation($agg1);
+ $config->addAggregation($agg2);
+
+ $this->assertCount(2, $config->getAggregations());
+ $this->assertSame($agg1, $config->getAggregations()[0]);
+ $this->assertSame($agg2, $config->getAggregations()[1]);
+ }
+
+ public function testSetAggregationsReplacesAll(): void
+ {
+ $config = new RowGroupingConfiguration();
+ $config->addAggregation(new AggregationDefinition('salary', AggregationType::Sum));
+
+ $newAgg = new AggregationDefinition('bonus', AggregationType::Average);
+ $config->setAggregations([$newAgg]);
+
+ $this->assertCount(1, $config->getAggregations());
+ $this->assertSame($newAgg, $config->getAggregations()[0]);
+ }
+
+ public function testFluentInterface(): void
+ {
+ $config = new RowGroupingConfiguration();
+
+ $result = $config
+ ->setFields(['office'])
+ ->setEnabled(true)
+ ->setStartRenderer(fn () => [])
+ ->setEndRenderer(fn () => [])
+ ->addAggregation(new AggregationDefinition('salary', AggregationType::Sum));
+
+ $this->assertSame($config, $result);
+ }
+
+ public function testConstructorWithArguments(): void
+ {
+ $config = new RowGroupingConfiguration(
+ fields: ['office'],
+ enabled: false,
+ aggregations: [new AggregationDefinition('salary', AggregationType::Sum)],
+ );
+
+ $this->assertSame(['office'], $config->getFields());
+ $this->assertFalse($config->isEnabled());
+ $this->assertCount(1, $config->getAggregations());
+ }
+}