From 9814ec3750dcb826ecbf42e5980faeb7714cda8c Mon Sep 17 00:00:00 2001 From: Kamil Szalewski Date: Tue, 7 Apr 2026 18:42:29 +0200 Subject: [PATCH 1/2] draft: row grouping --- src/DataTableConfigBuilder.php | 36 +++ src/DataTableConfigBuilderInterface.php | 5 + src/DataTableConfigInterface.php | 5 + src/Resources/config/core.php | 6 + src/Resources/views/themes/base.html.twig | 30 +- src/RowGroup/AggregationCalculator.php | 75 +++++ src/RowGroup/AggregationDefinition.php | 42 +++ src/RowGroup/AggregationResult.php | 39 +++ src/RowGroup/AggregationType.php | 14 + src/RowGroup/RowGroupData.php | 43 +++ src/RowGroup/RowGroupFooterView.php | 41 +++ src/RowGroup/RowGroupHeaderView.php | 38 +++ src/RowGroup/RowGroupProcessor.php | 265 +++++++++++++++ src/RowGroup/RowGroupingConfiguration.php | 113 +++++++ src/Type/DataTableType.php | 26 +- .../RowGroup/AggregationCalculatorTest.php | 144 +++++++++ tests/Unit/RowGroup/RowGroupProcessorTest.php | 303 ++++++++++++++++++ .../RowGroup/RowGroupingConfigurationTest.php | 116 +++++++ 18 files changed, 1339 insertions(+), 2 deletions(-) create mode 100644 src/RowGroup/AggregationCalculator.php create mode 100644 src/RowGroup/AggregationDefinition.php create mode 100644 src/RowGroup/AggregationResult.php create mode 100644 src/RowGroup/AggregationType.php create mode 100644 src/RowGroup/RowGroupData.php create mode 100644 src/RowGroup/RowGroupFooterView.php create mode 100644 src/RowGroup/RowGroupHeaderView.php create mode 100644 src/RowGroup/RowGroupProcessor.php create mode 100644 src/RowGroup/RowGroupingConfiguration.php create mode 100644 tests/Unit/RowGroup/AggregationCalculatorTest.php create mode 100644 tests/Unit/RowGroup/RowGroupProcessorTest.php create mode 100644 tests/Unit/RowGroup/RowGroupingConfigurationTest.php 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 %} + + + {{ value_row.groupValue }} + {% for aggregation in value_row.aggregations %} + {{ aggregation.label ?? aggregation.columnName }}: {{ aggregation.value }} + {% endfor %} + + +{% endblock %} + +{% block table_body_row_group_footer %} + + + {{ 'Rows'|trans({}, 'KreyuDataTable') }}: {{ value_row.rowCount }} + {% for aggregation in value_row.aggregations %} + {{ aggregation.label ?? aggregation.columnName }}: {{ aggregation.value }} + {% endfor %} + + +{% 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..c4ee815e --- /dev/null +++ b/tests/Unit/RowGroup/RowGroupProcessorTest.php @@ -0,0 +1,303 @@ +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' => true, 'col2' => true])); + + // 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' => 1, 'b' => 2])); + + $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' => 1])); + + $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()); + } +} From 5726034e7609fd31ad31b8783f64be0ee78f86e2 Mon Sep 17 00:00:00 2001 From: Kamil Szalewski Date: Wed, 8 Apr 2026 09:33:07 +0200 Subject: [PATCH 2/2] Update RowGroupProcessorTest.php --- tests/Unit/RowGroup/RowGroupProcessorTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Unit/RowGroup/RowGroupProcessorTest.php b/tests/Unit/RowGroup/RowGroupProcessorTest.php index c4ee815e..bf60bb16 100644 --- a/tests/Unit/RowGroup/RowGroupProcessorTest.php +++ b/tests/Unit/RowGroup/RowGroupProcessorTest.php @@ -11,6 +11,7 @@ use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupHeaderView; use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupingConfiguration; use Kreyu\Bundle\DataTableBundle\RowGroup\RowGroupProcessor; +use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface; use Kreyu\Bundle\DataTableBundle\ValueRowView; use PHPUnit\Framework\TestCase; @@ -53,7 +54,7 @@ public function testSingleLevelGrouping(): void ]; $rows = $this->createValueRows($view, $data); - $result = iterator_to_array($this->processor->process($rows, $config, $view, ['col1' => true, 'col2' => true])); + $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); @@ -227,7 +228,7 @@ public function testGroupHeaderViewVars(): void ]; $rows = $this->createValueRows($view, $data); - $result = iterator_to_array($this->processor->process($rows, $config, $view, ['a' => 1, 'b' => 2])); + $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); @@ -250,7 +251,7 @@ public function testGroupFooterViewVars(): void ]; $rows = $this->createValueRows($view, $data); - $result = iterator_to_array($this->processor->process($rows, $config, $view, ['a' => 1])); + $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);