diff --git a/.gitignore b/.gitignore index 00e71ec7..a9c0e146 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ vendor coverage .DS_Store .vscode/ +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f65bc51a..a5e189b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `livewire-datatables` will be documented in this file -## 1.0.0 - 201X-XX-XX +## 1.0.0 - 201X-XX-XX ( to be released in the future... ) - initial release + +## 0.9.0 ( 2022-03-22 ) + +- Breaking Change: 'unsortable' has been renamed to 'sortable', which is more intuitive. Please adjust your overwritten views, if any (thyseus). + diff --git a/README.md b/README.md index 58ccb9ed..ff4e45af 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ - Filter using booleans, times, dates, selects or free text - Create complex combined filters using the [complex query builder](#complex-query-builder) - Show / hide columns +- Column groups +- Mass Action (Bulk) Support ## [Live Demo App](https://livewire-datatables.com) @@ -18,7 +20,7 @@ ![screenshot](resources/images/screenshot.png "Screenshot") ## Requirements -- [Laravel 7](https://laravel.com/docs/7.x) +- [Laravel 7, 8 or 9](https://laravel.com/docs/9.x) - [Livewire](https://laravel-livewire.com/) - [Tailwind](https://tailwindcss.com/) - [Alpine JS](https://github.com/alpinejs/alpine) @@ -30,6 +32,11 @@ You can install the package via composer: ```bash composer require mediconesystems/livewire-datatables ``` +If you use laravel 9 first execute +```bash +composer require psr/simple-cache:^1.0 maatwebsite/excel +``` + ### Optional You don't need to, but if you like you can publish the config file and blade template assets: ```bash @@ -52,7 +59,7 @@ somewhere in your CSS ```html ... - + ... ``` @@ -68,38 +75,41 @@ somewhere in your CSS /> ``` -- *Attention*: Please note that having multiple datatables on the same page _or_ more than one datatable of the same type on different pages needs to have a unique `name` attribute assigned to each one so they do not conflict with each other as in the example above. +*Attention*: Please note that having multiple datatables on the same page _or_ more than one datatable of the same +type on different pages needs to have a unique `name` attribute assigned to each one so they do not conflict with each +other as in the example above. ### Props -| Property | Arguments | Result | Example | -|----|----|----|----| -|**model**|*String* full model name|Define the base model for the table| ```model="App\Post"```| -|**include**|*String\|Array* of column definitions|specify columns to be shown in table, label can be specified by using \| delimter | ```include="name, email, dob\|Birth Date, role"```| -|**exclude**|*String\|Array* of column definitions|columns are excluded from table| ```:exclude="['created_at', 'updated_at']"```| -|**hide**|*String\|Array* of column definitions|columns are present, but start hidden|```hidden="email_verified_at"```| -|**dates**|*String\|Array* of column definitions [ and optional format in \| delimited string]|column values are formatted as per the default date format, or format can be included in string with \| separator | ```:dates="['dob\|lS F y', 'created_at']"```| -|**times**|*String\|Array* of column definitions [optional format in \| delimited string]|column values are formatted as per the default time format, or format can be included in string with \| separator | ```'bedtime\|g:i A'```| -|**searchable**|*String\|Array* of column names | Defines columns to be included in global search | ```searchable="name, email"```| -|**sort**|*String* of column definition [and optional 'asc' or 'desc' (default: 'desc') in \| delimited string]|Specifies the column and direction for initial table sort. Default is column 0 descending | ```sort="name\|asc"```| -|**hide-header**|*Boolean* default: *false*|The top row of the table including the column titles is removed if this is ```true```| | -|**hide-pagination**|*Boolean* default: *false*|Pagination controls are removed if this is ```true```| | -|**per-page**|*Integer* default: 10|Number of rows per page| ```per-page="20"``` | -|**exportable**|*Boolean* default: *false*|Allows table to be exported| `````` | -|**hideable**| _String_ | gives ability to show/hide columns, accepts strings 'inline', 'buttons', or 'select'| `````` | -|**beforeTableSlot**| _String_ |blade view to be included immediately before the table in the component, which can therefore access public properties| | -|**afterTableSlot**| _String_ |blade view to be included immediately after the table in the component, which can therefore access public properties| [demo](https://livewire-datatables.com/complex) | +| Property | Arguments | Result | Example | +|----|----|------------------------------------------------------------------------------------------------------------------------------------------------|----| +|**model**|*String* full model name| Define the base model for the table | ```model="App\Post"```| +|**include**|*String\| Array* of column definitions |specify columns to be shown in table, label can be specified by using \| delimter | ```include="name, email, dob\|Birth Date, role"```| +|**exclude**|*String\| Array* of column definitions |columns are excluded from table| ```:exclude="['created_at', 'updated_at']"```| +|**hide**|*String\| Array* of column definitions |columns are present, but start hidden|```hidden="email_verified_at"```| +|**dates**|*String\| Array* of column definitions [ and optional format in \ | delimited string]|column values are formatted as per the default date format, or format can be included in string with \| separator | ```:dates="['dob\|lS F y', 'created_at']"```| +|**times**|*String\| Array* of column definitions [optional format in \ | delimited string]|column values are formatted as per the default time format, or format can be included in string with \| separator | ```'bedtime\|g:i A'```| +|**searchable**|*String\| Array* of column names | Defines columns to be included in global search | ```searchable="name, email"```| +|**sort**|*String* of column definition [and optional 'asc' or 'desc' (default: 'desc') in \| delimited string] |Specifies the column and direction for initial table sort. Default is column 0 descending | ```sort="name\|asc"```| +|**hide-header**|*Boolean* default: *false*| The top row of the table including the column titles is removed if this is ```true``` | | +|**hide-pagination**|*Boolean* default: *false*| Pagination controls are removed if this is ```true``` | | +|**per-page**|*Integer* default: 10| Number of rows per page | ```per-page="20"``` | +|**exportable**|*Boolean* default: *false*| Allows table to be exported | `````` | +|**hideable**| _String_ | gives ability to show/hide columns, accepts strings 'inline', 'buttons', or 'select' | `````` | +|**buttonsSlot**| _String_ | blade view to be included immediately after the buttons at the top of the table in the component, which can therefore access public properties | | +|**beforeTableSlot**| _String_ | blade view to be included immediately before the table in the component, which can therefore access public properties | | +|**afterTableSlot**| _String_ | blade view to be included immediately after the table in the component, which can therefore access public properties | [demo](https://livewire-datatables.com/complex) | --- ## Component Syntax ### Create a livewire component that extends ```Mediconesystems\LivewireDatatables\LivewireDatatable``` -> ```php artisan livewire:datatable foo``` --> 'app/Http/Livewire/Foo.php' +> ```php artisan make:livewire-datatable foo``` --> 'app/Http/Livewire/Foo.php' -> ```php artisan livewire:datatable tables.bar``` --> 'app/Http/Livewire/Tables/Bar.php' +> ```php artisan make:livewire-datatable tables.bar``` --> 'app/Http/Livewire/Tables/Bar.php' ### Provide a datasource by declaring public property ```$model``` **OR** public method ```builder()``` that returns an instance of ```Illuminate\Database\Eloquent\Builder``` -> ```php artisan livewire:datatable users-table --model=user``` --> 'app/Http/Livewire/UsersTable.php' with ```public $model = User::class``` +> ```php artisan make:livewire-datatable users-table --model=user``` --> 'app/Http/Livewire/UsersTable.php' with ```public $model = User::class``` ### Declare a public method ```columns``` that returns an array containing one or more ```Mediconesystems\LivewireDatatables\Column``` @@ -152,6 +162,9 @@ class ComplexDemoTable extends LivewireDatatable ->hideable() ->filterable($this->planets), + // Column that counts every line from 1 upwards, independent of content + Column::index($this); + DateColumn::name('dob') ->label('DOB') ->group('group2') @@ -160,7 +173,10 @@ class ComplexDemoTable extends LivewireDatatable (new LabelColumn()) ->label('My custom heading') - ->content('This fixed string appears in every row') + ->content('This fixed string appears in every row'), + + NumberColumn::name('dollars_spent') + ->enableSummary(), ]; } } @@ -188,11 +204,16 @@ class ComplexDemoTable extends LivewireDatatable |**searchable**| |Includes the column in the global search|```Column::name('name')->searchable()```| |**hideable**| |The user is able to toggle the visibility of this column|```Column::name('name')->hideable()```| |**filterable**|[*Array* $options], [*String* $filterScope]|Adds a filter to the column, according to Column type. If an array of options is passed it wil be used to populate a select input. If the column is a scope column then the name of the filter scope must also be passed|```Column::name('allegiance')->filterable(['Rebellion', 'Empire'])```| +|**unwrap**| | Prevents the content of the column from being wrapped in multiple lines |```Column::name('oneliner')->unwrap()```| |**filterOn**|*String/Array* $statement|Allows you to specify a column name or sql statement upon which to perform the filter (must use SQL syntax, not Eloquent eg. ```'users.name'``` instead of ```'user.name'```). Useful if using a callback to modify the displayed values. Can pass a single string or array of strings which will be combined with ```OR```|```Column::callback(['name', 'allegiance'], function ($name, $allegiance) { return "$name is allied to $allegiance"; })->filterable(['Rebellion', 'Empire'])->filterOn('users.allegiance')```| |**view**|*String* $viewName| Passes the column value, whole row of values, and any additional parameters to a view template | _(see below)_| |**editable**| | Marks the column as editable | _(see below)_| -|**alignCenter**| | Center-aligns column header and contents |```Column::delete()->alignCenter()```| -|**alignRight**| | Right-aligns column header and contents |```Column::delete()->alignRight()```| +|**alignCenter**| | Center-aligns column header _and_ contents |```Column::delete()->alignCenter()```| +|**alignRight**| | Right-aligns column header _and_ contents |```Column::delete()->alignRight()```| +|**contentAlignCenter**| | Center-aligns column contents |```Column::delete()->contentAlignCenter()```| +|**contentAlignRight**| | Right-aligns column contents |```Column::delete()->contentAlignRight()```| +|**headerAlignCenter**| | Center-aligns column header |```Column::delete()->headerAlignCenter()```| +|**headerAlignRight**| | Right-aligns column header |```Column::delete()->headerAlignRight()```| |**editable**| | Marks the column as editable | _(see below)_| |**exportCallback**| Closure $callback | Reformats the result when exporting | _(see below)_ | |**excludeFromExport**| | Excludes the column from export |```Column::name('email')->excludeFromExport()```| @@ -240,6 +261,97 @@ public function columns() ->label('Planet'), ``` +### Summary row +If you need to summarize all cells of a specific column, you can use `enableSummary()`: + +```php +public function columns() +{ + return [ + Column::name('dollars_spent') + ->label('Expenses in Dollar') + ->enableSummary(), + + Column::name('euro_spent') + ->label('Expenses in Euro') + ->enableSummary(), +``` + +### Mass (Bulk) Action + +If you want to be able to act upon several records at once, you can use the `buildActions()` method in your Table: + +```php +public function buildActions() + { + return [ + + Action::value('edit')->label('Edit Selected')->group('Default Options')->callback(function ($mode, $items) { + // $items contains an array with the primary keys of the selected items + }), + + Action::value('update')->label('Update Selected')->group('Default Options')->callback(function ($mode, $items) { + // $items contains an array with the primary keys of the selected items + }), + + Action::groupBy('Export Options', function () { + return [ + Action::value('csv')->label('Export CSV')->export('SalesOrders.csv'), + Action::value('html')->label('Export HTML')->export('SalesOrders.html'), + Action::value('xlsx')->label('Export XLSX')->export('SalesOrders.xlsx')->styles($this->exportStyles)->widths($this->exportWidths) + ]; + }), + ]; + } +``` + +### Mass Action Style + +If you only have small style adjustments to the Bulk Action Dropdown you can adjust some settings here: + +```php +public function getExportStylesProperty() + { + return [ + '1' => ['font' => ['bold' => true]], + 'B2' => ['font' => ['italic' => true]], + 'C' => ['font' => ['size' => 16]], + ]; + } + + public function getExportWidthsProperty() + { + return [ + 'A' => 55, + 'B' => 45, + ]; + } +``` + +### Pin Records + +If you want to give your users the ability to pin specific records to be able to, for example, compare +them with each other, you can use the CanPinRecords trait. Ensure to have at least one Checkbox Column +so the user can select records: + +```php +use Mediconesystems\LivewireDatatables\Traits\CanPinRecords; + +class RecordTable extends LivewireDatatable +{ + use CanPinRecords; + + public $model = Record::class; + + public function columns() + { + return [ + Column::checkbox(), + + // ... + +``` + ### Custom column names It is still possible to take full control over your table, you can define a ```builder``` method using whatever query you like, using your own joins, groups whatever, and then name your columns using your normal SQL syntax: @@ -269,7 +381,6 @@ public function columns() ``` - ### Callbacks Callbacks give you the freedom to perform any mutations you like on the data before displaying in the table. - The callbacks are performed on the paginated results of the database query, so shouldn't use a ton of memory @@ -302,6 +413,34 @@ class CallbackDemoTable extends LivewireDatatable } ``` +### Default Filters + +If you want to have a default filter applied to your table, you can use the `defaultFilters` property. The `defaultFilter` should be an Array of column names and the default filter value to use for. When a persisted filter (`$this->persistFilters` is true and session values are available) is available, it will override the default filters. + +In the example below, the table will by default be filtered by rows where the _deleted_at_ column is false. If the user has a persisted filter for the _deleted_at_ column, the default filter will be ignored. + +```php +class CallbackDemoTable extends LivewireDatatable +{ + public $defaultFilters = [ + 'deleted_at' => '0', + ]; + + public function builder() + { + return User::query()->withTrashed(); + } + + public function columns() + { + return [ + Column::name('id'), + BooleanColumn::name('deleted_at')->filterable(), + ]; + } +} +``` + ### Views You can specify that a column's output is piped directly into a separate blade view template. - Template is specified using ususal laravel view helper syntax @@ -364,7 +503,7 @@ Just add ```$complex = true``` to your Datatable Class and all filterable column ![image](https://user-images.githubusercontent.com/7000886/128855344-25035758-ca90-42d2-bd19-518c9de45148.png) --- -**Persisting Queries** (Requires AlpineJS v3 with $perist plugin) +**Persisting Queries** (Requires AlpineJS v3 with $persist plugin) - Add ```$persistComplexQuery = true``` to your class and queries will be stored in browser localstorage. - By default the localstorage key will be the class name. You can provide your own by setting the public property ```$persistKey``` or overriding ```getPersistKeyProperty()``` on the Datatable Class - eg: for user-specific persistence: @@ -378,7 +517,7 @@ public function getPersistKeyProperty() --- **Saving Queries** -If you want to save permanently save queries you must provide 3 methods for adding, deleting and retrieving your saved queries using whatever logic you like: +If you want to permanently save queries you must provide 3 methods for adding, deleting and retrieving your saved queries using whatever logic you like: - ```public function saveQuery(String $name, Array $rules)``` - ```public function deleteQuery(Int $id)``` @@ -422,7 +561,7 @@ class ComplexQuery extends BaseModel /* Datatable Class */ -class TableWithSaving extends Livewire Datatable +class TableWithSaving extends LivewireDatatable { ... diff --git a/composer.json b/composer.json index 963137f4..f970fae2 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,10 @@ { "name": "mediconesystems/livewire-datatables", - "description": "", + "description": "Advanced datatables using Laravel, Livewire, Tailwind CSS and Alpine JS", "keywords": [ - "mediconesystems", - "livewire-datatables" + "php", + "laravel", + "livewire" ], "homepage": "https://github.com/mediconesystems/livewire-datatables", "license": "MIT", @@ -13,18 +14,29 @@ "name": "Mark Salmon", "email": "mark.salmon@mediconesystems.com", "role": "Developer" + }, + { + "name": "Shane Burrell", + "email": "shane@shaneburrell.com", + "role": "Developer" + }, + { + "name": "Herbert Maschke", + "email": "thyseus@pm.me", + "role": "Developer" } ], "require": { - "php": "^7.2.5|^8.0", - "illuminate/support": "^7.0|^8.0", + "php": "^8.0", + "illuminate/support": "^7.0|^8.0|^9.0", "livewire/livewire": "^2.4.4", - "maatwebsite/excel": "^3.1" + "maatwebsite/excel": "^3.1", + "reedware/laravel-relation-joins": "^2.4|^3.0" }, "require-dev": { "laravel/legacy-factories": "^1.0.4", - "orchestra/testbench": "^4.0|5.0|6.0", - "phpunit/phpunit": "^8.0|9.0" + "orchestra/testbench": "^4.0|5.0|6.0|^7.0", + "phpunit/phpunit": "^8.0|9.0|^9.5.10" }, "autoload": { "psr-4": { @@ -39,7 +51,6 @@ "scripts": { "test": "vendor/bin/phpunit", "test-coverage": "vendor/bin/phpunit --coverage-html coverage" - }, "config": { "sort-packages": true diff --git a/config/livewire-datatables.php b/config/livewire-datatables.php index 0217f55b..10a028a4 100644 --- a/config/livewire-datatables.php +++ b/config/livewire-datatables.php @@ -14,6 +14,23 @@ 'default_time_format' => 'H:i', 'default_date_format' => 'd/m/Y', + /* + |-------------------------------------------------------------------------- + | Default Carbon Formats + |-------------------------------------------------------------------------- + | The default formats that are used for TimeColumn & DateColumn. + | You can use the formatting characters from the PHP DateTime class. + | More info: https://www.php.net/manual/en/datetime.format.php + | + */ + + 'default_time_start' => '0000-00-00', + 'default_time_end' => '9999-12-31', + + // Defaults that work with smalldatetime in SQL Server + // 'default_time_start' => '1900-01-01', + // 'default_time_end' => '2079-06-06', + /* |-------------------------------------------------------------------------- | Surpress Search Highlights @@ -56,6 +73,16 @@ 'model_namespace' => 'App', + /* + |-------------------------------------------------------------------------- + | Default Sortable + |-------------------------------------------------------------------------- + | Should a column of a datatable be sortable by default ? + | + */ + + 'default_sortable' => true, + /* |-------------------------------------------------------------------------- | Default CSS classes @@ -72,6 +99,6 @@ 'odd' => 'divide-x divide-gray-100 text-sm text-gray-900 bg-gray-50', 'selected' => 'divide-x divide-gray-100 text-sm text-gray-900 bg-yellow-100', ], - 'cell' => 'text-sm text-gray-900', + 'cell' => 'whitespace-no-wrap text-sm text-gray-900 px-6 py-2', ], ]; diff --git a/resources/views/livewire/datatables/checkbox.blade.php b/resources/views/livewire/datatables/checkbox.blade.php index ba58dcab..078c4773 100644 --- a/resources/views/livewire/datatables/checkbox.blade.php +++ b/resources/views/livewire/datatables/checkbox.blade.php @@ -1,3 +1,9 @@
- + pinnedRecords)) checked @endif + class="w-4 h-4 mt-1 text-blue-600 form-checkbox transition duration-150 ease-in-out" + />
diff --git a/resources/views/livewire/datatables/complex-query.blade.php b/resources/views/livewire/datatables/complex-query.blade.php index 57542edf..af86ff30 100644 --- a/resources/views/livewire/datatables/complex-query.blade.php +++ b/resources/views/livewire/datatables/complex-query.blade.php @@ -16,58 +16,64 @@ @endif - @if(count($this->rules[0]['content'])) -
{{ $this->rulesString }}@if($errors->any()) Invalid rules @endif
- @endif +
+ @if(count($this->rules[0]['content'])) +
{{ $this->rulesString }}@if($errors->any()) Invalid rules @endif
+ @endif +
@include('datatables::complex-query-group', ['rules' => $rules, 'parentIndex' => null])
- @if(count($this->rules[0]['content'])) - @unless($errors->any()) -
-
- {{-- --}} +
+ @if(count($this->rules[0]['content'])) + @unless($errors->any()) +
+
+ {{-- --}} +
+
+ @isset($savedQueries) +
+ + +
+ @endisset + +
-
- @isset($savedQueries) -
- -
+ +
+ @if(count($savedQueries ?? [])) +
+
Saved Queries
+
+ @foreach($savedQueries as $saved) +
+ +
- @endisset - + @endforeach
@endif - - @endif - @if(count($savedQueries ?? [])) -
-
Saved Queries
-
- @foreach($savedQueries as $saved) -
- - -
- @endforeach -
-
- @endif +
diff --git a/resources/views/livewire/datatables/datatable.blade.php b/resources/views/livewire/datatables/datatable.blade.php index bd854f69..a7c2e628 100644 --- a/resources/views/livewire/datatables/datatable.blade.php +++ b/resources/views/livewire/datatables/datatable.blade.php @@ -1,114 +1,132 @@
- @if($beforeTableSlot) -
- @include($beforeTableSlot) -
- @endif + @includeIf($beforeTableSlot)
-
-
+
+
@if($this->searchableColumns()->count()) -
-
-
- - - -
- -
- +
+
+
+ + + +
+ +
+ +
-
@endif
@if($this->activeFilters) - FILTERS ACTIVE + @lang('Filter active') @endif
- + @if($this->activeFilters) - + + @endif + + @if(count($this->massActionsOptions)) +
+ + + +
@endif @if($exportable) -
- -
+
+ +
@endif @if($hideable === 'select') - @include('datatables::hide-column-multiselect') + @include('datatables::hide-column-multiselect') @endif @foreach ($columnGroups as $name => $group) @endforeach + @includeIf($buttonsSlot)
@if($hideable === 'buttons') -
- @foreach($this->columns as $index => $column) - @if ($column['hideable']) - - @endif - @endforeach -
+
+ @foreach($this->columns as $index => $column) + @if ($column['hideable']) + + @endif + @endforeach +
@endif -
+
-
+
@unless($this->hideHeader) -
- @foreach($this->columns as $index => $column) - @if($hideable === 'inline') - @include('datatables::header-inline-hide', ['column' => $column, 'sort' => $sort]) - @elseif($column['type'] === 'checkbox') - @unless($column['hidden']) -
-
- {{ count($selected) }} +
+ @foreach($this->columns as $index => $column) + @if($hideable === 'inline') + @include('datatables::header-inline-hide', ['column' => $column, 'sort' => $sort]) + @elseif($column['type'] === 'checkbox') + @unless($column['hidden']) +
+
+ {{ count($visibleSelected) }} +
-
- @endunless - @else - @include('datatables::header-no-hide', ['column' => $column, 'sort' => $sort]) - @endif - @endforeach -
- -
+ @endunless + @else + @include('datatables::header-no-hide', ['column' => $column, 'sort' => $sort]) + @endif + @endforeach +
+ @endunless +
@foreach($this->columns as $index => $column) @if($column['hidden']) @if($hideable === 'inline')
@endif @elseif($column['type'] === 'checkbox') -
-
SELECT ALL
-
- results->total()) checked @endif class="form-checkbox mt-1 h-4 w-4 text-blue-600 transition duration-150 ease-in-out" /> -
-
+ @include('datatables::filters.checkbox') @elseif($column['type'] === 'label')
{{ $column['label'] ?? '' }} @@ -130,41 +148,58 @@ class="px-3 py-2 border border-green-400 rounded-md bg-white text-green-500 text @endif @endforeach
- @endif - @forelse($this->results as $row) + @foreach($this->results as $row)
@foreach($this->columns as $column) @if($column['hidden']) @if($hideable === 'inline') -
+
@endif @elseif($column['type'] === 'checkbox') @include('datatables::checkbox', ['value' => $row->checkbox_attribute]) @elseif($column['type'] === 'label') @include('datatables::label') @else -
+ +
{!! $row->{$column['name']} !!}
@endif @endforeach
- @empty -

- {{ __("There's Nothing to show at the moment") }} -

- @endforelse + @endforeach + + @if ($this->hasSummaryRow()) +
+ @foreach($this->columns as $column) + @unless($column['hidden']) + @if ($column['summary']) +
+ {{ $this->summarize($column['name']) }} +
+ @else +
+ @endif + @endunless + @endforeach +
+ @endif
+ @if($this->results->isEmpty()) +

+ {{ __("There's Nothing to show at the moment") }} +

+ @endif
@unless($this->hidePagination)
-
+
{{-- check if there is any data --}} @if(count($this->results)) -
- @foreach(config('livewire-datatables.per_page_options', [ 10, 25, 50, 100 ]) as $per_page_option) @endforeach @@ -177,7 +212,7 @@ class="px-3 py-2 border border-green-400 rounded-md bg-white text-green-500 text {{ $this->results->links('datatables::tailwind-simple-pagination') }}
- @@ -193,15 +228,12 @@ class="px-3 py-2 border border-green-400 rounded-md bg-white text-green-500 text
@if($complex) -
+
@endif - @if($afterTableSlot) -
- @include($afterTableSlot) -
- @endif - + @includeIf($afterTableSlot) + +
diff --git a/resources/views/livewire/datatables/delete.blade.php b/resources/views/livewire/datatables/delete.blade.php index 56aff510..55fd679c 100644 --- a/resources/views/livewire/datatables/delete.blade.php +++ b/resources/views/livewire/datatables/delete.blade.php @@ -44,7 +44,7 @@ class="text-gray-400 hover:text-gray-500 focus:outline-none focus:text-gray-500 - diff --git a/resources/views/livewire/datatables/editable.blade.php b/resources/views/livewire/datatables/editable.blade.php index 925cc050..3faad52d 100644 --- a/resources/views/livewire/datatables/editable.blade.php +++ b/resources/views/livewire/datatables/editable.blade.php @@ -2,8 +2,8 @@ edit: false, edited: false, init() { - window.livewire.on('fieldEdited', (id) => { - if (id === '{{ $rowId }}') { + window.livewire.on('fieldEdited', (id, column) => { + if (id === '{{ $rowId }}' && column === '{{ $column }}') { this.edited = true setTimeout(() => { this.edited = false @@ -11,7 +11,7 @@ } }) } -}" x-init="init()" :key="{{ $rowId }}"> +}" x-init="init()" wire:key="{{ $rowId }}_{{ $column }}"> diff --git a/resources/views/livewire/datatables/filters/checkbox.blade.php b/resources/views/livewire/datatables/filters/checkbox.blade.php new file mode 100644 index 00000000..87f5a871 --- /dev/null +++ b/resources/views/livewire/datatables/filters/checkbox.blade.php @@ -0,0 +1,13 @@ +
+
{{ __('SELECT ALL') }}
+
+ results->total() === count($visibleSelected)) checked @endif + /> +
+
diff --git a/resources/views/livewire/datatables/header-inline-hide.blade.php b/resources/views/livewire/datatables/header-inline-hide.blade.php index 59cf268c..041d71d0 100644 --- a/resources/views/livewire/datatables/header-inline-hide.blade.php +++ b/resources/views/livewire/datatables/header-inline-hide.blade.php @@ -1,43 +1,43 @@ @endif diff --git a/resources/views/livewire/datatables/hide-column-multiselect.blade.php b/resources/views/livewire/datatables/hide-column-multiselect.blade.php index 04ccb743..d4c5770c 100644 --- a/resources/views/livewire/datatables/hide-column-multiselect.blade.php +++ b/resources/views/livewire/datatables/hide-column-multiselect.blade.php @@ -8,28 +8,30 @@
@foreach($this->columns as $index => $column) -
- diff --git a/resources/views/livewire/datatables/label.blade.php b/resources/views/livewire/datatables/label.blade.php index 7bed4d3b..2310faad 100644 --- a/resources/views/livewire/datatables/label.blade.php +++ b/resources/views/livewire/datatables/label.blade.php @@ -1,3 +1,4 @@ -
+ +
{!! $column['content'] ?? '' !!}
diff --git a/src/Action.php b/src/Action.php new file mode 100644 index 00000000..91d1ff06 --- /dev/null +++ b/src/Action.php @@ -0,0 +1,89 @@ +$method, $args); + } + } + + public static function value($value) + { + $action = new static; + $action->value = $value; + + return $action; + } + + public function label($label) + { + $this->label = $label; + + return $this; + } + + public function group($group) + { + $this->group = $group; + + return $this; + } + + public static function groupBy($group, $actions) + { + if ($actions instanceof \Closure) { + return collect($actions())->each(function ($item) use ($group) { + $item->group = $group; + }); + } + } + + public function export($fileName) + { + $this->fileName = $fileName; + $this->isExport(); + + return $this; + } + + public function isExport($isExport = true) + { + $this->isExport = $isExport; + + return $this; + } + + public function styles($styles) + { + $this->styles = $styles; + + return $this; + } + + public function widths($widths) + { + $this->widths = $widths; + + return $this; + } + + public function callback($callable) + { + $this->callable = $callable; + + return $this; + } +} diff --git a/src/Column.php b/src/Column.php index 1972f94a..5208b0f6 100644 --- a/src/Column.php +++ b/src/Column.php @@ -2,6 +2,7 @@ namespace Mediconesystems\LivewireDatatables; +use Closure; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Mediconesystems\LivewireDatatables\Http\Livewire\LivewireDatatable; @@ -11,17 +12,18 @@ class Column public $type = 'string'; public $index = 0; public $label; + public $tooltip; public $name; public $select; public $joins; public $base; public $raw; public $searchable; + public $sortable; public $filterOn; public $filterable; public $hideable; public $sort; - public $unsortable; public $defaultSort; public $callback; public $hidden; @@ -30,13 +32,25 @@ class Column public $params = []; public $additionalSelects = []; public $filterView; - public $align = 'left'; + public $headerAlign = 'left'; + public $contentAlign = 'left'; public $preventExport; public $width; public $minWidth; public $maxWidth; public $exportCallback; + /** + * @var bool should the sum of all summarizable cells in this column be + * displayed as a summary at the bottom of the table? + */ + public $summary = false; + + /** + * @var bool allow the content of the column to wrap into multiple lines. + */ + public $wrappable = true; + /** * @var string (optional) you can group your columns to let the user toggle the visibility of a group at once. */ @@ -45,6 +59,11 @@ class Column /** @var array list all column types that are not sortable by SQL here */ public const UNSORTABLE_TYPES = ['label', 'checkbox']; + public function __construct() + { + $this->sortable = config('livewire-datatables.default_sortable', true); + } + public static function name($name) { $column = new static; @@ -85,11 +104,25 @@ public static function raw($raw) return $column; } - public static function callback($columns, $callback, $params = []) - { + /** + * Make a callback function. + * + * @param $columns Array|string The (comma separated) columns that should be retrieved from the database. + * Is being translated directly into the `.sql`. + * @param $callback Closure|string A callback that defines how the retrieved columns are processed. + * @param $params Array Optional additional parameters that are passed to the given Closure. + * @param $callbackName string Optional string that defines the 'name' of the column. + * Leave empty to let livewire autogenerate a distinct value. + */ + public static function callback( + array|string $columns, + Closure|string $callback, + array $params = [], + ?string $callbackName = null + ) { $column = new static; - $column->name = 'callback_' . crc32(json_encode(func_get_args())); + $column->name = 'callback_' . ($callbackName ?? crc32(json_encode(func_get_args()))); $column->callback = $callback; $column->additionalSelects = is_array($columns) ? $columns : array_map('trim', explode(',', $columns)); $column->params = $params; @@ -129,6 +162,34 @@ public function label($label) return $this; } + public function wrap() + { + $this->wrappable = true; + + return $this; + } + + public function unwrap() + { + $this->wrappable = false; + + return $this; + } + + public function enableSummary() + { + $this->summary = true; + + return $this; + } + + public function disableSummary() + { + $this->summary = false; + + return $this; + } + public function setIndex($index) { $this->index = $index; @@ -136,6 +197,16 @@ public function setIndex($index) return $this; } + public function tooltip($text, $label = null) + { + $this->tooltip = [ + 'text' => $text, + 'label' => $label, + ]; + + return $this; + } + public function sortBy($column) { $this->sort = $column; @@ -150,16 +221,23 @@ public function defaultSort($direction = true) return $this; } - public function hideable() + public function hideable($hideable = true) + { + $this->hideable = $hideable; + + return $this; + } + + public function sortable() { - $this->hideable = true; + $this->sortable = true; return $this; } public function unsortable() { - $this->unsortable = true; + $this->sortable = false; return $this; } @@ -272,10 +350,10 @@ public function round($precision = 0) return $this; } - public function view($view) + public function view($view, $data = []) { - $this->callback = function ($value, $row) use ($view) { - return view($view, ['value' => $value, 'row' => $row]); + $this->callback = function ($value, $row) use ($view, $data) { + return view($view, ['value' => $value, 'row' => $row, ...$data]); }; $this->exportCallback = function ($value) { @@ -322,14 +400,44 @@ public function hide() public function alignRight() { - $this->align = 'right'; + $this->headerAlign = 'right'; + $this->contentAlign = 'right'; return $this; } public function alignCenter() { - $this->align = 'center'; + $this->headerAlign = 'center'; + $this->contentAlign = 'center'; + + return $this; + } + + public function headerAlignRight() + { + $this->headerAlign = 'right'; + + return $this; + } + + public function contentAlignRight() + { + $this->contentAlign = 'right'; + + return $this; + } + + public function headerAlignCenter() + { + $this->headerAlign = 'center'; + + return $this; + } + + public function contentAlignCenter() + { + $this->contentAlign = 'center'; return $this; } @@ -353,7 +461,7 @@ public function aggregate() public function isBaseColumn() { - return ! Str::contains($this->name, '.') && ! $this->raw; + return ! Str::startsWith($this->name, 'callback_') && ! Str::contains($this->name, '.') && ! $this->raw; } public function field() diff --git a/src/ColumnSet.php.old.ctp b/src/ColumnSet.php.old.ctp deleted file mode 100644 index f63b5868..00000000 --- a/src/ColumnSet.php.old.ctp +++ /dev/null @@ -1,228 +0,0 @@ -columns = $columns; - } - - public static function build($input) - { - return is_array($input) - ? self::fromArray($input) - : self::fromModelInstance($input); - } - - public static function fromModelInstance($model) - { - return new static( - collect($model->getAttributes())->keys()->reject(function ($name) use ($model) { - return in_array($name, $model->getHidden()); - })->map(function ($attribute) use ($model) { - return Column::name($attribute); - }) - ); - } - - public static function fromArray($columns) - { - return new static(collect($columns)); - } - - public function include($include) - { - if (!$include) { - return $this; - } - - $include = collect(is_array($include) ? $include : array_map('trim', explode(',', $include))); - $this->columns = $include->map(function ($column) { - return Str::contains($column, '|') - ? Column::name(Str::before($column, '|'))->label(Str::after($column, '|')) - : Column::name($column); - }); - - return $this; - } - - public function exclude($exclude) - { - if (!$exclude) { - return $this; - } - - $exclude = is_array($exclude) ? $exclude : array_map('trim', explode(',', $exclude)); - - $this->columns = $this->columns->reject(function ($column) use ($exclude) { - return in_array(Str::after($column->name, '.'), $exclude); - }); - - return $this; - } - - public function hide($hidden) - { - if (!$hidden) { - return $this; - } - $hidden = is_array($hidden) ? $hidden : array_map('trim', explode(',', $hidden)); - $this->columns->each(function ($column) use ($hidden) { - $column->hidden = in_array(Str::after($column->name, '.'), $hidden); - }); - - return $this; - } - - public function formatDates($dates) - { - $dates = is_array($dates) ? $dates : array_map('trim', explode(',', $dates)); - - $this->columns = $this->columns->map(function ($column) use ($dates) { - foreach ($dates as $date) { - if ($column->name === Str::before($date, '|')) { - $format = Str::of($date)->contains('|') ? Str::after($date, '|') : null; - - return DateColumn::name($column->name)->format($format); - } - } - return $column; - }); - - return $this; - } - - public function formatTimes($times) - { - $times = is_array($times) ? $times : array_map('trim', explode(',', $times)); - - $this->columns = $this->columns->map(function ($column) use ($times) { - foreach ($times as $time) { - if (Str::after($column->name, '.') === Str::before($time, '|')) { - $format = Str::of($time)->contains('|') ? Str::after($time, '|') : null; - return TimeColumn::name($column->name)->format($format); - } - } - return $column; - }); - - return $this; - } - - public function search($searchable) - { - if (!$searchable) { - return $this; - } - - $searchable = is_array($searchable) ? $searchable : array_map('trim', explode(',', $searchable)); - $this->columns->each(function ($column) use ($searchable) { - $column->searchable = in_array($column->name, $searchable); - }); - - return $this; - } - - public function sort($sort) - { - if ($sort && $column = $this->columns->first(function ($column) use ($sort) { - return Str::after($column->name, '.') === Str::before($sort, '|'); - })) { - $column->defaultSort(Str::of($sort)->contains('|') ? Str::after($sort, '|') : null); - } - return $this; - } - - public function columns() - { - return collect($this->columns); - } - - public function columnsArray() - { - return $this->columns()->map->toArray()->toArray(); - } - - public function processForBuilder($builder) - { - $this->columns = $this->columns->map(function ($column) use ($builder) { - - foreach (array_merge([$column->base ?? $column->name], $column->additionalSelects) as $name) { - - if (!Str::contains($name, '.')) { - if (!Str::startsWith($name, 'callback_')) { - $selects[] = $builder->getModel()->getTable() . '.' . $name; - if ($column->isEditable()) { - $selects[] = $builder->getModel()->getTable() . '.' . $builder->getModel()->getKeyName() . ' AS ' . $builder->getModel()->getTable() . '.' . $builder->getModel()->getKeyName(); - } - } - } - - $parent = $builder; - foreach (explode('.', Str::beforeLast($name, '.')) as $join) { - - if (method_exists($parent->getModel(), $join)) { - $relation = $parent->getRelation($join); - // dump($parent, $join, $relation); - if ($relation instanceof HasOne || $relation instanceof BelongsTo) { - $column->joins[] = [ - $relation->getRelated()->getTable(), - $relation instanceof HasOne ? $relation->getQualifiedForeignKeyName() : $relation->getQualifiedOwnerKeyName(), - $relation instanceof HasOne ? $relation->getQualifiedParentKeyName() : $relation->getQualifiedForeignKeyName() - ]; - - $parent = $relation; - - $selects = [$parent->getRelated()->getTable() . '.' . Str::afterLast($name, '.') . ($name === $column->name - ? ' AS ' . $name - : '')]; - } - - if ($relation instanceof HasMany || $relation instanceof BelongsToMany) { - $name = explode('.', $name); - $column->aggregates[] = [$name[0], $column->aggregate(), $name[1]]; - } - } - } - } - - if (count($selects) > 1) { - if ($column->callback && !$column->isEditable()) { - - $column->additionalSelects = []; - $column->select = DB::raw('CONCAT_WS("' . static::SEPARATOR . '" ,' . - collect($selects)->map(function ($select) { - return "COALESCE($select, '')"; - })->join(', ') . ')' . ' AS `' . $column->name . '`'); - } else { - $column->select = array_shift($selects); - $column->additionalSelects = $selects; - } - } else if (count($selects)) { - foreach ($selects as $select) { - $column->select = $select . ($column->callback ? ' AS ' . $column->name : ''); - } - } - - return $column; - }); - - // dd($this->columns); - return $this; - } -} diff --git a/src/Commands/DatatableMakeCommand.php b/src/Commands/DatatableMakeCommand.php new file mode 100644 index 00000000..2e6c6bdf --- /dev/null +++ b/src/Commands/DatatableMakeCommand.php @@ -0,0 +1,8 @@ +collection->first()); } + + public function setFileName($fileName) + { + $this->fileName = $fileName; + + return $this; + } + + public function getFileName(): string + { + return $this->fileName; + } + + public function setColumnWidths($columnWidths) + { + $this->columnWidths = $columnWidths; + + return $this; + } + + public function getColumnWidths(): array + { + return $this->columnWidths; + } + + public function columnWidths(): array + { + return $this->getColumnWidths(); + } + + public function setStyles($styles) + { + $this->styles = $styles; + + return $this; + } + + public function getStyles(): array + { + return $this->styles; + } + + public function styles(Worksheet $sheet) + { + return $this->getStyles(); + } + + public function download() + { + return Excel::download($this, $this->getFileName()); + } } diff --git a/src/Http/Livewire/LivewireDatatable.php b/src/Http/Livewire/LivewireDatatable.php index 15a91e45..1366cc7f 100644 --- a/src/Http/Livewire/LivewireDatatable.php +++ b/src/Http/Livewire/LivewireDatatable.php @@ -3,12 +3,9 @@ namespace Mediconesystems\LivewireDatatables\Http\Livewire; use Exception; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; -use Illuminate\Database\Eloquent\Relations\HasOne; -use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Query\Expression; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -16,7 +13,6 @@ use Illuminate\View\View; use Livewire\Component; use Livewire\WithPagination; -use Maatwebsite\Excel\Facades\Excel; use Mediconesystems\LivewireDatatables\Column; use Mediconesystems\LivewireDatatables\ColumnSet; use Mediconesystems\LivewireDatatables\Exports\DatatableExport; @@ -40,6 +36,7 @@ class LivewireDatatable extends Component public $activeBooleanFilters = []; public $activeTextFilters = []; public $activeNumberFilters = []; + public $defaultFilters = []; public $hideHeader; public $hidePagination; public $perPage; @@ -50,11 +47,11 @@ class LivewireDatatable extends Component public $times; public $searchable; public $exportable; - public $export_name; public $hideable; public $params; public $selected = []; public $beforeTableSlot; + public $buttonsSlot; public $afterTableSlot; public $complex; public $complexQuery; @@ -68,8 +65,14 @@ class LivewireDatatable extends Component public $persistSort = true; public $persistPerPage = true; public $persistFilters = true; + public $visibleSelected = []; public $row = 1; + public $tablePrefix = ''; + + public $actions; + public $massActionOption; + /** * @var array List your groups and the corresponding label (or translation) here. * The label can be a i18n placeholder like 'app.my_string' and it will be automatically translated via __(). @@ -110,6 +113,31 @@ class LivewireDatatable extends Component 'does not include' => '<>', ]; + protected $viewColumns = [ + 'index', + 'hidden', + 'label', + 'tooltip', + 'group', + 'summary', + 'content', + 'headerAlign', + 'contentAlign', + 'type', + 'filterable', + 'hideable', + 'sortable', + 'complex', + 'filterView', + 'name', + 'params', + 'wrappable', + 'width', + 'minWidth', + 'maxWidth', + 'preventExport', + ]; + /** * This events allows to control the options of the datatable from foreign livewire components * by using $emit. @@ -124,9 +152,7 @@ public function applyToTable($options) if (isset($options['hiddenColumns']) && is_array($options['hiddenColumns'])) { // first display all columns, - foreach ($this->columns as $key => $column) { - $this->columns[$key]['hidden'] = false; - } + $this->resetHiddenColumns(); // then hide all columns that should be hidden: foreach ($options['hiddenColumns'] as $columnToHide) { @@ -149,6 +175,7 @@ public function applyToTable($options) 'activeNumberFilters', 'hide', 'selected', + 'pinnedRecords', ] as $property) { if (isset($options[$property])) { $this->$property = $options[$property]; @@ -164,6 +191,7 @@ public function applyToTable($options) public function resetTable() { $this->perPage = config('livewire-datatables.default_per_page', 10); + $this->sort = $this->defaultSort(); $this->search = null; $this->setPage(1); $this->activeSelectFilters = []; @@ -173,16 +201,29 @@ public function resetTable() $this->activeBooleanFilters = []; $this->activeNumberFilters = []; $this->hide = null; + $this->resetHiddenColumns(); $this->selected = []; } + /** + * Display all columns, also those that are currently hidden. + * Should get called when resetting the table. + */ + public function resetHiddenColumns() + { + foreach ($this->columns as $key => $column) { + $this->columns[$key]['hidden'] = false; + } + } + public function updatedSearch() { + $this->visibleSelected = ($this->search) ? array_intersect($this->getQuery()->get()->pluck('checkbox_attribute')->toArray(), $this->selected) : $this->selected; $this->setPage(1); } public function mount( - $model = null, + $model = false, $include = [], $exclude = [], $hide = [], @@ -194,9 +235,9 @@ public function mount( $hidePagination = null, $perPage = null, $exportable = false, - $export_name = null, $hideable = false, $beforeTableSlot = false, + $buttonsSlot = false, $afterTableSlot = false, $params = [] ) { @@ -214,6 +255,7 @@ public function mount( 'exportable', 'hideable', 'beforeTableSlot', + 'buttonsSlot', 'afterTableSlot', ] as $property) { $this->$property = $this->$property ?? $$property; @@ -222,13 +264,19 @@ public function mount( $this->params = $params; $this->columns = $this->getViewColumns(); - + $this->actions = $this->getMassActions(); $this->initialiseSearch(); $this->initialiseSort(); $this->initialiseHiddenColumns(); + $this->initialiseDefaultFilters(); $this->initialiseFilters(); $this->initialisePerPage(); $this->initialiseColumnGroups(); + $this->model = $this->model ?: get_class($this->builder()->getModel()); + + if (isset($this->pinnedRecords)) { + $this->initialisePinnedRecords(); + } } // save settings @@ -249,26 +297,9 @@ public function columns() public function getViewColumns() { return collect($this->freshColumns)->map(function ($column) { - return collect($column)->only([ - 'index', - 'hidden', - 'label', - 'group', - 'content', - 'align', - 'type', - 'filterable', - 'hideable', - 'complex', - 'filterView', - 'name', - 'params', - 'width', - 'minWidth', - 'maxWidth', - 'unsortable', - 'preventExport', - ])->toArray(); + return collect($column) + ->only($this->viewColumns) + ->toArray(); })->toArray(); } @@ -313,45 +344,38 @@ public function getProcessedColumnsProperty() ->sort($this->sort); } - public function resolveColumnName($column) - { - return $column->isBaseColumn() - ? $this->query->getModel()->getTable() . '.' . ($column->base ?? Str::before($column->name, ':')) - : $column->select ?? $this->resolveRelationColumn($column->base ?? $column->name, $column->aggregate); - } - - public function resolveCheckboxColumnName($column) - { - $column = is_object($column) - ? $column->toArray() - : $column; - - return Str::contains($column['base'], '.') - ? $this->resolveRelationColumn($column['base'], $column['aggregate']) - : $this->query->getModel()->getTable() . '.' . $column['base']; - } - public function resolveAdditionalSelects($column) { $selects = collect($column->additionalSelects)->map(function ($select) use ($column) { return Str::contains($select, '.') - ? $this->resolveRelationColumn($select, Str::contains($select, ':') ? Str::after($select, ':') : null, $column->name) + ? $this->resolveColumnName($column, $select) : $this->query->getModel()->getTable() . '.' . $select; }); - return $selects->count() > 1 - ? new Expression("CONCAT_WS('" . static::SEPARATOR . "' ," . + if (DB::connection() instanceof \Illuminate\Database\SQLiteConnection) { + // SQLite dialect. + return $selects->count() > 1 + ? new Expression('(' . collect($selects)->map(function ($select) { - return "COALESCE($select, '')"; + return 'COALESCE(' . $this->tablePrefix . $select . ', \'\')'; + })->join(" || '" . static::SEPARATOR . "' || ") . ')') + : $selects->first(); + } else { + // Default to MySql dialect. + return $selects->count() > 1 + ? new Expression("CONCAT_WS('" . static::SEPARATOR . "' ," . + collect($selects)->map(function ($select) { + return 'COALESCE(' . $this->tablePrefix . $select . ', \'\')'; })->join(', ') . ')') - : $selects->first(); + : $selects->first(); + } } public function resolveEditableColumnName($column) { return [ $column->select, - $this->query->getModel()->getTable() . '.' . $this->query->getModel()->getKeyName(), + $this->query->getModel()->getTable() . '.' . $this->query->getModel()->getKeyName() . ' AS ' . $column->name . '_edit_id', ]; } @@ -365,12 +389,6 @@ public function getSelectStatements($withAlias = false, $export = false) return $column; } - if ($column->isType('checkbox')) { - $column->select = $this->resolveCheckboxColumnName($column); - - return $column; - } - if (Str::startsWith($column->name, 'callback_')) { $column->select = $this->resolveAdditionalSelects($column); @@ -412,94 +430,41 @@ public function getSelectStatements($withAlias = false, $export = false) }); } - protected function resolveRelationColumn($name, $aggregate = null, $alias = null) + protected function resolveColumnName($column, $additional = null) { - $parts = explode('.', Str::before($name, ':')); - $columnName = array_pop($parts); - $relation = implode('.', $parts); - - return method_exists($this->query->getModel(), $parts[0]) - ? $this->joinRelation($relation, $columnName, $aggregate, $alias ?? $name) - : $name; - } - - protected function joinRelation($relation, $relationColumn, $aggregate = null, $alias = null) - { - $table = ''; - $model = ''; - $lastQuery = $this->query; - foreach (explode('.', $relation) as $eachRelation) { - $model = $lastQuery->getRelation($eachRelation); - - switch (true) { - case $model instanceof HasOne: - $table = $model->getRelated()->getTable(); - $foreign = $model->getQualifiedForeignKeyName(); - $other = $model->getQualifiedParentKeyName(); - break; + if ($column->isBaseColumn()) { + return $this->query->getModel()->getTable() . '.' . ($column->base ?? Str::before($column->name, ':')); + } - case $model instanceof HasMany: - $this->query->customWithAggregate($relation, $aggregate ?? 'count', $relationColumn, $alias); - $table = null; - break; + $relations = explode('.', Str::before(($additional ?: $column->name), ':')); + $aggregate = Str::after(($additional ?: $column->name), ':'); - case $model instanceof BelongsTo: - $table = $model->getRelated()->getTable(); - $foreign = $model->getQualifiedForeignKeyName(); - $other = $model->getQualifiedOwnerKeyName(); - break; + if (! method_exists($this->query->getModel(), $relations[0])) { + return $additional ?: $column->name; + } - case $model instanceof BelongsToMany: - $this->query->customWithAggregate($relation, $aggregate ?? 'count', $relationColumn, $alias); - $table = null; - break; + $columnName = array_pop($relations); + $aggregateName = implode('.', $relations); - case $model instanceof HasOneThrough: - $pivot = explode('.', $model->getQualifiedParentKeyName())[0]; - $pivotPK = $model->getQualifiedFirstKeyName(); - $pivotFK = $model->getQualifiedLocalKeyName(); - $this->performJoin($pivot, $pivotPK, $pivotFK); + $relatedQuery = $this->query; - $related = $model->getRelated(); - $table = $related->getTable(); - $tablePK = $related->getForeignKey(); - $foreign = $pivot . '.' . $tablePK; - $other = $related->getQualifiedKeyName(); + while (count($relations) > 0) { + $relation = array_shift($relations); - break; + if ($relatedQuery->getRelation($relation) instanceof HasMany || $relatedQuery->getRelation($relation) instanceof HasManyThrough || $relatedQuery->getRelation($relation) instanceof BelongsToMany) { + $this->query->customWithAggregate($aggregateName, $column->aggregate ?? 'count', $columnName, $column->name); - default: - $this->query->customWithAggregate($relation, $aggregate ?? 'count', $relationColumn, $alias); + return null; } - if ($table) { - $this->performJoin($table, $foreign, $other); - } - $lastQuery = $model->getQuery(); - } - if ($model instanceof HasOne || $model instanceof BelongsTo || $model instanceof HasOneThrough) { - return $table . '.' . $relationColumn; - } + $useThrough = collect($this->query->getQuery()->joins) + ->pluck('table') + ->contains($relatedQuery->getRelation($relation)->getRelated()->getTable()); - if ($model instanceof HasMany) { - return; + $relatedQuery = $this->query->joinRelation($relation, null, 'left', $useThrough, $relatedQuery); } - if ($model instanceof BelongsToMany) { - return; - } - } - - protected function performJoin($table, $foreign, $other, $type = 'left') - { - $joins = []; - foreach ((array) $this->query->getQuery()->joins as $key => $join) { - $joins[] = $join->table; - } - - if (! in_array($table, $joins)) { - $this->query->join($table, $foreign, '=', $other, $type); - } + return $relatedQuery->getQuery()->from . '.' . $columnName; } public function getFreshColumnsProperty() @@ -600,8 +565,8 @@ public function initialiseSort() return in_array($column['type'], Column::UNSORTABLE_TYPES) || $column['hidden']; })->keys()->first(); - $this->getSessionStoredSort(); $this->direction = $this->defaultSort() && $this->defaultSort()['direction'] === 'asc'; + $this->getSessionStoredSort(); } public function initialiseHiddenColumns() @@ -637,6 +602,51 @@ public function initialiseColumnGroups() }, $this->columns); } + public function initialiseDefaultFilters() + { + if (! $this->defaultFilters || ! is_array($this->defaultFilters) || count($this->defaultFilters) === 0) { + return; + } + + $columns = collect($this->columns); + + foreach ($this->defaultFilters as $columnName => $value) { + $columnIndex = $columns->search(function ($column) use ($columnName) { + return $column['name'] === $columnName; + }); + + if ($columnIndex === false) { + continue; + } + + $column = $columns[$columnIndex]; + + if ($column['type'] === 'string') { + $this->activeTextFilters[$columnIndex] = $value; + } + + if ($column['type'] === 'boolean') { + $this->activeBooleanFilters[$columnIndex] = $value; + } + + if ($column['type'] === 'select') { + $this->activeSelectFilters[$columnIndex] = $value; + } + + if ($column['type'] === 'date') { + $this->activeDateFilters[$columnIndex] = $value; + } + + if ($column['type'] === 'time') { + $this->activeTimeFilters[$columnIndex] = $value; + } + + if ($column['type'] === 'number') { + $this->activeNumberFilters[$columnIndex] = $value; + } + } + } + public function initialiseFilters() { if (! $this->persistFilters) { @@ -645,12 +655,29 @@ public function initialiseFilters() $filters = session()->get($this->sessionStorageKey() . '_filter'); - $this->activeBooleanFilters = $filters['boolean'] ?? []; - $this->activeSelectFilters = $filters['select'] ?? []; - $this->activeTextFilters = $filters['text'] ?? []; - $this->activeDateFilters = $filters['date'] ?? []; - $this->activeTimeFilters = $filters['time'] ?? []; - $this->activeNumberFilters = $filters['number'] ?? []; + if (! empty($filters['text'])) { + $this->activeTextFilters = $filters['text']; + } + + if (! empty($filters['boolean'])) { + $this->activeBooleanFilters = $filters['boolean']; + } + + if (! empty($filters['select'])) { + $this->activeSelectFilters = $filters['select']; + } + + if (! empty($filters['date'])) { + $this->activeDateFilters = $filters['date']; + } + + if (! empty($filters['time'])) { + $this->activeTimeFilters = $filters['time']; + } + + if (! empty($filters['number'])) { + $this->activeNumberFilters = $filters['number']; + } if (isset($filters['search'])) { $this->search = $filters['search']; @@ -669,10 +696,9 @@ public function defaultSort() ] : null; } - public function getSortString() + public function getSortString($dbtable) { $column = $this->freshColumns[$this->sort]; - $dbTable = DB::connection()->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME); switch (true) { case $column['sort']: @@ -691,11 +717,49 @@ public function getSortString() return Str::before($column['select'], ' AS '); break; - default: - return $dbTable == 'pgsql' || $dbTable == 'sqlsrv' - ? new Expression('"' . $column['name'] . '"') - : new Expression('`' . $column['name'] . '`'); - break; + default: + + switch ($dbtable) { + case 'mysql': + return new Expression('`' . $column['name'] . '`'); + break; + case 'pgsql': + return new Expression('"' . $column['name'] . '"'); + break; + case 'sqlsrv': + return new Expression("'" . $column['name'] . "'"); + break; + default: + return new Expression("'" . $column['name'] . "'"); + } + } + } + + /** + * @return bool has the user defined at least one column to display a summary row? + */ + public function hasSummaryRow() + { + foreach ($this->columns as $column) { + if ($column['summary']) { + return true; + } + } + + return false; + } + + /** + * Attempt so summarize each data cell of the given column. + * In case we have a string or any other value that is not summarizable, + * we return a empty string. + */ + public function summarize($column) + { + try { + return $this->results->sum($column); + } catch (\TypeError $e) { + return ''; } } @@ -726,11 +790,14 @@ public function sort($index, $direction = null) if ($direction === null) { // toggle direction $this->direction = ! $this->direction; } else { - $this->direction = $direction === 'desc' ? false : true; + $this->direction = $direction === 'asc' ? true : false; } } else { $this->sort = (int) $index; } + if ($direction !== null) { + $this->direction = $direction === 'asc' ? true : false; + } $this->setPage(1); session()->put([ @@ -816,6 +883,7 @@ public function isGroupHidden($group) public function doBooleanFilter($index, $value) { $this->activeBooleanFilters[$index] = $value; + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -823,6 +891,7 @@ public function doBooleanFilter($index, $value) public function doSelectFilter($index, $value) { $this->activeSelectFilters[$index][] = $value; + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -832,7 +901,7 @@ public function doTextFilter($index, $value) foreach (explode(' ', $value) as $val) { $this->activeTextFilters[$index][] = $val; } - + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -840,6 +909,7 @@ public function doTextFilter($index, $value) public function doDateFilterStart($index, $start) { $this->activeDateFilters[$index]['start'] = $start; + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -847,6 +917,7 @@ public function doDateFilterStart($index, $start) public function doDateFilterEnd($index, $end) { $this->activeDateFilters[$index]['end'] = $end; + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -854,6 +925,7 @@ public function doDateFilterEnd($index, $end) public function doTimeFilterStart($index, $start) { $this->activeTimeFilters[$index]['start'] = $start; + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -861,6 +933,7 @@ public function doTimeFilterStart($index, $start) public function doTimeFilterEnd($index, $end) { $this->activeTimeFilters[$index]['end'] = $end; + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -869,6 +942,7 @@ public function doNumberFilterStart($index, $start) { $this->activeNumberFilters[$index]['start'] = ($start != '') ? (int) $start : null; $this->clearEmptyNumberFilter($index); + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -877,6 +951,7 @@ public function doNumberFilterEnd($index, $end) { $this->activeNumberFilters[$index]['end'] = ($end != '') ? (int) $end : null; $this->clearEmptyNumberFilter($index); + $this->setVisibleSelected(); $this->setPage(1); $this->setSessionStoredFilters(); } @@ -893,6 +968,7 @@ public function clearEmptyNumberFilter($index) public function removeSelectFilter($column, $key = null) { unset($this->activeSelectFilters[$column][$key]); + $this->visibleSelected = $this->selected; if (count($this->activeSelectFilters[$column]) < 1) { unset($this->activeSelectFilters[$column]); } @@ -910,6 +986,7 @@ public function clearAllFilters() $this->activeNumberFilters = []; $this->complexQuery = null; $this->userFilter = null; + $this->visibleSelected = $this->selected; $this->setPage(1); $this->setSessionStoredFilters(); @@ -919,6 +996,7 @@ public function clearAllFilters() public function removeBooleanFilter($column) { unset($this->activeBooleanFilters[$column]); + $this->visibleSelected = $this->selected; $this->setSessionStoredFilters(); } @@ -932,12 +1010,14 @@ public function removeTextFilter($column, $key = null) } else { unset($this->activeTextFilters[$column]); } + $this->visibleSelected = $this->selected; $this->setSessionStoredFilters(); } public function removeNumberFilter($column) { unset($this->activeNumberFilters[$column]); + $this->visibleSelected = $this->selected; $this->setSessionStoredFilters(); } @@ -1112,6 +1192,8 @@ public function buildDatabaseQuery($export = false) { $this->query = $this->builder(); + $this->tablePrefix = $this->query->getConnection()->getTablePrefix() ?? ''; + $this->query->addSelect( $this->getSelectStatements(true, $export) ->filter() @@ -1129,11 +1211,16 @@ public function buildDatabaseQuery($export = false) ->addTimeRangeFilter() ->addComplexQuery() ->addSort(); + + if (isset($this->pinnedRecors)) { + $this->applyPinnedRecords(); + } } public function complexQuery($rules) { $this->complexQuery = $rules; + $this->setPage(1); } public function addComplexQuery() @@ -1146,8 +1233,6 @@ public function addComplexQuery() $this->processNested($this->complexQuery, $query); }); - $this->setPage(1); - return $this; } @@ -1191,9 +1276,15 @@ public function processNested($rules = null, $query = null, $logic = 'and') $query->whereNotNull($column); } elseif ($this->columns[$rule['content']['column']]['type'] === 'boolean') { if ($rule['content']['value'] === 'true') { - $query->whereNotNull(Str::contains($column, '(') ? DB::raw($column) : $column); + $query->where(function ($query) use ($column) { + $query->whereNotNull(Str::contains($column, '(') ? DB::raw($column) : $column) + ->where($column, '<>', 0); + }); } else { - $query->whereNull(Str::contains($column, '(') ? DB::raw($column) : $column); + $query->where(function ($query) use ($column) { + $query->whereNull(Str::contains($column, '(') ? DB::raw($column) : $column) + ->orWhere(Str::contains($column, '(') ? DB::raw($column) : $column, 0); + }); } } else { $col = (isset($this->freshColumns[$rule['content']['column']]['round']) && $this->freshColumns[$rule['content']['column']]['round'] !== null) @@ -1234,10 +1325,10 @@ public function addGlobalSearch() foreach ($this->getColumnFilterStatement($i) as $column) { $query->when(is_array($column), function ($query) use ($search, $column) { foreach ($column as $col) { - $query->orWhereRaw('LOWER(' . $col . ') like ?', '%' . mb_strtolower($search) . '%'); + $query->orWhereRaw('LOWER(' . (Str::contains(mb_strtolower($column), 'concat') ? '' : $this->tablePrefix) . $col . ') like ?', '%' . mb_strtolower($search) . '%'); } }, function ($query) use ($search, $column) { - $query->orWhereRaw('LOWER(' . $column . ') like ?', '%' . mb_strtolower($search) . '%'); + $query->orWhereRaw('LOWER(' . (Str::contains(mb_strtolower($column), 'concat') ? '' : $this->tablePrefix) . $column . ') like ?', '%' . mb_strtolower($search) . '%'); }); } }); @@ -1280,14 +1371,14 @@ public function addSelectFilters() if ($this->freshColumns[$index]['type'] === 'json') { $query->where(function ($query) use ($value, $index) { foreach ($this->getColumnFilterStatement($index) as $column) { - $query->whereRaw('LOWER(' . $column . ') like ?', [mb_strtolower("%$value%")]); + $query->whereRaw('LOWER(' . $this->tablePrefix . $column . ') like ?', [mb_strtolower("%$value%")]); } }); } else { $query->orWhere(function ($query) use ($value, $index) { foreach ($this->getColumnFilterStatement($index) as $column) { if (Str::contains(mb_strtolower($column), 'concat')) { - $query->orWhereRaw('LOWER(' . $column . ') like ?', [mb_strtolower("%$value%")]); + $query->orWhereRaw('LOWER(' . $this->tablePrefix . $column . ') like ?', [mb_strtolower("%$value%")]); } else { $query->orWhereRaw($column . ' = ?', $value); } @@ -1355,7 +1446,7 @@ public function addTextFilters() $query->orWhere(function ($query) use ($index, $value) { foreach ($this->getColumnFilterStatement($index) as $column) { $column = is_array($column) ? $column[0] : $column; - $query->orWhereRaw('LOWER(' . $column . ') like ?', [mb_strtolower("%$value%")]); + $query->orWhereRaw('LOWER(' . $this->tablePrefix . $column . ') like ?', [mb_strtolower("%$value%")]); } }); } @@ -1408,8 +1499,8 @@ public function addDateRangeFilter() break; } $query->whereBetween($this->getColumnFilterStatement($index)[0], [ - isset($filter['start']) && $filter['start'] != '' ? $filter['start'] : '0000-00-00', - isset($filter['end']) && $filter['end'] != '' ? $filter['end'] : '9999-12-31', + isset($filter['start']) && $filter['start'] != '' ? $filter['start'] : config('livewire-datatables.default_time_start', '0000-00-00'), + isset($filter['end']) && $filter['end'] != '' ? $filter['end'] : config('livewire-datatables.default_time_end', '9999-12-31'), ]); } }); @@ -1451,7 +1542,10 @@ public function addTimeRangeFilter() public function addSort() { if (isset($this->sort) && isset($this->freshColumns[$this->sort]) && $this->freshColumns[$this->sort]['name']) { - $this->query->orderBy(DB::raw($this->getSortString()), $this->direction ? 'asc' : 'desc'); + if (isset($this->pinnedRecords) && $this->pinnedRecords) { + $this->query->orderBy(DB::raw('FIELD(id,' . implode(',', $this->pinnedRecords) . ')'), 'DESC'); + } + $this->query->orderBy(DB::raw($this->getSortString($this->query->getConnection()->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME))), $this->direction ? 'asc' : 'desc'); } return $this; @@ -1484,6 +1578,9 @@ public function mapCallbacks($paginatedCollection, $export = false) { $paginatedCollection->collect()->map(function ($row, $i) use ($export) { foreach ($row as $name => $value) { + if ($this->search && ! config('livewire-datatables.suppress_search_highlights') && $this->searchableColumns()->firstWhere('name', $name)) { + $row->$name = $this->highlight($row->$name, $this->search); + } if ($export && isset($this->export_callbacks[$name])) { $values = Str::contains($value, static::SEPARATOR) ? explode(static::SEPARATOR, $value) : [$value, $row]; $row->$name = $this->export_callbacks[$name](...$values); @@ -1492,10 +1589,8 @@ public function mapCallbacks($paginatedCollection, $export = false) 'value' => $value, 'key' => $this->builder()->getModel()->getQualifiedKeyName(), 'column' => Str::after($name, '.'), - 'rowId' => $row->{$this->builder()->getModel()->getTable() . '.' . $this->builder()->getModel()->getKeyName()} ?? $row->{$this->builder()->getModel()->getKeyName()}, + 'rowId' => $row->{$name . '_edit_id'}, ]); - } elseif ($export && isset($this->export_callbacks[$name])) { - $row->$name = $this->export_callbacks[$name]($value, $row); } elseif (isset($this->callbacks[$name]) && is_string($this->callbacks[$name])) { $row->$name = $this->{$this->callbacks[$name]}($value, $row); } elseif (Str::startsWith($name, 'callback_')) { @@ -1503,10 +1598,6 @@ public function mapCallbacks($paginatedCollection, $export = false) } elseif (isset($this->callbacks[$name]) && is_callable($this->callbacks[$name])) { $row->$name = $this->callbacks[$name]($value, $row); } - - if ($this->search && ! config('livewire-datatables.suppress_search_highlights') && $this->searchableColumns()->firstWhere('name', $name)) { - $row->$name = $this->highlight($row->$name, $this->search); - } } return $row; @@ -1582,24 +1673,30 @@ public function render() return view('datatables::datatable')->layoutData(['title' => $this->title]); } - public function export() + public function export(string $filename = 'DatatableExport.xlsx') { $this->forgetComputed(); - $results = $this->mapCallbacks( + $export = new DatatableExport($this->getExportResultsSet()); + $export->setFilename($filename); + + return $export->download(); + } + + public function getExportResultsSet() + { + return $this->mapCallbacks( $this->getQuery()->when(count($this->selected), function ($query) { return $query->havingRaw('checkbox_attribute IN (' . implode(',', $this->selected) . ')'); })->get(), true )->map(function ($item) { - return collect($this->columns)->reject(function ($value, $key) { + return collect($this->columns)->reject(function ($value) { return $value['preventExport'] == true || $value['hidden'] == true; - })->mapWithKeys(function ($value, $key) use ($item) { + })->mapWithKeys(function ($value) use ($item) { return [$value['label'] ?? $value['name'] => $item->{$value['name']}]; })->all(); }); - - return Excel::download(new DatatableExport($results), $this->export_name ? $this->export_name . '.xlsx' : 'DatatableExport.xlsx'); } public function getQuery($export = false) @@ -1611,8 +1708,6 @@ public function getQuery($export = false) public function checkboxQuery() { - $this->resolveCheckboxColumnName(collect($this->freshColumns)->firstWhere('type', 'checkbox')); - return $this->query->reorder()->get()->map(function ($row) { return (string) $row->checkbox_attribute; }); @@ -1620,14 +1715,38 @@ public function checkboxQuery() public function toggleSelectAll() { - if (count($this->selected) === $this->getQuery()->count()) { - $this->selected = []; + $visible_checkboxes = $this->getQuery()->get()->pluck('checkbox_attribute')->toArray(); + $visible_checkboxes = array_map('strval', $visible_checkboxes); + if ($this->searchOrFilterActive()) { + if (count($this->visibleSelected) === count($visible_checkboxes)) { + $this->selected = array_values(array_diff($this->selected, $visible_checkboxes)); + $this->visibleSelected = []; + } else { + $this->selected = array_unique(array_merge($this->selected, $visible_checkboxes)); + sort($this->selected); + $this->visibleSelected = $visible_checkboxes; + } } else { - $this->selected = $this->checkboxQuery()->values()->toArray(); + if (count($this->selected) === $this->getQuery()->getCountForPagination()) { + $this->selected = []; + } else { + $this->selected = $this->checkboxQuery()->values()->toArray(); + } + $this->visibleSelected = $this->selected; } + $this->forgetComputed(); } + public function updatedSelected() + { + if ($this->searchOrFilterActive()) { + $this->setVisibleSelected(); + } else { + $this->visibleSelected = $this->selected; + } + } + public function rowIsSelected($row) { return isset($row->checkbox_attribute) && in_array($row->checkbox_attribute, $this->selected); @@ -1648,6 +1767,11 @@ public function getSavedQueries() // Override this method with your own method for getting saved queries } + public function buildActions() + { + // Override this method with your own method for creating mass actions + } + public function rowClasses($row, $loop) { // Override this method with your own method for adding classes to a row @@ -1667,4 +1791,81 @@ public function cellClasses($row, $column) // Override this method with your own method for adding classes to a cell return config('livewire-datatables.default_classes.cell', 'text-sm text-gray-900'); } + + public function getMassActions() + { + return collect($this->massActions)->map(function ($action) { + return collect($action)->only(['group', 'value', 'label'])->toArray(); + })->toArray(); + } + + public function getMassActionsProperty() + { + $actions = collect($this->buildActions())->flatten(); + + $duplicates = $actions->pluck('value')->duplicates(); + + if ($duplicates->count()) { + throw new Exception('Duplicate Mass Action(s): ' . implode(', ', $duplicates->toArray())); + } + + return $actions->toArray(); + } + + public function getMassActionsOptionsProperty() + { + return collect($this->actions)->groupBy(function ($item) { + return $item['group']; + }, true); + } + + public function massActionOptionHandler() + { + if (! $this->massActionOption) { + return; + } + + $option = $this->massActionOption; + + $action = collect($this->massActions)->filter(function ($item) use ($option) { + return $item->value === $option; + })->shift(); + + $collection = collect($action); + + if ($collection->get('isExport')) { + $datatableExport = new DatatableExport($this->getExportResultsSet()); + + $datatableExport->setFileName($collection->get('fileName')); + + $datatableExport->setStyles($collection->get('styles')); + + $datatableExport->setColumnWidths($collection->get('widths')); + + return $datatableExport->download(); + } + + if (! count($this->selected)) { + $this->massActionOption = null; + + return; + } + + if ($collection->has('callable') && is_callable($action->callable)) { + $action->callable($option, $this->selected); + } + + $this->massActionOption = null; + } + + private function searchOrFilterActive() + { + return ! empty($this->search) || $this->getActiveFiltersProperty(); + } + + private function setVisibleSelected() + { + $this->visibleSelected = array_intersect($this->getQuery()->get()->pluck('checkbox_attribute')->toArray(), $this->selected); + $this->visibleSelected = array_map('strval', $this->visibleSelected); + } } diff --git a/src/LivewireDatatablesServiceProvider.php b/src/LivewireDatatablesServiceProvider.php index 8fcaa3f2..f63dde05 100644 --- a/src/LivewireDatatablesServiceProvider.php +++ b/src/LivewireDatatablesServiceProvider.php @@ -11,6 +11,7 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Livewire\Livewire; +use Mediconesystems\LivewireDatatables\Commands\DatatableMakeCommand; use Mediconesystems\LivewireDatatables\Commands\MakeDatatableCommand; use Mediconesystems\LivewireDatatables\Http\Controllers\FileExportController; use Mediconesystems\LivewireDatatables\Http\Livewire\ComplexQuery; @@ -48,7 +49,7 @@ public function boot() __DIR__ . '/../resources/views/icons' => resource_path('views/livewire/datatables/icons'), ], 'views'); - $this->commands([MakeDatatableCommand::class]); + $this->commands([MakeDatatableCommand::class, DatatableMakeCommand::class]); } Route::get('/datatables/{filename}', [FileExportController::class, 'handle']) @@ -94,7 +95,7 @@ public function loadEloquentBuilderMacros() $table = $relation->getRelated()->newQuery()->getQuery()->from === $this->getQuery()->from ? $relation->getRelationCountHashWithoutIncrementing() - : $relation->getRelated()->getTable(); + : ($this->query->getConnection()->getTablePrefix() ?? '') . $relation->getRelated()->getTable(); $query = $relation->getRelationExistenceAggregatesQuery( $relation->getRelated()->newQuery(), @@ -124,7 +125,7 @@ public function loadEloquentBuilderMacros() $table = $relation->getRelated()->newQuery()->getQuery()->from === $this->getQuery()->from ? $relation->getRelationCountHashWithoutIncrementing() - : $relation->getRelated()->getTable(); + : ($this->query->getConnection()->getTablePrefix() ?? '') . $relation->getRelated()->getTable(); $hasQuery = $relation->getRelationExistenceAggregatesQuery( $relation->getRelated()->newQueryWithoutRelationships(), @@ -149,6 +150,7 @@ public function loadRelationMacros() } $expression = $aggregate === 'group_concat' + ? $distinct_aggregate : new Expression('COALESCE(' . $aggregate . "({$column}),0)"); diff --git a/src/NumberColumn.php b/src/NumberColumn.php index e37dd442..7dbeb558 100644 --- a/src/NumberColumn.php +++ b/src/NumberColumn.php @@ -5,10 +5,11 @@ class NumberColumn extends Column { public $type = 'number'; - public $align = 'right'; + public $headerAlign = 'right'; + public $contentAlign = 'right'; public $round; - public function round($places = 0) + public function round($places = 0): self { $this->round = $places; @@ -18,4 +19,13 @@ public function round($places = 0) return $this; } + + public function format(int $places = 0): self + { + $this->callback = function ($value) use ($places) { + return number_format($value, $places, '.', ','); + }; + + return $this; + } } diff --git a/src/Traits/CanPinRecords.php b/src/Traits/CanPinRecords.php new file mode 100644 index 00000000..c695bbe7 --- /dev/null +++ b/src/Traits/CanPinRecords.php @@ -0,0 +1,82 @@ +label(__('Pin selected Records')) + ->callback(function ($mode, $items) { + $this->pinnedRecords = array_merge($this->pinnedRecords, $items); + $this->selected = $this->pinnedRecords; + + session()->put($this->sessionKey(), $this->pinnedRecords); + }), + + Action::value('unpin') + ->label(__('Unpin selected Records')) + ->callback(function ($mode, $items) { + $this->pinnedRecords = array_diff($this->pinnedRecords, $items); + $this->selected = $this->pinnedRecords; + + session()->put($this->sessionKey(), $this->pinnedRecords); + }), + ]); + } + + public function resetTable() + { + parent::resetTable(); + $this->pinnedRecords = []; + } + + protected function initialisePinnedRecords() + { + if (session()->has($this->sessionKey())) { + $this->pinnedRecords = session()->get($this->sessionKey()); + } + + $this->selected = $this->pinnedRecords; + } + + /** + * This function should be called after every filter method to ensure pinned records appear + * in every possible filter combination. + * Ensures to have at least _one other_ where applied to the current query build + * to apply this orWhere() on top of that. + */ + protected function applyPinnedRecords(): self + { + if (isset($this->pinnedRecords) && $this->pinnedRecords && $this->query->getQuery()->wheres) { + $this->query->orWhereIn('id', $this->pinnedRecords); + } + + return $this; + } + + private function sessionKey(): string + { + return $this->sessionStorageKey() . $this->sessionKeyPostfix; + } +} diff --git a/src/Traits/WithCallbacks.php b/src/Traits/WithCallbacks.php index 5a72d64a..540fa8f9 100644 --- a/src/Traits/WithCallbacks.php +++ b/src/Traits/WithCallbacks.php @@ -13,6 +13,6 @@ public function edited($value, $key, $column, $rowId) ->where(Str::after($key, '.'), $rowId) ->update([$column => $value]); - $this->emit('fieldEdited', $rowId); + $this->emit('fieldEdited', $rowId, $column); } } diff --git a/tests/ColumnTest.php b/tests/ColumnTest.php index c779ac4c..20b772b6 100644 --- a/tests/ColumnTest.php +++ b/tests/ColumnTest.php @@ -81,6 +81,7 @@ public function it_returns_an_array_from_column() 'sort' => null, 'defaultSort' => null, 'searchable' => null, + 'sortable' => null, 'params' => [], 'additionalSelects' => [], 'scope' => null, @@ -89,10 +90,9 @@ public function it_returns_an_array_from_column() 'select' => null, 'joins' => null, 'aggregate' => 'group_concat', - 'align' => 'left', + 'headerAlign' => 'left', 'preventExport' => null, 'width' => null, - 'unsortable' => null, 'exportCallback' => function () { }, 'filterOn' => null, @@ -126,6 +126,7 @@ public function it_returns_an_array_from_raw() 'sort' => 'SELECT column FROM table', 'defaultSort' => 'asc', 'searchable' => null, + 'sortable' => null, 'params' => [], 'additionalSelects' => [], 'scope' => null, @@ -133,10 +134,9 @@ public function it_returns_an_array_from_raw() 'filterView' => null, 'select' => DB::raw('SELECT column FROM table'), 'joins' => null, - 'align' => 'left', + 'headerAlign' => 'left', 'preventExport' => null, 'width' => null, - 'unsortable' => null, 'exportCallback' => null, 'filterOn' => null, 'group' => null, @@ -167,6 +167,7 @@ public function it_returns_width_property_from_column() 'sort' => null, 'defaultSort' => null, 'searchable' => null, + 'sortable' => null, 'params' => [], 'additionalSelects' => [], 'scope' => null, @@ -175,10 +176,9 @@ public function it_returns_width_property_from_column() 'select' => null, 'joins' => null, 'aggregate' => 'group_concat', - 'align' => 'left', + 'headerAlign' => 'left', 'preventExport' => null, 'width' => '1em', - 'unsortable' => null, 'exportCallback' => null, 'filterOn' => null, 'group' => null, @@ -216,7 +216,7 @@ public function check_invalid_width_unit_not_returning_value() 'select' => null, 'joins' => null, 'aggregate' => 'group_concat', - 'align' => 'left', + 'headerAlign' => 'left', 'preventExport' => null, 'width' => null, ], $subject); @@ -249,7 +249,7 @@ public function check_adding_px_to_numeric_width_input() 'select' => null, 'joins' => null, 'aggregate' => 'group_concat', - 'align' => 'left', + 'headerAlign' => 'left', 'preventExport' => null, 'width' => '5px', ], $subject); diff --git a/tests/LivewireDatatableQueryBuilderTest.php b/tests/LivewireDatatableQueryBuilderTest.php index 6d839891..725b5c6f 100644 --- a/tests/LivewireDatatableQueryBuilderTest.php +++ b/tests/LivewireDatatableQueryBuilderTest.php @@ -123,17 +123,17 @@ public function it_creates_a_query_builder_for_belongs_to_relation_columns() $subject = new LivewireDatatable(1); $subject->mount(DummyHasManyModel::class, ['id', 'dummy_model.name']); - $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_has_many_models"."dummy_model_id" = "dummy_models"."id" order by `id` desc', $subject->getQuery()->toSql()); + $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_models"."id" = "dummy_has_many_models"."dummy_model_id" order by `id` desc', $subject->getQuery()->toSql()); $subject->sort(1); $subject->forgetComputed(); - $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_has_many_models"."dummy_model_id" = "dummy_models"."id" order by dummy_models.name desc', $subject->getQuery()->toSql()); + $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_models"."id" = "dummy_has_many_models"."dummy_model_id" order by dummy_models.name desc', $subject->getQuery()->toSql()); $subject->sort(1); $subject->forgetComputed(); - $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_has_many_models"."dummy_model_id" = "dummy_models"."id" order by dummy_models.name asc', $subject->getQuery()->toSql()); + $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_models"."id" = "dummy_has_many_models"."dummy_model_id" order by dummy_models.name asc', $subject->getQuery()->toSql()); } /** @test */ @@ -148,7 +148,7 @@ public function it_creates_a_where_query_for_belongs_to_relation_columns() // $subject->doNumberFilterEnd(1, 456); $subject->forgetComputed(); - $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_has_many_models"."dummy_model_id" = "dummy_models"."id" where (dummy_models.name >= ?) order by dummy_has_many_models.id desc', $subject->getQuery()->toSql()); + $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_models"."id" = "dummy_has_many_models"."dummy_model_id" where (dummy_models.name >= ?) order by dummy_has_many_models.id desc', $subject->getQuery()->toSql()); $this->assertEquals([123], $subject->getQuery()->getBindings()); @@ -163,7 +163,7 @@ public function it_creates_a_where_query_for_belongs_to_relation_columns() $subject->doNumberFilterEnd(1, null); $subject->forgetComputed(); - $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_has_many_models"."dummy_model_id" = "dummy_models"."id" order by dummy_has_many_models.id desc', $subject->getQuery()->toSql()); + $this->assertEquals('select "dummy_has_many_models"."id" as "id", "dummy_models"."name" as "dummy_model.name" from "dummy_has_many_models" left join "dummy_models" on "dummy_models"."id" = "dummy_has_many_models"."dummy_model_id" order by dummy_has_many_models.id desc', $subject->getQuery()->toSql()); $this->assertEquals([], $subject->getQuery()->getBindings()); } diff --git a/tests/MakeDatatableCommandTest.php b/tests/MakeDatatableCommandTest.php index 578aa243..4414f268 100644 --- a/tests/MakeDatatableCommandTest.php +++ b/tests/MakeDatatableCommandTest.php @@ -12,7 +12,7 @@ class MakeDatatableCommandTest extends TestCase /** @test */ public function component_is_created_by_make_command() { - Artisan::call('livewire:datatable', ['name' => 'foo']); + Artisan::call('make:livewire-datatable', ['name' => 'foo']); $this->assertTrue(File::exists($this->livewireClassesPath('Foo.php'))); } @@ -20,7 +20,7 @@ public function component_is_created_by_make_command() /** @test */ public function dot_nested_component_is_created_by_make_command() { - Artisan::call('livewire:datatable', ['name' => 'foo.bar']); + Artisan::call('make:livewire-datatable', ['name' => 'foo.bar']); $this->assertTrue(File::exists($this->livewireClassesPath('Foo/Bar.php'))); } @@ -28,7 +28,7 @@ public function dot_nested_component_is_created_by_make_command() /** @test */ public function forward_slash_nested_component_is_created_by_make_command() { - Artisan::call('livewire:datatable', ['name' => 'foo/bar']); + Artisan::call('make:livewire-datatable', ['name' => 'foo/bar']); $this->assertTrue(File::exists($this->livewireClassesPath('Foo/Bar.php'))); } @@ -36,7 +36,7 @@ public function forward_slash_nested_component_is_created_by_make_command() /** @test */ public function multiword_component_is_created_by_make_command() { - Artisan::call('livewire:datatable', ['name' => 'foo-bar']); + Artisan::call('make:livewire-datatable', ['name' => 'foo-bar']); $this->assertTrue(File::exists($this->livewireClassesPath('FooBar.php'))); } @@ -44,7 +44,7 @@ public function multiword_component_is_created_by_make_command() /** @test */ public function pascal_case_component_is_automatically_converted_by_make_command() { - Artisan::call('livewire:datatable', ['name' => 'FooBar.FooBar']); + Artisan::call('make:livewire-datatable', ['name' => 'FooBar.FooBar']); $this->assertTrue(File::exists($this->livewireClassesPath('FooBar/FooBar.php'))); } @@ -52,7 +52,7 @@ public function pascal_case_component_is_automatically_converted_by_make_command /** @test */ public function snake_case_component_is_automatically_converted_by_make_command() { - Artisan::call('livewire:datatable', ['name' => 'text_replace']); + Artisan::call('make:livewire-datatable', ['name' => 'text_replace']); $this->assertTrue(File::exists($this->livewireClassesPath('TextReplace.php'))); } @@ -60,7 +60,7 @@ public function snake_case_component_is_automatically_converted_by_make_command( /** @test */ public function snake_case_component_is_automatically_converted_by_make_command_on_nested_component() { - Artisan::call('livewire:datatable', ['name' => 'TextManager.text_replace']); + Artisan::call('make:livewire-datatable', ['name' => 'TextManager.text_replace']); $this->assertTrue(File::exists($this->livewireClassesPath('TextManager/TextReplace.php'))); } @@ -79,7 +79,7 @@ public function new_component_model_name_matches_option() /** @test */ public function a_component_is_not_created_with_a_reserved_class_name() { - Artisan::call('livewire:datatable', ['name' => 'component']); + Artisan::call('make:livewire-datatable', ['name' => 'component']); $this->assertFalse(File::exists($this->livewireClassesPath('Component.php'))); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 7f834513..9680e5d3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use Maatwebsite\Excel\ExcelServiceProvider; use Mediconesystems\LivewireDatatables\LivewireDatatablesServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; +use Reedware\LaravelRelationJoins\LaravelRelationJoinServiceProvider; class TestCase extends Orchestra { @@ -26,6 +27,7 @@ protected function getPackageProviders($app) LivewireServiceProvider::class, LivewireDatatablesServiceProvider::class, ExcelServiceProvider::class, + LaravelRelationJoinServiceProvider::class, ]; }