├── .styleci.yml ├── .gitignore ├── tests ├── Models │ ├── Post.php │ ├── Comment.php │ ├── Profile.php │ └── User.php ├── TestCase.php └── Feature │ ├── LinkTest.php │ └── TraitTest.php ├── SECURITY.md ├── .editorconfig ├── .gitattributes ├── src ├── Exceptions │ └── SortableException.php ├── View │ └── Components │ │ └── SortableLink.php ├── Provider.php ├── Config │ └── sortable.php ├── Traits │ └── Sortable.php └── Support │ └── SortableLink.php ├── phpunit.xml ├── LICENSE.md ├── composer.json ├── .github └── workflows │ └── tests.yml └── README.md /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr2 2 | 3 | enabled: 4 | - concat_with_spaces -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.history 3 | /.vscode 4 | /tests/databases 5 | /vendor 6 | .DS_Store 7 | .phpunit.result.cache 8 | composer.phar 9 | composer.lock 10 | -------------------------------------------------------------------------------- /tests/Models/Post.php: -------------------------------------------------------------------------------- 1 | belongsTo(Comment::class, 'parent_id'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Models/Profile.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 21 | } 22 | 23 | public function compositeSortable($query, $direction) 24 | { 25 | return $query->orderBy('phone', $direction)->orderBy('address', $direction); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text eol=lf 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.c text 7 | *.h text 8 | 9 | # Declare files that will always have CRLF line endings on checkout. 10 | *.sln text eol=crlf 11 | 12 | # Denote all files that are truly binary and should not be modified. 13 | *.png binary 14 | *.jpg binary 15 | *.otf binary 16 | *.eot binary 17 | *.svg binary 18 | *.ttf binary 19 | *.woff binary 20 | *.woff2 binary 21 | 22 | *.css linguist-vendored 23 | *.scss linguist-vendored 24 | *.js linguist-vendored 25 | CHANGELOG.md export-ignore 26 | -------------------------------------------------------------------------------- /tests/Models/User.php: -------------------------------------------------------------------------------- 1 | hasOne(Profile::class, 'user_id', 'id'); 25 | } 26 | 27 | public function addressSortable($query, $direction) 28 | { 29 | return $query->join('profiles', 'users.id', '=', 'profiles.user_id')->orderBy('address', $direction)->select('users.*'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exceptions/SortableException.php: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./src 13 | 14 | 15 | 16 | 17 | tests 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Akaunting 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akaunting/laravel-sortable", 3 | "description": "Sortable behavior package for Laravel", 4 | "keywords": [ 5 | "laravel", 6 | "sort", 7 | "sortable", 8 | "sorting", 9 | "model", 10 | "view", 11 | "blade", 12 | "column" 13 | ], 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Denis Duliçi", 18 | "email": "info@akaunting.com", 19 | "homepage": "https://akaunting.com", 20 | "role": "Developer" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.0", 25 | "laravel/framework": "^9.0|^10.0" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "^9.5|^10.0", 29 | "orchestra/testbench": "^7.4|^8.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Akaunting\\Sortable\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Akaunting\\Sortable\\Tests\\": "tests" 39 | } 40 | }, 41 | "extra": { 42 | "laravel": { 43 | "providers": [ 44 | "Akaunting\\Sortable\\Provider" 45 | ] 46 | } 47 | }, 48 | "minimum-stability": "dev", 49 | "prefer-stable": true 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} 8 | 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | php: ['8.0', '8.1', '8.2'] 14 | laravel: [9.*, 10.*] 15 | stability: [prefer-lowest, prefer-stable] 16 | include: 17 | - laravel: 9.* 18 | testbench: 7.* 19 | - laravel: 10.* 20 | testbench: 8.* 21 | exclude: 22 | - laravel: 10.* 23 | php: 8.0 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v2 28 | 29 | - name: Setup PHP 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: ${{ matrix.php }} 33 | extensions: bcmath, ctype, dom, fileinfo, intl, gd, json, mbstring, pdo, pdo_sqlite, openssl, sqlite, xml, zip 34 | coverage: none 35 | 36 | - name: Install dependencies 37 | run: | 38 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 39 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 40 | - name: Execute tests 41 | run: vendor/bin/phpunit 42 | -------------------------------------------------------------------------------- /src/View/Components/SortableLink.php: -------------------------------------------------------------------------------- 1 | column = $column; 50 | $this->title = $title; 51 | $this->query = $query; 52 | $this->arguments = $arguments; 53 | } 54 | 55 | /** 56 | * Get the view / contents that represent the component. 57 | * 58 | * @return \Illuminate\View\View|\Closure|string 59 | */ 60 | public function render() 61 | { 62 | return Base::render([ 63 | $this->column, 64 | $this->title, 65 | $this->query, 66 | $this->arguments, 67 | ]); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | setUpDatabase(); 29 | 30 | $this->setUpModels(); 31 | } 32 | 33 | protected function tearDown(): void 34 | { 35 | parent::tearDown(); 36 | } 37 | 38 | protected function getPackageProviders($app) 39 | { 40 | return [ 41 | Provider::class, 42 | ]; 43 | } 44 | 45 | protected function setUpDatabase() 46 | { 47 | config(['database.default' => 'testbench']); 48 | 49 | config(['database.connections.testbench' => [ 50 | 'driver' => 'sqlite', 51 | 'database' => ':memory:', 52 | 'prefix' => '', 53 | ], 54 | ]); 55 | } 56 | 57 | protected function setUpModels() 58 | { 59 | $this->user = new User(); 60 | $this->profile = new Profile(); 61 | $this->post = new Post(); 62 | $this->comment = new Comment(); 63 | } 64 | 65 | public function getNextClosure() 66 | { 67 | return function () { 68 | return 'next'; 69 | }; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Provider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__ . '/Config/sortable.php', 'sortable'); 20 | } 21 | 22 | /** 23 | * Bootstrap the application services. 24 | * 25 | * @return void 26 | */ 27 | public function boot() 28 | { 29 | $this->publishes([ 30 | __DIR__ . '/Config/sortable.php' => config_path('sortable.php'), 31 | ], 'sortable'); 32 | 33 | $this->registerBladeDirectives(); 34 | $this->registerBladeComponents(); 35 | $this->registerMacros(); 36 | } 37 | 38 | public function registerBladeDirectives() 39 | { 40 | $this->callAfterResolving('blade.compiler', function (BladeCompiler $compiler) { 41 | $compiler->directive('sortablelink', function ($expression) { 42 | $expression = ($expression[0] === '(') ? substr($expression, 1, -1) : $expression; 43 | 44 | return ""; 45 | }); 46 | }); 47 | } 48 | 49 | public function registerBladeComponents() 50 | { 51 | Blade::component('sortablelink', SortableLink::class); 52 | } 53 | 54 | public function registerMacros() 55 | { 56 | request()->macro('allFilled', function (array $keys) { 57 | foreach ($keys as $key) { 58 | if (! $this->filled($key)) { 59 | return false; 60 | } 61 | } 62 | 63 | return true; 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Config/sortable.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'alpha' => [ 7 | 'fields' => ['description', 'email', 'name', 'slug'], 8 | 'icon' => 'fa fa-sort-alpha', 9 | ], 10 | 'amount' => [ 11 | 'fields' => ['amount', 'price'], 12 | 'icon' => 'fa fa-sort-amount', 13 | ], 14 | 'numeric' => [ 15 | 'fields' => ['created_at', 'updated_at', 'level', 'id', 'phone_number'], 16 | 'icon' => 'fa fa-sort-numeric', 17 | ], 18 | ], 19 | 20 | 'icons' => [ 21 | 'enabled' => true, 22 | 23 | 'wrapper' => '', 24 | 25 | 'default' => 'fa fa-sort', 26 | 27 | // Icon that shows when generating sortable link for columns not sorted by, not applied if value is null 28 | 'sortable' => 'fa fa-sort', 29 | 30 | 'clickable' => false, 31 | 32 | 'prefix' => ' ', 33 | 34 | 'suffix' => '', 35 | 36 | 'asc_suffix' => '-asc', 37 | 38 | 'desc_suffix' => '-desc', 39 | ], 40 | 41 | // Default anchor class, not applied if value is null 42 | 'anchor_class' => null, 43 | 44 | // Default active anchor class, not applied if value is null 45 | 'active_anchor_class' => null, 46 | 47 | // Default sort direction anchor class, not applied if value is null 48 | 'direction_anchor_class_prefix' => null, 49 | 50 | // Relation - column separator ex: author.name means relation "author" and column "name" 51 | 'relation_column_separator' => '.', 52 | 53 | // Formatting function applied to name of column, use null to turn formatting off 54 | 'formatting_function' => 'ucfirst', 55 | 56 | // Apply formatting function to custom titles as well as column names 57 | 'format_custom_titles' => true, 58 | 59 | // Inject title parameter in query strings, use null to turn injection off 60 | // Example: 'inject_title' => 't' will result in ..user/?t="formatted title of sorted column" 61 | 'inject_title_as' => null, 62 | 63 | // Allow request modification, when default sorting is set but is not in URI (first load) 64 | 'allow_request_modification' => true, 65 | 66 | // Default direction for: $user->sortable('id') usage 67 | 'default_direction' => 'asc', 68 | 69 | // Default direction for non-sorted columns 70 | 'default_direction_unsorted' => 'asc', 71 | 72 | // Use the first defined sortable column (Model::$sortable) as default 73 | // Also applies if sorting parameters are invalid for example: 'sort' => 'name', 'direction' => '' 74 | 'default_first_column' => false, 75 | 76 | // Join type: join vs leftJoin 77 | 'join_type' => 'leftJoin', 78 | 79 | ]; 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sortable behavior package for Laravel 2 | 3 | ![Downloads](https://img.shields.io/packagist/dt/akaunting/laravel-sortable) 4 | ![Tests](https://img.shields.io/github/actions/workflow/status/akaunting/laravel-sortable/tests.yml?label=tests) 5 | [![StyleCI](https://github.styleci.io/repos/442271942/shield?style=flat&branch=master)](https://styleci.io/repos/442271942) 6 | [![License](https://img.shields.io/github/license/akaunting/laravel-sortable)](LICENSE.md) 7 | 8 | This package allows you to add sortable behavior to `models` and `views`. It ships with a trait where you can set the sortable fields and a blade directive to generate table headers automatically. 9 | 10 | ## Getting Started 11 | 12 | ### 1. Install 13 | 14 | Run the following command: 15 | 16 | ```bash 17 | composer require akaunting/laravel-sortable 18 | ``` 19 | 20 | ### 2. Publish 21 | 22 | Publish configuration 23 | 24 | ```bash 25 | php artisan vendor:publish --tag=sortable 26 | ``` 27 | 28 | ### 3. Configure 29 | 30 | You can change the column sorting settings of your app from `config/sortable.php` file 31 | 32 | ## Usage 33 | 34 | All you have to do is use the `Sortable` trait inside your model and define the `$sortable` fields. 35 | 36 | ```php 37 | use Akaunting\Sortable\Traits\Sortable; 38 | use Illuminate\Database\Eloquent\Model; 39 | 40 | class Post extends Model 41 | { 42 | use Sortable; 43 | ... 44 | 45 | public $sortable = [ 46 | 'id', 47 | 'title', 48 | 'author', 49 | 'created_at', 50 | ]; 51 | ... 52 | } 53 | ``` 54 | 55 | If you don't define the `$sortable` array, the `Scheme::hasColumn()` function is used which runs an extra database query. 56 | 57 | ### Scope 58 | 59 | The trait adds a `sortable` scope to the model so you can use it just before `paginate`: 60 | 61 | ```php 62 | public function index() 63 | { 64 | $posts = Post::query()->sortable()->paginate(10); 65 | 66 | return view('posts.index')->with(['posts' => $posts]); 67 | } 68 | ``` 69 | 70 | You can set also default sorting field which will be applied when URL is empty. 71 | 72 | ```php 73 | $posts = $post->sortable(['author'])->paginate(10); // $post->orderBy('posts.author', 'asc') 74 | 75 | $posts = $post->sortable(['title'])->paginate(10); // $post->orderBy('posts.title', 'asc') 76 | 77 | $posts = $post->sortable(['title' => 'desc'])->paginate(10); // $post->orderBy('posts.title', 'desc') 78 | ``` 79 | 80 | ### Blade Directive 81 | 82 | There is also a `blade` directive for you to create sortable links in your views: 83 | 84 | ```blade 85 | @sortablelink('title', trans('general.title'), ['parameter' => 'smile'], ['rel' => 'nofollow']) 86 | ``` 87 | 88 | The *first* parameter is the column in database. The *second* one is displayed inside the anchor tag. The *third* one is an `array()`, and it sets the default (GET) query string. The *fourth* one is also an `array()` for additional anchor-tag attributes. You can use a custom URL as 'href' attribute in the fourth parameter, which will append the query string. 89 | 90 | Only the first parameter is required. 91 | 92 | Examples: 93 | 94 | ```blade 95 | @sortablelink('title') 96 | @sortablelink('title', trans('general.title')) 97 | @sortablelink('title', trans('general.title'), ['filter' => 'active, visible']) 98 | @sortablelink('title', trans('general.title'), ['filter' => 'active, visible'], ['class' => 'btn btn-success', 'rel' => 'nofollow', 'href' => route('posts.index')]) 99 | ``` 100 | 101 | #### Icon Set 102 | 103 | You can use any icon set you want. Just change the `icons.wrapper` from the config file accordingly. By default, it uses Font Awesome. 104 | 105 | ### Blade Component 106 | 107 | Same as the directive, there is also a `blade` component for you to create sortable links in your views: 108 | 109 | ```html 110 | 111 | ``` 112 | 113 | ### Sorting Relationships 114 | 115 | The package supports `HasOne` and `BelongsTo` relational sorting: 116 | 117 | ```php 118 | class Post extends Model 119 | { 120 | use Sortable; 121 | ... 122 | 123 | protected $fillable = [ 124 | 'title', 125 | 'author_id', 126 | 'body', 127 | ]; 128 | 129 | public $sortable = [ 130 | 'id', 131 | 'title', 132 | 'author', 133 | 'created_at', 134 | 'updated_at', 135 | ]; 136 | 137 | /** 138 | * Get the author associated with the post. 139 | */ 140 | public function author() 141 | { 142 | return $this->hasOne(\App\Models\Author::class); 143 | } 144 | ... 145 | } 146 | ``` 147 | 148 | And you can use the relation in views: 149 | 150 | ```blade 151 | // resources/views/posts/index.blade.php 152 | 153 | @sortablelink('title', trans('general.title')) 154 | @sortablelink('author.name', trans('general.author')) 155 | ``` 156 | 157 | > **Note**: In case there is a self-referencing model (like comments, categories etc.); parent table will be aliased with `parent_` string. 158 | 159 | ### Advanced Relation 160 | 161 | You can also extend the relation sorting feature by creating a function with `Sortable` suffix. There you're free to write your own queries and apply `orderBy()` manually: 162 | 163 | ```php 164 | class User extends Model 165 | { 166 | use Sortable; 167 | ... 168 | 169 | public $sortable = [ 170 | 'name', 171 | 'address', 172 | ]; 173 | 174 | public function addressSortable($query, $direction) 175 | { 176 | return $query->join('user_details', 'users.id', '=', 'user_details.user_id') 177 | ->orderBy('address', $direction) 178 | ->select('users.*'); 179 | } 180 | ... 181 | ``` 182 | 183 | The usage in `controller` and `view` remains the same. 184 | 185 | ### Aliasing 186 | 187 | You can declare the `$sortableAs` array in your model and use it to alias (bypass column exists check), and ignore prefixing with table: 188 | 189 | ```php 190 | public $sortableAs = [ 191 | 'nick_name', 192 | ]; 193 | ``` 194 | 195 | In controller 196 | 197 | ```php 198 | $users = $user->select(['name as nick_name'])->sortable(['nick_name'])->paginate(10); 199 | ``` 200 | 201 | In view 202 | 203 | ```blade 204 | @sortablelink('nick_name', 'nick') 205 | ``` 206 | 207 | It's very useful when you want to sort results using [`withCount()`](https://laravel.com/docs/eloquent-relationships#counting-related-models). 208 | 209 | ## Changelog 210 | 211 | Please see [Releases](../../releases) for more information what has changed recently. 212 | 213 | ## Contributing 214 | 215 | Pull requests are more than welcome. You must follow the PSR coding standards. 216 | 217 | ## Security 218 | 219 | Please review [our security policy](https://github.com/akaunting/laravel-sortable/security/policy) on how to report security vulnerabilities. 220 | 221 | ## Credits 222 | 223 | - [Denis Duliçi](https://github.com/denisdulici) 224 | - [Martin Kiesel](https://github.com/Kyslik) 225 | - [All Contributors](../../contributors) 226 | 227 | ## License 228 | 229 | The MIT License (MIT). Please see [LICENSE](LICENSE.md) for more information. 230 | -------------------------------------------------------------------------------- /src/Traits/Sortable.php: -------------------------------------------------------------------------------- 1 | allFilled(['sort', 'direction']) && $this->columnExists($this, request()->get('sort'))) { // allFilled() is macro 26 | return $this->queryOrderBuilder($query, request()->only(['sort', 'direction'])); 27 | } 28 | 29 | if (is_null($defaultParameters)) { 30 | $defaultParameters = $this->getDefaultSortable(); 31 | } 32 | 33 | if (! is_null($defaultParameters)) { 34 | $defaultSortArray = $this->formatToParameters($defaultParameters); 35 | 36 | if (config('sortable.allow_request_modification', true) && ! empty($defaultSortArray)) { 37 | request()->merge($defaultSortArray); 38 | } 39 | 40 | return $this->queryOrderBuilder($query, $defaultSortArray); 41 | } 42 | 43 | return $query; 44 | } 45 | 46 | /** 47 | * Returns the first element of defined sortable columns from the Model 48 | * 49 | * @return array|null 50 | */ 51 | private function getDefaultSortable() 52 | { 53 | if (config('sortable.default_first_column', false)) { 54 | $sortBy = Arr::first($this->sortable); 55 | 56 | if (! is_null($sortBy)) { 57 | return [$sortBy => config('sortable.default_direction', 'asc')]; 58 | } 59 | } 60 | 61 | return null; 62 | } 63 | 64 | /** 65 | * @throws SortableException 66 | */ 67 | private function queryOrderBuilder(Builder $query, array $sortParameters): Builder 68 | { 69 | $model = $this; 70 | 71 | list($column, $direction) = $this->parseParameters($sortParameters); 72 | 73 | if (is_null($column)) { 74 | return $query; 75 | } 76 | 77 | $explodeResult = SortableLink::explodeSortParameter($column); 78 | if (! empty($explodeResult)) { 79 | $relationName = $explodeResult[0]; 80 | $column = $explodeResult[1]; 81 | 82 | try { 83 | $relation = $query->getRelation($relationName); 84 | $query = $this->queryJoinBuilder($query, $relation); 85 | } catch (BadMethodCallException $e) { 86 | throw new SortableException($relationName, 1, $e); 87 | } catch (\Exception $e) { 88 | throw new SortableException($relationName, 2, $e); 89 | } 90 | 91 | $model = $relation->getRelated(); 92 | } 93 | 94 | if (method_exists($model, Str::camel($column) . 'Sortable')) { 95 | return call_user_func_array([$model, Str::camel($column) . 'Sortable'], [$query, $direction]); 96 | } 97 | 98 | if (isset($model->sortableAs) && in_array($column, $model->sortableAs)) { 99 | $query = $query->orderBy($column, $direction); 100 | } elseif ($this->columnExists($model, $column)) { 101 | $column = $model->getTable() . '.' . $column; 102 | $query = $query->orderBy($column, $direction); 103 | } 104 | 105 | return $query; 106 | } 107 | 108 | private function parseParameters(array $parameters): array 109 | { 110 | $column = Arr::get($parameters, 'sort'); 111 | if (empty($column)) { 112 | return [null, null]; 113 | } 114 | 115 | $direction = Arr::get($parameters, 'direction', []); 116 | if (! in_array(strtolower($direction), ['asc', 'desc'])) { 117 | $direction = config('sortable.default_direction', 'asc'); 118 | } 119 | 120 | return [$column, $direction]; 121 | } 122 | 123 | /** 124 | * @param BelongsTo|HasOne $relation 125 | * 126 | * @throws \Exception 127 | */ 128 | private function queryJoinBuilder(Builder $query, $relation): Builder 129 | { 130 | $relatedTable = $relation->getRelated()->getTable(); 131 | $parentTable = $relation->getParent()->getTable(); 132 | 133 | if ($parentTable === $relatedTable) { 134 | $query = $query->from($parentTable . ' as parent_' . $parentTable); 135 | $parentTable = 'parent_' . $parentTable; 136 | $relation->getParent()->setTable($parentTable); 137 | } 138 | 139 | if ($relation instanceof HasOne || $relation instanceof MorphOne) { 140 | $relatedPrimaryKey = $relation->getQualifiedForeignKeyName(); 141 | $parentPrimaryKey = $relation->getQualifiedParentKeyName(); 142 | } elseif ($relation instanceof BelongsTo) { 143 | $relatedPrimaryKey = $relation->getQualifiedOwnerKeyName(); 144 | $parentPrimaryKey = $relation->getQualifiedForeignKeyName(); 145 | } else { 146 | throw new \Exception(); 147 | } 148 | 149 | return $this->formJoin($query, $parentTable, $relatedTable, $parentPrimaryKey, $relatedPrimaryKey); 150 | } 151 | 152 | private function columnExists($model, $column): bool 153 | { 154 | return isset($model->sortable) 155 | ? in_array($column, $model->sortable) 156 | : Schema::connection($model->getConnectionName())->hasColumn($model->getTable(), $column); 157 | } 158 | 159 | /** 160 | * @param array|string $array 161 | * 162 | * @return array 163 | */ 164 | private function formatToParameters($array): array 165 | { 166 | if (empty($array)) { 167 | return []; 168 | } 169 | 170 | $defaultDirection = config('sortable.default_direction', 'asc'); 171 | 172 | if (is_string($array)) { 173 | return ['sort' => $array, 'direction' => $defaultDirection]; 174 | } 175 | 176 | return (key($array) === 0) 177 | ? ['sort' => $array[0], 'direction' => $defaultDirection] 178 | : ['sort' => key($array), 'direction' => reset($array)]; 179 | } 180 | 181 | /** 182 | * @param $query 183 | * @param $parentTable 184 | * @param $relatedTable 185 | * @param $parentPrimaryKey 186 | * @param $relatedPrimaryKey 187 | * 188 | * @return mixed 189 | */ 190 | private function formJoin($query, $parentTable, $relatedTable, $parentPrimaryKey, $relatedPrimaryKey) 191 | { 192 | $joinType = config('sortable.join_type', 'leftJoin'); 193 | 194 | return $query->select($parentTable . '.*')->{$joinType}($relatedTable, $parentPrimaryKey, '=', $relatedPrimaryKey); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/Feature/LinkTest.php: -------------------------------------------------------------------------------- 1 | 0, 'another-key' => null, 'another-one' => 1]; 16 | Request::replace($parameters); 17 | 18 | $link = SortableLink::render(['column']); 19 | $expected = http_build_query($parameters); 20 | 21 | $this->assertStringContainsString($expected, $link); 22 | } 23 | 24 | public function testQueryStringCanHoldArray(): void 25 | { 26 | $parameters = ['key' => ['part1', 'part2'], 'another-one' => 1]; 27 | Request::replace($parameters); 28 | 29 | $link = SortableLink::render(['column']); 30 | $expected = http_build_query($parameters); 31 | 32 | $this->assertStringContainsString($expected, $link); 33 | } 34 | 35 | public function testInjectTitleInQueryStrings(): void 36 | { 37 | Config::set('sortable.inject_title_as', 'title'); 38 | SortableLink::render(['column', 'ColumnTitle']); 39 | 40 | $expected = ['title' => 'ColumnTitle']; 41 | 42 | $this->assertEquals($expected, Request::all()); 43 | } 44 | 45 | public function testInjectTitleInQueryStringsIsOff(): void 46 | { 47 | Config::set('sortable.inject_title_as', null); 48 | SortableLink::render(['column', 'ColumnTitle']); 49 | 50 | $this->assertEquals([], Request::all()); 51 | } 52 | 53 | public function testGeneratingAnchorAttributes(): void 54 | { 55 | $link = SortableLink::render(['column', 'ColumnTitle', ['a' => 'b'], ['c' => 'd']]); 56 | 57 | $icon = config('sortable.icons.default'); 58 | 59 | $expected = 'ColumnTitle' . SortableLink::getIconHtml($icon); 60 | 61 | $this->assertSame($expected, $link); 62 | } 63 | 64 | public function testGeneratingTitleWithoutFormattingFunction(): void 65 | { 66 | Config::set('sortable.formatting_function', null); 67 | $link = SortableLink::render(['column']); 68 | 69 | $icon = config('sortable.icons.default'); 70 | 71 | $expected = 'column' . SortableLink::getIconHtml($icon); 72 | 73 | $this->assertSame($expected, $link); 74 | } 75 | 76 | public function testGeneratingTitle(): void 77 | { 78 | Config::set('sortable.formatting_function', 'ucfirst'); 79 | Config::set('sortable.format_custom_titles', true); 80 | $link = SortableLink::render(['column']); 81 | 82 | $icon = config('sortable.icons.default'); 83 | 84 | $expected = 'Column' . SortableLink::getIconHtml($icon); 85 | 86 | $this->assertSame($expected, $link); 87 | } 88 | 89 | public function testCustomTitle(): void 90 | { 91 | Config::set('sortable.formatting_function', 'ucfirst'); 92 | Config::set('sortable.format_custom_titles', true); 93 | $link = SortableLink::render(['column', 'ColumnTitle']); 94 | 95 | $icon = config('sortable.icons.default'); 96 | 97 | $expected = 'ColumnTitle' . SortableLink::getIconHtml($icon); 98 | 99 | $this->assertSame($expected, $link); 100 | } 101 | 102 | public function testCustomTitleWithoutFormatting(): void 103 | { 104 | Config::set('sortable.formatting_function', 'ucfirst'); 105 | Config::set('sortable.format_custom_titles', false); 106 | $link = SortableLink::render(['column', 'ColumnTitle']); 107 | 108 | $icon = config('sortable.icons.default'); 109 | 110 | $expected = 'ColumnTitle' . SortableLink::getIconHtml($icon); 111 | 112 | $this->assertSame($expected, $link); 113 | } 114 | 115 | public function testCustomTitleWithHTML(): void 116 | { 117 | Config::set('sortable.formatting_function', 'ucfirst'); 118 | Config::set('sortable.format_custom_titles', true); 119 | $link = SortableLink::render(['column', new HtmlString('ColumnTitle')]); 120 | 121 | $icon = config('sortable.icons.default'); 122 | 123 | $expected = 'ColumnTitle' . SortableLink::getIconHtml($icon); 124 | 125 | $this->assertSame($expected, $link); 126 | } 127 | 128 | public function testCustomHrefAttribute(): void 129 | { 130 | $link = SortableLink::render(['column', 'ColumnTitle', ['a' => 'b'], ['c' => 'd', 'href' => 'http://localhost/custom-path']]); 131 | 132 | $icon = config('sortable.icons.default'); 133 | 134 | $expected = 'ColumnTitle' . SortableLink::getIconHtml($icon); 135 | 136 | $this->assertSame($expected, $link); 137 | } 138 | 139 | public function testParseParameters(): void 140 | { 141 | $parameters = ['column']; 142 | $resultArray = SortableLink::parseParameters($parameters); 143 | $expected = ['column', 'column', null, [], []]; 144 | $this->assertEquals($expected, $resultArray); 145 | 146 | $parameters = ['column', 'ColumnTitle']; 147 | $resultArray = SortableLink::parseParameters($parameters); 148 | $expected = ['column', 'column', 'ColumnTitle', [], []]; 149 | $this->assertEquals($expected, $resultArray); 150 | 151 | $parameters = ['column', 'ColumnTitle', ['world' => 'matrix']]; 152 | $resultArray = SortableLink::parseParameters($parameters); 153 | $expected = ['column', 'column', 'ColumnTitle', ['world' => 'matrix'], []]; 154 | $this->assertEquals($expected, $resultArray); 155 | 156 | $parameters = ['column', 'ColumnTitle', ['world' => 'matrix'], ['white' => 'rabbit']]; 157 | $resultArray = SortableLink::parseParameters($parameters); 158 | $expected = ['column', 'column', 'ColumnTitle', ['world' => 'matrix'], ['white' => 'rabbit']]; 159 | $this->assertEquals($expected, $resultArray); 160 | 161 | $parameters = ['relation.column']; 162 | $resultArray = SortableLink::parseParameters($parameters); 163 | $expected = ['column', 'relation.column', null, [], []]; 164 | $this->assertEquals($expected, $resultArray); 165 | 166 | $parameters = ['relation.column', 'ColumnTitle']; 167 | $resultArray = SortableLink::parseParameters($parameters); 168 | $expected = ['column', 'relation.column', 'ColumnTitle', [], []]; 169 | $this->assertEquals($expected, $resultArray); 170 | 171 | $parameters = ['relation.column', 'ColumnTitle', ['world' => 'matrix']]; 172 | $resultArray = SortableLink::parseParameters($parameters); 173 | $expected = ['column', 'relation.column', 'ColumnTitle', ['world' => 'matrix'], []]; 174 | $this->assertEquals($expected, $resultArray); 175 | 176 | $parameters = ['relation.column', 'ColumnTitle', ['world' => 'matrix'], ['red' => 'pill']]; 177 | $resultArray = SortableLink::parseParameters($parameters); 178 | $expected = ['column', 'relation.column', 'ColumnTitle', ['world' => 'matrix'], ['red' => 'pill']]; 179 | $this->assertEquals($expected, $resultArray); 180 | } 181 | 182 | public function testGetOneToOneSort(): void 183 | { 184 | $sortParameter = 'relation-name.column'; 185 | $resultArray = SortableLink::explodeSortParameter($sortParameter); 186 | $expected = ['relation-name', 'column']; 187 | $this->assertEquals($expected, $resultArray); 188 | 189 | $sortParameter = 'column'; 190 | $resultArray = SortableLink::explodeSortParameter($sortParameter); 191 | $expected = []; 192 | $this->assertEquals($expected, $resultArray); 193 | } 194 | 195 | public function testGetOneToOneSortThrowsException(): void 196 | { 197 | $this->expectException('\Exception'); 198 | $this->expectExceptionCode(0); 199 | $sortParameter = 'relation-name..column'; 200 | SortableLink::explodeSortParameter($sortParameter); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Support/SortableLink.php: -------------------------------------------------------------------------------- 1 | merge([$mergeTitleAs => $title]); 22 | } 23 | 24 | list($icon, $direction) = self::getDirectionAndIcon($sortColumn, $sortParameter); 25 | 26 | $trailingTag = self::getTrailingTag($icon); 27 | 28 | $anchorClass = self::getAnchorClass($sortParameter, $anchorAttributes); 29 | 30 | $anchorAttributesString = self::buildAnchorAttributesString($anchorAttributes); 31 | 32 | $queryString = self::buildQueryString($queryParameters, $sortParameter, $direction); 33 | 34 | $url = self::buildUrl($queryString, $anchorAttributes); 35 | 36 | return '' . e($title) . $trailingTag; 37 | } 38 | 39 | /** 40 | * @throws SortableException 41 | */ 42 | public static function parseParameters(array $parameters): array 43 | { 44 | //TODO: let 2nd parameter be both title, or default query parameters 45 | //TODO: needs some checks before determining $title 46 | $explodeResult = self::explodeSortParameter($parameters[0]); 47 | $sortColumn = (empty($explodeResult)) ? $parameters[0] : $explodeResult[1]; 48 | $title = (count($parameters) === 1) ? null : $parameters[1]; 49 | $queryParameters = (isset($parameters[2]) && is_array($parameters[2])) ? $parameters[2] : []; 50 | $anchorAttributes = (isset($parameters[3]) && is_array($parameters[3])) ? $parameters[3] : []; 51 | 52 | return [$sortColumn, $parameters[0], $title, $queryParameters, $anchorAttributes]; 53 | } 54 | 55 | /** 56 | * Explodes parameter if possible and returns array [column, relation] 57 | * Empty array is returned if explode could not run eg: separator was not found. 58 | * 59 | * @throws SortableException 60 | */ 61 | public static function explodeSortParameter(string $parameter): array 62 | { 63 | $separator = config('sortable.relation_column_separator'); 64 | 65 | if (Str::contains($parameter, $separator)) { 66 | $oneToOneSort = explode($separator, $parameter); 67 | 68 | if (count($oneToOneSort) !== 2) { 69 | throw new SortableException(); 70 | } 71 | 72 | return $oneToOneSort; 73 | } 74 | 75 | return []; 76 | } 77 | 78 | /** 79 | * @param string|Htmlable|null $title 80 | * 81 | * @return string|Htmlable 82 | */ 83 | private static function applyFormatting($title, string $sortColumn) 84 | { 85 | if ($title instanceof Htmlable) { 86 | return $title; 87 | } 88 | 89 | if ($title === null) { 90 | $title = $sortColumn; 91 | } elseif (! config('sortable.format_custom_titles')) { 92 | return $title; 93 | } 94 | 95 | $formatting_function = config('sortable.formatting_function'); 96 | if (! is_null($formatting_function) && function_exists($formatting_function)) { 97 | $title = call_user_func($formatting_function, $title); 98 | } 99 | 100 | // clear special chars 101 | $title = htmlspecialchars_decode($title, ENT_QUOTES); 102 | 103 | return $title; 104 | } 105 | 106 | private static function getDirectionAndIcon($sortColumn, $sortParameter): array 107 | { 108 | $icon = self::selectIcon($sortColumn); 109 | 110 | $sort = request()->get('sort'); 111 | $dir = request()->get('direction'); 112 | 113 | if (($sort == $sortParameter) && in_array($dir, ['asc', 'desc'])) { 114 | $icon .= ($dir === 'asc') 115 | ? config('sortable.icons.asc_suffix') 116 | : config('sortable.icons.desc_suffix'); 117 | 118 | $direction = ($dir === 'desc') ? 'asc' : 'desc'; 119 | } else { 120 | $icon = config('sortable.icons.sortable'); 121 | $direction = config('sortable.default_direction_unsorted'); 122 | } 123 | 124 | $icon = static::getIconHtml($icon); 125 | 126 | return [$icon, $direction]; 127 | } 128 | 129 | private static function selectIcon($sortColumn): string 130 | { 131 | $icon = config('sortable.icons.default'); 132 | 133 | foreach (config('sortable.types') as $value) { 134 | if (in_array($sortColumn, $value['fields'])) { 135 | $icon = $value['icon']; 136 | } 137 | } 138 | 139 | return $icon; 140 | } 141 | 142 | /** 143 | * @param string|null $icon 144 | */ 145 | private static function getTrailingTag($icon): string 146 | { 147 | if (! config('sortable.icons.enabled')) { 148 | return ''; 149 | } 150 | 151 | if (config('sortable.icons.clickable') === true) { 152 | return $icon . ''; 153 | } 154 | 155 | return '' . $icon; 156 | } 157 | 158 | /** 159 | * Take care of special case, when `class` is passed to the sortablelink. 160 | */ 161 | private static function getAnchorClass(string $sortColumn, array &$anchorAttributes = []): string 162 | { 163 | $class = []; 164 | 165 | $anchorClass = config('sortable.anchor_class'); 166 | if ($anchorClass !== null) { 167 | $class[] = $anchorClass; 168 | } 169 | 170 | $activeClass = config('sortable.active_anchor_class'); 171 | if (($activeClass !== null) && self::shouldShowActive($sortColumn)) { 172 | $class[] = $activeClass; 173 | } 174 | 175 | $directionClassPrefix = config('sortable.direction_anchor_class_prefix'); 176 | if (($directionClassPrefix !== null) && self::shouldShowActive($sortColumn)) { 177 | $class[] = $directionClassPrefix . (request()->get('direction') === 'asc') 178 | ? config('sortable.asc_suffix', '-asc') 179 | : config('sortable.desc_suffix', '-desc'); 180 | } 181 | 182 | if (isset($anchorAttributes['class'])) { 183 | $class = array_merge($class, explode(' ', $anchorAttributes['class'])); 184 | 185 | unset($anchorAttributes['class']); 186 | } 187 | 188 | return (empty($class)) ? '' : ' class="' . implode(' ', $class) . '"'; 189 | } 190 | 191 | private static function shouldShowActive(string $sortColumn): bool 192 | { 193 | return request()->has('sort') && (request()->get('sort') == $sortColumn); 194 | } 195 | 196 | private static function buildQueryString(array $queryParameters, string $sortParameter, string $direction): string 197 | { 198 | $checkStrlenOrArray = function ($element) { 199 | return is_array($element) ? $element : strlen($element); 200 | }; 201 | 202 | $persistParameters = array_filter(request()->except('sort', 'direction', 'page'), $checkStrlenOrArray); 203 | $queryString = http_build_query(array_merge($queryParameters, $persistParameters, [ 204 | 'sort' => $sortParameter, 205 | 'direction' => $direction, 206 | ])); 207 | 208 | return $queryString; 209 | } 210 | 211 | private static function buildAnchorAttributesString(array $anchorAttributes): string 212 | { 213 | if (empty($anchorAttributes)) { 214 | return ''; 215 | } 216 | 217 | unset($anchorAttributes['href']); 218 | 219 | $attributes = []; 220 | foreach ($anchorAttributes as $k => $v) { 221 | $attributes[] = $k . ('' != $v ? '="' . $v . '"' : ''); 222 | } 223 | 224 | return ' ' . implode(' ', $attributes); 225 | } 226 | 227 | private static function buildUrl(string $queryString, array $anchorAttributes): string 228 | { 229 | $path = isset($anchorAttributes['href']) ? $anchorAttributes['href'] : request()->path(); 230 | 231 | return url($path . "?" . $queryString); 232 | } 233 | 234 | /** 235 | * @param string|null $icon 236 | */ 237 | public static function getIconHtml($icon): string 238 | { 239 | $prefix = config('sortable.icons.prefix'); 240 | $suffix = config('sortable.icons.suffix'); 241 | $wrapper = config('sortable.icons.wrapper'); 242 | 243 | return $prefix . str_replace('{icon}', $icon, $wrapper) . $suffix; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /tests/Feature/TraitTest.php: -------------------------------------------------------------------------------- 1 | set('sortable.default_first_column', false); 15 | $query = $this->user->scopeSortable($this->user->newQuery()); 16 | $this->assertEmpty($query->getQuery()->orders); 17 | 18 | config()->set('sortable.default_first_column', true); 19 | $this->user->sortable = ['name', 'id']; 20 | 21 | $query = $this->user->scopeSortable($this->user->newQuery()); 22 | $this->assertEquals([ 23 | [ 24 | 'column' => 'users.' . Arr::first($this->user->sortable), 25 | 'direction' => $this->direction, 26 | ], 27 | ], $query->getQuery()->orders); 28 | 29 | $query = $this->post->scopeSortable($this->post->newQuery()); 30 | $this->assertEmpty($query->getQuery()->orders); 31 | } 32 | 33 | public function testSortableWithRequestParameters(): void 34 | { 35 | config()->set('sortable.default_first_column', false); 36 | 37 | $usersTable = $this->user->getTable(); 38 | Request::replace(['sort' => 'name', 'direction' => 'asc']); 39 | $resultArray = $this->user->scopeSortable($this->user->newQuery())->getQuery()->orders; 40 | $expected = ['column' => $usersTable . '.name', 'direction' => 'asc']; 41 | $this->assertEquals($expected, head($resultArray)); 42 | 43 | Request::replace(['sort' => 'name', 'direction' => 'desc']); 44 | $resultArray = $this->user->scopeSortable($this->user->newQuery())->getQuery()->orders; 45 | $expected = ['column' => $usersTable . '.name', 'direction' => 'desc']; 46 | $this->assertEquals($expected, head($resultArray)); 47 | 48 | Request::replace(['sort' => 'name', 'direction' => '']); 49 | $result = $this->user->scopeSortable($this->user->newQuery())->getQuery()->orders; 50 | $this->assertNull($result); 51 | 52 | Request::replace(['sort' => '', 'direction' => 'asc']); 53 | $result = $this->user->scopeSortable($this->user->newQuery())->getQuery()->orders; 54 | $this->assertNull($result); 55 | 56 | Request::replace(['sort' => '', 'direction' => '']); 57 | $result = $this->user->scopeSortable($this->user->newQuery())->getQuery()->orders; 58 | $this->assertNull($result); 59 | 60 | Request::replace(['sort' => 'name']); 61 | $result = $this->user->scopeSortable($this->user->newQuery())->getQuery()->orders; 62 | $this->assertNull($result); 63 | 64 | Request::replace(['sort' => '']); 65 | $result = $this->user->scopeSortable($this->user->newQuery())->getQuery()->orders; 66 | $this->assertNull($result); 67 | } 68 | 69 | public function testSortableWithDefaultAndWithoutRequestParameters(): void 70 | { 71 | $usersTable = $this->user->getTable(); 72 | $default = [ 73 | 'name' => 'desc', 74 | ]; 75 | 76 | $resultArray = $this->user->scopeSortable($this->user->newQuery(), $default)->getQuery()->orders; 77 | $expected = ['column' => $usersTable . '.name', 'direction' => 'desc']; 78 | $this->assertEquals($expected, head($resultArray)); 79 | } 80 | 81 | public function testSortableQueryJoinBuilder(): void 82 | { 83 | $query = $this->user->newQuery()->with(['profile']); 84 | $relation = $query->getRelation('profile'); 85 | $resultQuery = $this->invokeMethod($this->user, 'queryJoinBuilder', [$query, $relation]); 86 | $expectedQuery = $this->user->newQuery()->select('users.*')->leftJoin('profiles', 'users.id', '=', 'profiles.user_id'); 87 | $this->assertEquals($expectedQuery->toSql(), $resultQuery->toSql()); 88 | 89 | $query = $this->profile->newQuery()->with(['user']); 90 | $relation = $query->getRelation('user'); 91 | $resultQuery = $this->invokeMethod($this->user, 'queryJoinBuilder', [$query, $relation]); 92 | $expectedQuery = $this->profile->newQuery()->select('profiles.*')->leftJoin('users', 'profiles.user_id', '=', 'users.id'); 93 | $this->assertEquals($expectedQuery->toSql(), $resultQuery->toSql()); 94 | 95 | $query = $this->comment->newQuery()->with(['parent']); 96 | $relation = $query->getRelation('parent'); 97 | $resultQuery = $this->invokeMethod($this->comment, 'queryJoinBuilder', [$query, $relation]); 98 | $expectedQuery = $this->comment->newQuery()->from('comments as parent_comments')->select('parent_comments.*') 99 | ->leftJoin('comments', 'parent_comments.parent_id', '=', 'comments.id'); 100 | $this->assertEquals($expectedQuery->toSql(), $resultQuery->toSql()); 101 | } 102 | 103 | public function testSortableOverridingQueryOrderBuilder(): void 104 | { 105 | $sortParameters = ['sort' => 'address', 'direction' => 'desc']; 106 | $query = $this->user->newQuery(); 107 | $resultQuery = $this->invokeMethod($this->user, 'queryOrderBuilder', [$query, $sortParameters]); 108 | $expectedQuery = $this->user->newQuery() 109 | ->join('profiles', 'users.id', '=', 'profiles.user_id') 110 | ->orderBy('address', 'desc') 111 | ->select('users.*'); 112 | 113 | $this->assertEquals($expectedQuery, $resultQuery); 114 | } 115 | 116 | public function testSortableOverridingQueryOrderBuilderOnRelation(): void 117 | { 118 | $sortParameters = ['sort' => 'profile.composite', 'direction' => 'desc']; 119 | $query = $this->user->newQuery(); 120 | 121 | $resultQuery = $this->invokeMethod($this->user, 'queryOrderBuilder', [$query, $sortParameters]); 122 | 123 | $expectedQuery = $this->user->newQuery() 124 | ->leftJoin('profiles', 'users.id', '=', 'profiles.user_id') 125 | ->orderBy('phone', 'desc') 126 | ->orderBy('address', 'desc') 127 | ->select('users.*'); 128 | 129 | $this->assertEquals($expectedQuery, $resultQuery); 130 | } 131 | 132 | public function testSortableAs(): void 133 | { 134 | $sortParameters = ['sort' => 'nick_name', 'direction' => 'asc']; 135 | $query = $this->user->newQuery()->select('name as nick_name'); 136 | $resultQuery = $this->invokeMethod($this->user, 'queryOrderBuilder', [$query, $sortParameters]); 137 | $expectedQuery = $this->user->newQuery()->select('name as nick_name')->orderBy('nick_name', 'asc'); 138 | 139 | $this->assertEquals($expectedQuery, $resultQuery); 140 | } 141 | 142 | public function testSortableQueryJoinBuilderThrowsException(): void 143 | { 144 | $this->expectException('\Exception'); 145 | $this->expectExceptionCode(0); 146 | 147 | $query = $this->user->hasMany(Profile::class)->newQuery(); 148 | $relation = $query->getRelation('profile'); 149 | 150 | $this->invokeMethod($this->user, 'queryJoinBuilder', [$query, $relation]); 151 | } 152 | 153 | public function testSortableWithDefaultUsesConfig(): void 154 | { 155 | $usersTable = $this->user->getTable(); 156 | $default = 'name'; 157 | 158 | $resultArray = $this->user->scopeSortable($this->user->newQuery(), $default)->getQuery()->orders; 159 | $expected = ['column' => $usersTable . '.name', 'direction' => $this->direction]; 160 | $this->assertEquals($expected, head($resultArray)); 161 | 162 | $default = ['name']; 163 | 164 | $resultArray = $this->user->scopeSortable($this->user->newQuery(), $default)->getQuery()->orders; 165 | $expected = ['column' => $usersTable . '.name', 'direction' => $this->direction]; 166 | $this->assertEquals($expected, head($resultArray)); 167 | } 168 | 169 | public function testParseParameters(): void 170 | { 171 | $array = []; 172 | $resultArray = $this->invokeMethod($this->user, 'parseParameters', [$array]); 173 | $expected = [null, null]; 174 | $this->assertEquals($expected, $resultArray); 175 | 176 | $array = ['sort' => '']; 177 | $resultArray = $this->invokeMethod($this->user, 'parseParameters', [$array]); 178 | $expected = [null, null]; 179 | $this->assertEquals($expected, $resultArray); 180 | 181 | $array = ['direction' => '']; 182 | $resultArray = $this->invokeMethod($this->user, 'parseParameters', [$array]); 183 | $expected = [null, null]; 184 | $this->assertEquals($expected, $resultArray); 185 | 186 | $array = ['direction' => 'foo']; 187 | $resultArray = $this->invokeMethod($this->user, 'parseParameters', [$array]); 188 | $expected = [null, null]; 189 | $this->assertEquals($expected, $resultArray); 190 | 191 | $array = ['sort' => 'foo', 'direction' => '']; 192 | $resultArray = $this->invokeMethod($this->user, 'parseParameters', [$array]); 193 | $expected = ['foo', $this->direction]; 194 | $this->assertEquals($expected, $resultArray); 195 | 196 | $array = ['sort' => 'foo', 'direction' => 'desc']; 197 | $resultArray = $this->invokeMethod($this->user, 'parseParameters', [$array]); 198 | $expected = ['foo', 'desc']; 199 | $this->assertEquals($expected, $resultArray); 200 | 201 | $array = ['sort' => 'foo', 'direction' => 'asc']; 202 | $resultArray = $this->invokeMethod($this->user, 'parseParameters', [$array]); 203 | $expected = ['foo', 'asc']; 204 | $this->assertEquals($expected, $resultArray); 205 | 206 | $array = ['sort' => 'foo', 'direction' => 'bar']; 207 | $resultArray = $this->invokeMethod($this->user, 'parseParameters', [$array]); 208 | $expected = ['foo', $this->direction]; 209 | $this->assertEquals($expected, $resultArray); 210 | } 211 | 212 | public function testFormatToParameters(): void 213 | { 214 | $array = []; 215 | $resultArray = $this->invokeMethod($this->user, 'formatToParameters', [$array]); 216 | $expected = []; 217 | $this->assertEquals($expected, $resultArray); 218 | 219 | $array = null; 220 | $resultArray = $this->invokeMethod($this->user, 'formatToParameters', [$array]); 221 | $expected = []; 222 | $this->assertEquals($expected, $resultArray); 223 | 224 | $array = 'foo'; 225 | $resultArray = $this->invokeMethod($this->user, 'formatToParameters', [$array]); 226 | $expected = ['sort' => 'foo', 'direction' => $this->direction]; 227 | $this->assertEquals($expected, $resultArray); 228 | 229 | $array = ['foo']; 230 | $resultArray = $this->invokeMethod($this->user, 'formatToParameters', [$array]); 231 | $expected = ['sort' => 'foo', 'direction' => $this->direction]; 232 | $this->assertEquals($expected, $resultArray); 233 | 234 | $array = ['foo' => 'desc']; 235 | $resultArray = $this->invokeMethod($this->user, 'formatToParameters', [$array]); 236 | $expected = ['sort' => 'foo', 'direction' => 'desc']; 237 | $this->assertEquals($expected, $resultArray); 238 | 239 | $array = ['foo' => 'desc', 'bar' => 'asc']; 240 | $resultArray = $this->invokeMethod($this->user, 'formatToParameters', [$array]); 241 | $expected = ['sort' => 'foo', 'direction' => 'desc']; 242 | $this->assertEquals($expected, $resultArray); 243 | } 244 | 245 | /** 246 | * Call protected/private method of a class. 247 | * 248 | * @param object &$object Instantiated object that we will run method on. 249 | * @param string $methodName Method name to call 250 | * @param array $parameters Array of parameters to pass into method. 251 | * 252 | * @return mixed Method return. 253 | * @throws \ReflectionException 254 | */ 255 | protected function invokeMethod(object &$object, string $methodName, array $parameters = []) 256 | { 257 | $reflection = new \ReflectionClass(get_class($object)); 258 | $method = $reflection->getMethod($methodName); 259 | $method->setAccessible(true); 260 | 261 | return $method->invokeArgs($object, $parameters); 262 | } 263 | } 264 | --------------------------------------------------------------------------------