From 8f76b2b15c1f40e453604a0f02c52ed826cb7816 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 19:14:25 +0000 Subject: [PATCH 01/16] Merge GroupsBuilder into ReportBuilder with optional groupBy support Add groupBy/aggregation properties ($groupBy, $aggregateColumn, $aggregateFunction) to WithReportBuilder trait, controlled by $enableGroupBy flag. ReportBuilder overrides rowsQuery() to apply GROUP BY and aggregate SELECT when active. Report editor view gains conditional groupBy UI controls. GroupsBuilder now extends ReportBuilder with enableGroupBy=true and is marked deprecated. Closes #109 Co-Authored-By: Claude Opus 4.6 --- resources/views/group-table.blade.php | 222 +-------------------- resources/views/report-editor.blade.php | 38 ++++ src/GroupsBuilder.php | 10 +- src/ReportBuilder.php | 20 ++ src/Support/Concerns/WithGroupBuilder.php | 3 + src/Support/Concerns/WithReportBuilder.php | 92 ++++++++- 6 files changed, 152 insertions(+), 233 deletions(-) diff --git a/resources/views/group-table.blade.php b/resources/views/group-table.blade.php index 5959b7c..efc313e 100644 --- a/resources/views/group-table.blade.php +++ b/resources/views/group-table.blade.php @@ -1,220 +1,2 @@ -
- - @includeIf($this->headerView()) - - @if($this->availableColumns()) - @include('query-builder::report-editor') - @endif - - @if($this->columns()) - -
- @if ($this->showQueryBuilder()) -
- @include('query-builder::editor') - - @if(! $this->rows->count()) - - @endif -
- @endif - - @if($this->isToolbarVisible()) -
- -
- @if($this->isSearchVisible()) - @include('query-builder::components.search') - @endif -
- -
- - @if($this->areActionsVisible()) - @include('query-builder::components.actions') - @endif - - @if($this->isColumnSelectorVisible()) - @include('query-builder::components.columns-selector') - @endif - - @if($this->isRowSelectorVisible()) - @include('query-builder::components.rows-selector') - @endif -
-
- @endif - - @if($this->rows->count()) - -
- - - - - @if($selectable) - - @endif - - @foreach ($this->columns() as $column) - @if(in_array($column->key, $displayColumns)) - - @endif - @endforeach - - - useLoadingIndicator()) wire:loading.delay.longest.class="{{ $this->loadingClass }}" - wire:target.except="exportReportBuilder, saveReportBuilder, loadReportBuilder" @endif> - @if($selectable && $selectPage && $this->rows->count() < $this->rows->total()) - - - - @endif - @foreach ($this->rows as $row) - - @if($this->rowPreview($row)) - {!! $this->injectRow($row) !!} - @endif - - isClickable()) - {!! $this->renderRowClick($row->id) !!} - @endif - wire:key="row-{{ $row->id }}" - @class([ - 'bg-white border-b', - 'hover:bg-gray-50 cursor-pointer' => $this->isClickable(), - ])> - - @if($selectable) - - @endif - - @foreach ($this->columns() as $column) - @if(in_array($column->key, $displayColumns)) - - @endif - @endforeach - - @endforeach - -
-
- - -
-
isSortable()) wire:click="sort('{{ $column->key }}')" @endif> - @if ($column->showHeader) -
justify, - 'cursor-pointer' => $column->isSortable(), - ])> - {{ $column->label }} - - @if ($sortBy === $column->key) - @if ($sortDirection === 'desc') - - - - @else - - - - @endif - @endif - - @if($this->isSearchableIconVisible() && $column->isSearchable()) - - @endif - -
- @endif -
- @unless($selectAll) -
- You have selected {{ count($selectedRows) }} {{ Str::of('row')->plural(count($selectedRows)) }}. Do you want to select all {{ $this->rows->total() }}? - -
- @else - You have selected all {{ $this->rows->total() }} {{ Str::of('row')->plural(count($selectedRows)) }}. - @endif -
-
- - -
-
-
- - -
-
-
- - @if($this->isPaginated() && $this->rows->hasPages()) -
- @if($this->scroll() === true) -
{{ $this->rows->links() }}
- @else -
{{ $this->rows->links(data: ['scrollTo' => $this->scroll()]) }}
- @endif -
- @endif - @endif - - @if($this->useLoadingIndicator()) - {{-- Table loading spinners... --}} - @if($this->showOverlay) -
-
- @endif - -
- - - - -
- @endif - - -
- - @includeIf($this->footerView()) - @endif -
\ No newline at end of file +{{-- @deprecated Use report-table view instead --}} +@include('query-builder::report-table') diff --git a/resources/views/report-editor.blade.php b/resources/views/report-editor.blade.php index 47fbe1e..c54627b 100644 --- a/resources/views/report-editor.blade.php +++ b/resources/views/report-editor.blade.php @@ -22,6 +22,44 @@ class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">{{$column['lab @endforeach @endforeach + + @if($this->enableGroupBy) +
Group By
+
+
+ + +
+ + @if($this->groupBy) +
+ + +
+ +
+ + +
+ @endif +
+ @endif
diff --git a/src/GroupsBuilder.php b/src/GroupsBuilder.php index 0574add..11e8a74 100755 --- a/src/GroupsBuilder.php +++ b/src/GroupsBuilder.php @@ -4,15 +4,15 @@ namespace ACTTraining\QueryBuilder; -use ACTTraining\QueryBuilder\Support\Concerns\WithGroupBuilder; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; -abstract class GroupsBuilder extends QueryBuilder +/** + * @deprecated Use ReportBuilder with $enableGroupBy = true instead. + */ +abstract class GroupsBuilder extends ReportBuilder { - use WithGroupBuilder; - - public bool $selectable = false; + public bool $enableGroupBy = true; public function render(): Factory|View { diff --git a/src/ReportBuilder.php b/src/ReportBuilder.php index 15b5a95..cdcdd02 100755 --- a/src/ReportBuilder.php +++ b/src/ReportBuilder.php @@ -7,6 +7,7 @@ use ACTTraining\QueryBuilder\Support\Concerns\WithReportBuilder; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; +use Livewire\Attributes\Computed; abstract class ReportBuilder extends QueryBuilder { @@ -14,6 +15,25 @@ abstract class ReportBuilder extends QueryBuilder public bool $selectable = false; + #[Computed] + public function rowsQuery() + { + $query = parent::rowsQuery(); + + if (! $this->hasGroupBy()) { + return $query; + } + + if (! in_array($this->aggregateFunction, $this->aggregateFunctions(), true)) { + return $query; + } + + $query->selectRaw("{$this->groupBy}, {$this->aggregateFunction}({$this->aggregateColumn}) as aggregate") + ->groupBy($this->groupBy); + + return $query; + } + public function render(): Factory|View { return view('query-builder::report-table'); diff --git a/src/Support/Concerns/WithGroupBuilder.php b/src/Support/Concerns/WithGroupBuilder.php index dfc60a4..b59ab05 100644 --- a/src/Support/Concerns/WithGroupBuilder.php +++ b/src/Support/Concerns/WithGroupBuilder.php @@ -16,6 +16,9 @@ use Livewire\Attributes\Validate; use Symfony\Component\HttpFoundation\BinaryFileResponse; +/** + * @deprecated Use WithReportBuilder with $enableGroupBy = true instead. + */ trait WithGroupBuilder { public string $aggregateColumn = 'id'; // Default column to aggregate diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 722c06d..716db9a 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -22,17 +22,22 @@ trait WithReportBuilder #[Validate('required|array')] public array $selectedColumns = []; + public string $groupBy = ''; + + public string $aggregateColumn = 'id'; + + public string $aggregateFunction = 'COUNT'; + + public bool $enableGroupBy = false; + private function findElementByKey(array $array, $targetValue): ?array { foreach ($array as $value) { - // Check if the current item is an array if (is_array($value)) { - // Check if it contains the 'key' element with the target value if (isset($value['key']) && $value['key'] === $targetValue) { - return $value; // Return the found item + return $value; } - // Recursively search the sub-array $result = $this->findElementByKey($value, $targetValue); if ($result !== null) { return $result; @@ -40,7 +45,7 @@ private function findElementByKey(array $array, $targetValue): ?array } } - return null; // Return null if no match is found + return null; } public function updatedSelectedColumns(): void @@ -49,6 +54,71 @@ public function updatedSelectedColumns(): void $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); } + public function updatedGroupBy(): void + { + $this->resetPage(); + $this->dispatch('refreshTable')->self(); + } + + public function updatedAggregateFunction(): void + { + $this->resetPage(); + $this->dispatch('refreshTable')->self(); + } + + public function updatedAggregateColumn(): void + { + $this->resetPage(); + $this->dispatch('refreshTable')->self(); + } + + public function hasGroupBy(): bool + { + return $this->enableGroupBy && $this->groupBy !== ''; + } + + public function aggregateFunctions(): array + { + return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX']; + } + + public function groupableColumns(): array + { + $columns = []; + + foreach ($this->availableColumns() as $section => $sectionColumns) { + foreach ($sectionColumns as $column) { + if (! is_array($column) || ! isset($column['label'], $column['key'])) { + continue; + } + + $columns[] = $column; + } + } + + return $columns; + } + + public function aggregatableColumns(): array + { + $columns = [['label' => 'ID', 'key' => 'id']]; + + foreach ($this->availableColumns() as $section => $sectionColumns) { + foreach ($sectionColumns as $column) { + if (! is_array($column) || ! isset($column['label'], $column['key'])) { + continue; + } + + $type = $column['type'] ?? null; + if (in_array($type, ['number', 'float'], true)) { + $columns[] = $column; + } + } + } + + return $columns; + } + public function availableColumns(): array { return []; @@ -71,7 +141,6 @@ public function buildColumns(): array $counter = 0; foreach (($this->configuredColumns() ?? []) as $column) { - // Skip nulls/invalid items early if (! is_array($column) || ! isset($column['label'], $column['key'])) { continue; } @@ -106,6 +175,12 @@ public function buildColumns(): array $counter++; } + if ($this->hasGroupBy()) { + $columns[] = Column::make('Aggregate', 'aggregate') + ->justify('right') + ->sortable(); + } + return $columns; } @@ -114,7 +189,6 @@ public function buildConditions(): array $conditions = []; foreach (($this->configuredColumns() ?? []) as $column) { - // Skip nulls/garbage early if (! is_array($column) || ! isset($column['label'], $column['key'])) { continue; } @@ -125,7 +199,6 @@ public function buildConditions(): array $type = $column['type'] ?? null; - // Normalise enum options $options = $column['options'] ?? []; if (! is_array($options)) { $options = []; @@ -157,6 +230,9 @@ public function resetReportBuilder(): void { $this->criteria = []; $this->selectedColumns = []; + $this->groupBy = ''; + $this->aggregateColumn = 'id'; + $this->aggregateFunction = 'COUNT'; $this->saveToSession(); } From 85599c1f38228dc6a0ce0e858f6f6334b8d5d2c2 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 19:18:54 +0000 Subject: [PATCH 02/16] Improve README with comprehensive usage examples Add documentation for all three builder types (QueryBuilder, TableBuilder, ReportBuilder), the new GroupBy feature, column types, conditions, filters, and relationship support. Co-Authored-By: Claude Opus 4.6 --- README.md | 255 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 238 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c995a34..6080b5c 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,62 @@ -# A drop in query builder for Laravel models. +# Query Builder for Laravel -Add a query builder and report table for youir models. +Drop-in Livewire components for building query builders, tables, and reports on Eloquent models. ## Installation -You can install the package via composer: - ```bash composer require act-training/query-builder ``` -Optionally, you can publish the views using +Optionally, publish the views: ```bash php artisan vendor:publish --tag="query-builder-views" ``` -## Usage -Models should use the AppliesCriteria trait. +## Model Setup + +Models should use the `AppliesCriteria` trait: ```php -class User extends Authenticatable +use ACTTraining\QueryBuilder\Support\Criteria\AppliesCriteria; + +class Employee extends Model { use AppliesCriteria; - - ... - } ``` -Create a Livewire component and make sure it extends QueryBuilder. +## Components + +The package provides three main abstract Livewire components to extend: + +| Component | Purpose | +|---|---| +| `QueryBuilder` | Full query builder with criteria-based AND/OR filtering | +| `TableBuilder` | Simpler table with URL-persisted filters and search | +| `ReportBuilder` | Dynamic column selection, save/load/export, optional groupBy | + +### QueryBuilder + +Create a Livewire component that extends `QueryBuilder`. Define `query()`, `columns()`, and `conditions()`: ```php rowClickable(false); } + public function query(): Builder + { + return Employee::query()->with(['contract', 'contract.job', 'contract.location']); + } + public function columns(): array { return [ @@ -74,20 +97,218 @@ class EmployeesReport extends QueryBuilder DateCondition::make('Start Date', 'contract.start_date'), ]; } +} +``` + +### TableBuilder + +A simpler alternative using filters instead of criteria. Define `query()`, `columns()`, and `filters()`: + +```php +sortable()->searchable(), + Column::make('Department', 'department')->sortable(), + ]; + } + + public function filters(): array + { + return [ + TextFilter::make('Name', 'full_name'), + SelectFilter::make('Department', 'department') + ->options(['HR' => 'HR', 'IT' => 'IT', 'Finance' => 'Finance']), + ]; + } +} +``` + +### ReportBuilder + +Extends `QueryBuilder` with dynamic column selection. Users pick columns at runtime from `availableColumns()`, and the component builds columns and conditions automatically: + +```php +with(['contract', 'contract.job', 'contract.location']); } - public function rowClick($row): void + public function availableColumns(): array { - $this->dispatchBrowserEvent('notify', ['content' => 'The row was clicked', 'type' => 'success']); + return [ + 'Employee' => [ + ['label' => 'Name', 'key' => 'full_name'], + ['label' => 'Email', 'key' => 'email'], + ], + 'Contract' => [ + ['label' => 'Job Title', 'key' => 'contract.job.name'], + ['label' => 'Location', 'key' => 'contract.location.name'], + ['label' => 'Start Date', 'key' => 'contract.start_date', 'type' => 'date'], + ['label' => 'Salary', 'key' => 'contract.salary', 'type' => 'number'], + ['label' => 'Line Manager', 'key' => 'contract.line_manager', 'type' => 'boolean'], + ], + ]; + } +} +``` + +Column definitions in `availableColumns()` support these keys: + +| Key | Description | +|---|---| +| `label` | Display label (required) | +| `key` | Column key, supports dot-notation for relationships (required) | +| `type` | Column type: `text` (default), `number`, `float`, `boolean`, `date`, `enum`, `null`, `view` | +| `sortable` | Enable sorting on this column | +| `justify` | Alignment: `left`, `center`, `right` | +| `view` | Custom Blade component for rendering | +| `options` | Options array for `enum` type | +| `skipCondition` | Exclude from query builder conditions | + +### ReportBuilder with GroupBy + +Enable groupBy to allow users to group results by a column with aggregate functions (COUNT, SUM, AVG, MIN, MAX). Set `$enableGroupBy = true`: + +```php +with(['contract', 'contract.job', 'contract.location']); } + public function availableColumns(): array + { + return [ + 'Employee' => [ + ['label' => 'Name', 'key' => 'full_name'], + ['label' => 'Department', 'key' => 'department'], + ], + 'Contract' => [ + ['label' => 'Job Title', 'key' => 'contract.job.name'], + ['label' => 'Location', 'key' => 'contract.location.name'], + ['label' => 'Salary', 'key' => 'contract.salary', 'type' => 'number'], + ], + ]; + } } ``` +When `enableGroupBy` is true, the report editor UI shows additional controls: + +- **Group By Column** - select which column to group by +- **Function** - aggregate function (COUNT, SUM, AVG, MIN, MAX) +- **Aggregate Column** - which column to aggregate (defaults to `id`, numeric columns from `availableColumns()` are included automatically) + +An "Aggregate" column is automatically appended to the table when grouping is active. + +You can customise the available aggregate functions and groupable/aggregatable columns by overriding: + +```php +public function aggregateFunctions(): array +{ + return ['COUNT', 'SUM', 'AVG']; +} + +public function groupableColumns(): array +{ + // Return a flat array of ['label' => ..., 'key' => ...] items + return [ + ['label' => 'Department', 'key' => 'department'], + ['label' => 'Location', 'key' => 'contract.location.name'], + ]; +} + +public function aggregatableColumns(): array +{ + return [ + ['label' => 'ID', 'key' => 'id'], + ['label' => 'Salary', 'key' => 'contract.salary'], + ]; +} +``` + +## Columns + +All column types use a fluent `make($label, $key)` constructor. If `$key` is omitted, it defaults to `Str::snake($label)`. + +| Column Type | Description | +|---|---| +| `Column` | General text column | +| `BooleanColumn` | Boolean/checkbox display | +| `DateColumn` | Date formatting with `->format()` and `->humanDiff()` | +| `ViewColumn` | Custom Blade view rendering | + +Columns support: `->sortable()`, `->searchable()`, `->justify('right')`, `->component('view.name')`, `->reformatUsing(callable)`, `->withSubTitle(callable)`, `->hideIf(condition)`, `->hideHeader()`. + +## Conditions (QueryBuilder) + +Used with `QueryBuilder` and `ReportBuilder` for criteria-based filtering: + +| Condition Type | Operations | +|---|---| +| `TextCondition` | equals, not_equals, contains, starts_with, ends_with | +| `NumberCondition` | equals, not_equals, greater_than, less_than, is_between | +| `FloatCondition` | equals, not_equals, greater_than, less_than, is_between | +| `BooleanCondition` | is_true, is_false | +| `DateCondition` | equals, before, after, is_between | +| `EnumCondition` | equals, not_equals (with defined options) | +| `NullCondition` | is_set, is_not_set | + +## Filters (TableBuilder) + +Used with `TableBuilder` for simpler key/operator/value filtering: + +`TextFilter`, `NumberFilter`, `DateFilter`, `BooleanFilter`, `SelectFilter`, `NullFilter` + +## Relationship Support + +Dot-notation keys work throughout columns, conditions, and filters to traverse Eloquent relationships: + +```php +Column::make('Job Title', 'contract.job.name') +TextCondition::make('Location', 'contract.location.name') +``` + ## Testing ```bash From 4db28aa312c32ae2e40572849f4256f49088a404 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 19:45:44 +0000 Subject: [PATCH 03/16] Fix groupBy to resolve dot-notation keys into SQL joins Dot-notation column keys (e.g. contract.department.name) are Eloquent relationship paths, not SQL columns. The groupBy/aggregate selectRaw was using them directly, causing "column not found" errors. - Add resolveColumnWithJoins() to walk the relationship chain and add proper joins for BelongsTo/HasOne/HasMany relationships - Replace normal columns with group_value + aggregate columns when grouping is active - Update displayColumns on groupBy/aggregate property changes Closes #109 Co-Authored-By: Claude Opus 4.6 --- src/ReportBuilder.php | 62 +++++++++++++++++++++- src/Support/Concerns/WithReportBuilder.php | 17 +++--- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/ReportBuilder.php b/src/ReportBuilder.php index cdcdd02..659588b 100755 --- a/src/ReportBuilder.php +++ b/src/ReportBuilder.php @@ -7,6 +7,10 @@ use ACTTraining\QueryBuilder\Support\Concerns\WithReportBuilder; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Livewire\Attributes\Computed; abstract class ReportBuilder extends QueryBuilder @@ -28,12 +32,66 @@ public function rowsQuery() return $query; } - $query->selectRaw("{$this->groupBy}, {$this->aggregateFunction}({$this->aggregateColumn}) as aggregate") - ->groupBy($this->groupBy); + $groupByColumn = $this->resolveColumnWithJoins($query, $this->groupBy); + $baseTable = $query->getModel()->getTable(); + $aggregateColumn = str_contains($this->aggregateColumn, '.') + ? $this->resolveColumnWithJoins($query, $this->aggregateColumn) + : "{$baseTable}.{$this->aggregateColumn}"; + + $query->selectRaw("{$groupByColumn} as group_value, {$this->aggregateFunction}({$aggregateColumn}) as aggregate") + ->groupBy($groupByColumn); return $query; } + protected function resolveColumnWithJoins(Builder $query, string $key): string + { + if (! str_contains($key, '.')) { + return $query->getModel()->getTable().'.'.$key; + } + + $parts = explode('.', $key); + $columnName = array_pop($parts); + $currentModel = $query->getModel(); + $joined = []; + + foreach ($parts as $relationName) { + if (! method_exists($currentModel, $relationName)) { + return $key; + } + + $relation = $currentModel->{$relationName}(); + $relatedTable = $relation->getRelated()->getTable(); + + if (in_array($relatedTable, $joined, true)) { + $currentModel = $relation->getRelated(); + + continue; + } + + if ($relation instanceof BelongsTo) { + $query->join( + $relatedTable, + $currentModel->getTable().'.'.$relation->getForeignKeyName(), + '=', + $relatedTable.'.'.$relation->getOwnerKeyName() + ); + } elseif ($relation instanceof HasOne || $relation instanceof HasMany) { + $query->join( + $relatedTable, + $currentModel->getTable().'.'.$currentModel->getKeyName(), + '=', + $relatedTable.'.'.$relation->getForeignKeyName() + ); + } + + $joined[] = $relatedTable; + $currentModel = $relation->getRelated(); + } + + return $currentModel->getTable().'.'.$columnName; + } + public function render(): Factory|View { return view('query-builder::report-table'); diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 716db9a..08319c6 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -57,19 +57,19 @@ public function updatedSelectedColumns(): void public function updatedGroupBy(): void { $this->resetPage(); - $this->dispatch('refreshTable')->self(); + $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); } public function updatedAggregateFunction(): void { $this->resetPage(); - $this->dispatch('refreshTable')->self(); + $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); } public function updatedAggregateColumn(): void { $this->resetPage(); - $this->dispatch('refreshTable')->self(); + $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); } public function hasGroupBy(): bool @@ -176,9 +176,14 @@ public function buildColumns(): array } if ($this->hasGroupBy()) { - $columns[] = Column::make('Aggregate', 'aggregate') - ->justify('right') - ->sortable(); + $groupByLabel = $this->findElementByKey($this->availableColumns(), $this->groupBy)['label'] ?? $this->groupBy; + + return [ + Column::make($groupByLabel, 'group_value')->sortable(), + Column::make("{$this->aggregateFunction}({$this->aggregateColumn})", 'aggregate') + ->justify('right') + ->sortable(), + ]; } return $columns; From fbb97703851225e8244764446597936faf0414f3 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 19:48:08 +0000 Subject: [PATCH 04/16] Clear existing ordering when groupBy is active MySQL's ONLY_FULL_GROUP_BY mode rejects ORDER BY clauses that reference non-aggregated columns not in the GROUP BY. Use reorder() to clear inherited ordering and default to ordering by the group column. Co-Authored-By: Claude Opus 4.6 --- src/ReportBuilder.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ReportBuilder.php b/src/ReportBuilder.php index 659588b..c168dd9 100755 --- a/src/ReportBuilder.php +++ b/src/ReportBuilder.php @@ -38,8 +38,10 @@ public function rowsQuery() ? $this->resolveColumnWithJoins($query, $this->aggregateColumn) : "{$baseTable}.{$this->aggregateColumn}"; - $query->selectRaw("{$groupByColumn} as group_value, {$this->aggregateFunction}({$aggregateColumn}) as aggregate") - ->groupBy($groupByColumn); + $query->reorder() + ->selectRaw("{$groupByColumn} as group_value, {$this->aggregateFunction}({$aggregateColumn}) as aggregate") + ->groupBy($groupByColumn) + ->orderBy($groupByColumn); return $query; } From 8bbf6830249d0457781abb946aa6453f3b6b6d4e Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 19:49:58 +0000 Subject: [PATCH 05/16] Remove 'order' global scope when groupBy is active Global scopes are applied at query execution time, after reorder(). The Employee model's global 'order' scope re-adds last_name/first_name ordering which conflicts with GROUP BY under ONLY_FULL_GROUP_BY mode. Co-Authored-By: Claude Opus 4.6 --- src/ReportBuilder.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ReportBuilder.php b/src/ReportBuilder.php index c168dd9..fbb2a85 100755 --- a/src/ReportBuilder.php +++ b/src/ReportBuilder.php @@ -38,7 +38,8 @@ public function rowsQuery() ? $this->resolveColumnWithJoins($query, $this->aggregateColumn) : "{$baseTable}.{$this->aggregateColumn}"; - $query->reorder() + $query->withoutGlobalScope('order') + ->reorder() ->selectRaw("{$groupByColumn} as group_value, {$this->aggregateFunction}({$aggregateColumn}) as aggregate") ->groupBy($groupByColumn) ->orderBy($groupByColumn); From 6c4cac13a4c95f9f2468cfc64c6f27d51ae074f7 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:08:25 +0000 Subject: [PATCH 06/16] Change groupBy default from empty string to null Flux listbox select with placeholder requires null (not empty string) to show the placeholder text. Empty string causes the first option to be selected by default. Co-Authored-By: Claude Opus 4.6 --- src/Support/Concerns/WithReportBuilder.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 08319c6..a8281f3 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -22,7 +22,7 @@ trait WithReportBuilder #[Validate('required|array')] public array $selectedColumns = []; - public string $groupBy = ''; + public ?string $groupBy = null; public string $aggregateColumn = 'id'; @@ -74,7 +74,7 @@ public function updatedAggregateColumn(): void public function hasGroupBy(): bool { - return $this->enableGroupBy && $this->groupBy !== ''; + return $this->enableGroupBy && $this->groupBy !== null; } public function aggregateFunctions(): array @@ -235,7 +235,7 @@ public function resetReportBuilder(): void { $this->criteria = []; $this->selectedColumns = []; - $this->groupBy = ''; + $this->groupBy = null; $this->aggregateColumn = 'id'; $this->aggregateFunction = 'COUNT'; $this->saveToSession(); From cc370265854b1a48492baeb23c9d9c9383db609e Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:15:43 +0000 Subject: [PATCH 07/16] Add availableGroupByColumns() for configurable groupBy options Allows consuming apps to define a separate set of groupable columns instead of defaulting to all available columns. Falls back to availableColumns() when not overridden. Co-Authored-By: Claude Opus 4.6 --- src/Support/Concerns/WithReportBuilder.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index a8281f3..08b0012 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -82,11 +82,16 @@ public function aggregateFunctions(): array return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX']; } + public function availableGroupByColumns(): array + { + return $this->availableColumns(); + } + public function groupableColumns(): array { $columns = []; - foreach ($this->availableColumns() as $section => $sectionColumns) { + foreach ($this->availableGroupByColumns() as $section => $sectionColumns) { foreach ($sectionColumns as $column) { if (! is_array($column) || ! isset($column['label'], $column['key'])) { continue; From 116230c51290cccf578f538192cdfb54555c400d Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:20:16 +0000 Subject: [PATCH 08/16] Reset aggregate column to id when COUNT is selected COUNT always operates on id so the aggregate column picker is unnecessary. Reset to id when switching back to COUNT. Co-Authored-By: Claude Opus 4.6 --- src/Support/Concerns/WithReportBuilder.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 08b0012..368ca2b 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -62,6 +62,10 @@ public function updatedGroupBy(): void public function updatedAggregateFunction(): void { + if ($this->aggregateFunction === 'COUNT') { + $this->aggregateColumn = 'id'; + } + $this->resetPage(); $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); } From 35cfee9d0576c92241de7d13abcf3aab3ee70380 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:22:37 +0000 Subject: [PATCH 09/16] Remove ID from aggregatable columns, hardcode for COUNT COUNT always uses the base table's id column directly. The ID option is removed from the aggregatable columns list since it's only relevant to COUNT which no longer needs a column picker. Co-Authored-By: Claude Opus 4.6 --- src/ReportBuilder.php | 11 ++++++++--- src/Support/Concerns/WithReportBuilder.php | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/ReportBuilder.php b/src/ReportBuilder.php index fbb2a85..5f9c5ac 100755 --- a/src/ReportBuilder.php +++ b/src/ReportBuilder.php @@ -34,9 +34,14 @@ public function rowsQuery() $groupByColumn = $this->resolveColumnWithJoins($query, $this->groupBy); $baseTable = $query->getModel()->getTable(); - $aggregateColumn = str_contains($this->aggregateColumn, '.') - ? $this->resolveColumnWithJoins($query, $this->aggregateColumn) - : "{$baseTable}.{$this->aggregateColumn}"; + + if ($this->aggregateFunction === 'COUNT') { + $aggregateColumn = "{$baseTable}.id"; + } else { + $aggregateColumn = str_contains($this->aggregateColumn, '.') + ? $this->resolveColumnWithJoins($query, $this->aggregateColumn) + : "{$baseTable}.{$this->aggregateColumn}"; + } $query->withoutGlobalScope('order') ->reorder() diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 368ca2b..266b4c6 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -110,7 +110,7 @@ public function groupableColumns(): array public function aggregatableColumns(): array { - $columns = [['label' => 'ID', 'key' => 'id']]; + $columns = []; foreach ($this->availableColumns() as $section => $sectionColumns) { foreach ($sectionColumns as $column) { From a8d85fd86eb90bc4d97c3d212d1978b1a7d1cf8f Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:24:51 +0000 Subject: [PATCH 10/16] Use filled() for hasGroupBy check to handle both null and empty string Flux clearable select sets value to empty string, not null. Using filled() handles both cases. Co-Authored-By: Claude Opus 4.6 --- src/Support/Concerns/WithReportBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 266b4c6..a10c3d9 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -78,7 +78,7 @@ public function updatedAggregateColumn(): void public function hasGroupBy(): bool { - return $this->enableGroupBy && $this->groupBy !== null; + return $this->enableGroupBy && filled($this->groupBy); } public function aggregateFunctions(): array From 251c5163c37f4ffc088f2e898fe910817c117a25 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:30:11 +0000 Subject: [PATCH 11/16] Apply view component to groupBy column in grouped results When a groupBy column has a view component defined (e.g. enum, company), use it for rendering the grouped column values instead of plain text. Co-Authored-By: Claude Opus 4.6 --- src/Support/Concerns/WithReportBuilder.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index a10c3d9..f43a80e 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -185,11 +185,22 @@ public function buildColumns(): array } if ($this->hasGroupBy()) { - $groupByLabel = $this->findElementByKey($this->availableColumns(), $this->groupBy)['label'] ?? $this->groupBy; + $groupByConfig = $this->findElementByKey($this->availableColumns(), $this->groupBy); + $groupByLabel = $groupByConfig['label'] ?? $this->groupBy; + + $groupColumn = Column::make($groupByLabel, 'group_value')->sortable(); + + if (! empty($groupByConfig['view'])) { + $groupColumn->component($groupByConfig['view']); + } + + $aggregateLabel = $this->aggregateFunction === 'COUNT' + ? 'Count' + : "{$this->aggregateFunction}({$this->aggregateColumn})"; return [ - Column::make($groupByLabel, 'group_value')->sortable(), - Column::make("{$this->aggregateFunction}({$this->aggregateColumn})", 'aggregate') + $groupColumn, + Column::make($aggregateLabel, 'aggregate') ->justify('right') ->sortable(), ]; From 045d31339e924eab81ed52b29295ce040a2491b6 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:31:27 +0000 Subject: [PATCH 12/16] Look up groupBy column config from availableGroupByColumns The view component definition lives in the group_by config, not the main columns config. Co-Authored-By: Claude Opus 4.6 --- src/Support/Concerns/WithReportBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index f43a80e..3160946 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -185,7 +185,7 @@ public function buildColumns(): array } if ($this->hasGroupBy()) { - $groupByConfig = $this->findElementByKey($this->availableColumns(), $this->groupBy); + $groupByConfig = $this->findElementByKey($this->availableGroupByColumns(), $this->groupBy); $groupByLabel = $groupByConfig['label'] ?? $this->groupBy; $groupColumn = Column::make($groupByLabel, 'group_value')->sortable(); From d556e2f6a827a19f456670a8343fc0e15e85105f Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:37:13 +0000 Subject: [PATCH 13/16] Add reformatUsing for group column options mapping When a group_by config entry includes an options array, use reformatUsing to map raw database values to human-readable labels (e.g. enum values). Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 7 ++++++- src/Support/Concerns/WithReportBuilder.php | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2027cce..dc9c5aa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,12 @@ "Bash(vendor/bin/pest:*)", "Bash(vendor/bin/pint:*)", "Bash(git push:*)", - "Bash(gh pr:*)" + "Bash(gh pr:*)", + "Bash(composer analyse:*)", + "Bash(composer test:*)", + "Bash(composer format:*)", + "Bash(git stash:*)", + "Bash(git commit:*)" ] } } diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 3160946..af8ff3f 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -194,6 +194,11 @@ public function buildColumns(): array $groupColumn->component($groupByConfig['view']); } + $options = $groupByConfig['options'] ?? []; + if (! empty($options)) { + $groupColumn->reformatUsing(fn ($value) => $options[$value] ?? $value); + } + $aggregateLabel = $this->aggregateFunction === 'COUNT' ? 'Count' : "{$this->aggregateFunction}({$this->aggregateColumn})"; From f4f6ada424a932ee7ab80ee7b739a0d8cbe0ba4d Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:47:59 +0000 Subject: [PATCH 14/16] Apply view component to aggregate column for formatted display When using SUM/AVG/MIN/MAX, look up the aggregate column's config and apply its view component (e.g. currency, integer-to-float) so values display formatted rather than raw. Co-Authored-By: Claude Opus 4.6 --- src/Support/Concerns/WithReportBuilder.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index af8ff3f..762d64a 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -203,11 +203,21 @@ public function buildColumns(): array ? 'Count' : "{$this->aggregateFunction}({$this->aggregateColumn})"; + $aggregateCol = Column::make($aggregateLabel, 'aggregate') + ->justify('right') + ->sortable(); + + if ($this->aggregateFunction !== 'COUNT') { + $aggregateConfig = $this->findElementByKey($this->availableColumns(), $this->aggregateColumn); + + if (! empty($aggregateConfig['view'])) { + $aggregateCol->component($aggregateConfig['view']); + } + } + return [ $groupColumn, - Column::make($aggregateLabel, 'aggregate') - ->justify('right') - ->sortable(), + $aggregateCol, ]; } From c4a217b3a84bfa14c0f01b026f9aa09efbce7ae2 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 20:52:26 +0000 Subject: [PATCH 15/16] Use friendly labels for aggregate functions and column header Rename functions to Count/Sum/Average/Minimum/Maximum. Change 'Aggregate Column' label to 'Value Column'. Use human-readable column header like 'Sum of Salary' instead of 'SUM(payroll.salary)'. Co-Authored-By: Claude Opus 4.6 --- src/ReportBuilder.php | 2 +- src/Support/Concerns/WithReportBuilder.php | 26 ++++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/ReportBuilder.php b/src/ReportBuilder.php index 5f9c5ac..2abcfb3 100755 --- a/src/ReportBuilder.php +++ b/src/ReportBuilder.php @@ -28,7 +28,7 @@ public function rowsQuery() return $query; } - if (! in_array($this->aggregateFunction, $this->aggregateFunctions(), true)) { + if (! array_key_exists($this->aggregateFunction, $this->aggregateFunctions())) { return $query; } diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 762d64a..54ea3ea 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -83,7 +83,13 @@ public function hasGroupBy(): bool public function aggregateFunctions(): array { - return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX']; + return [ + 'COUNT' => 'Count', + 'SUM' => 'Sum', + 'AVG' => 'Average', + 'MIN' => 'Minimum', + 'MAX' => 'Maximum', + ]; } public function availableGroupByColumns(): array @@ -199,20 +205,22 @@ public function buildColumns(): array $groupColumn->reformatUsing(fn ($value) => $options[$value] ?? $value); } + $functionLabel = $this->aggregateFunctions()[$this->aggregateFunction] ?? $this->aggregateFunction; + $aggregateConfig = $this->aggregateFunction !== 'COUNT' + ? $this->findElementByKey($this->availableColumns(), $this->aggregateColumn) + : null; + $aggregateColumnLabel = $aggregateConfig['label'] ?? $this->aggregateColumn; + $aggregateLabel = $this->aggregateFunction === 'COUNT' - ? 'Count' - : "{$this->aggregateFunction}({$this->aggregateColumn})"; + ? $functionLabel + : "{$functionLabel} of {$aggregateColumnLabel}"; $aggregateCol = Column::make($aggregateLabel, 'aggregate') ->justify('right') ->sortable(); - if ($this->aggregateFunction !== 'COUNT') { - $aggregateConfig = $this->findElementByKey($this->availableColumns(), $this->aggregateColumn); - - if (! empty($aggregateConfig['view'])) { - $aggregateCol->component($aggregateConfig['view']); - } + if ($aggregateConfig && ! empty($aggregateConfig['view'])) { + $aggregateCol->component($aggregateConfig['view']); } return [ From 333722b31424bd1413c00402c06d3acac4b43d97 Mon Sep 17 00:00:00 2001 From: Simon Barrett Date: Sun, 8 Mar 2026 21:00:58 +0000 Subject: [PATCH 16/16] Fix duplicate joins when resolving multiple columns Check query's existing joins instead of a local array, preventing duplicate table joins when resolveColumnWithJoins is called multiple times (e.g. for both groupBy and aggregate columns). Co-Authored-By: Claude Opus 4.6 --- src/ReportBuilder.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ReportBuilder.php b/src/ReportBuilder.php index 2abcfb3..ae61737 100755 --- a/src/ReportBuilder.php +++ b/src/ReportBuilder.php @@ -61,7 +61,9 @@ protected function resolveColumnWithJoins(Builder $query, string $key): string $parts = explode('.', $key); $columnName = array_pop($parts); $currentModel = $query->getModel(); - $joined = []; + $existingJoins = collect($query->getQuery()->joins ?? []) + ->pluck('table') + ->all(); foreach ($parts as $relationName) { if (! method_exists($currentModel, $relationName)) { @@ -71,7 +73,7 @@ protected function resolveColumnWithJoins(Builder $query, string $key): string $relation = $currentModel->{$relationName}(); $relatedTable = $relation->getRelated()->getTable(); - if (in_array($relatedTable, $joined, true)) { + if (in_array($relatedTable, $existingJoins, true)) { $currentModel = $relation->getRelated(); continue; @@ -93,7 +95,7 @@ protected function resolveColumnWithJoins(Builder $query, string $key): string ); } - $joined[] = $relatedTable; + $existingJoins[] = $relatedTable; $currentModel = $relation->getRelated(); }