├── .github └── issue_template.md ├── .styleci.yml ├── LICENSE ├── README.md ├── codesize.xml ├── composer.json ├── config └── select.php ├── src ├── AppServiceProvider.php ├── Exceptions │ └── Query.php ├── Services │ └── Options.php └── Traits │ ├── OptionsBuilder.php │ └── TypeaheadBuilder.php └── tests └── features └── SelectTest.php /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 2 | This is a **bug | feature request**. 3 | 4 | 5 | ### Prerequisites 6 | * [ ] Are you running the latest version? 7 | * [ ] Are you reporting to the correct repository? 8 | * [ ] Did you check the documentation? 9 | * [ ] Did you perform a cursory search? 10 | 11 | ### Description 12 | 13 | 14 | ### Steps to Reproduce 15 | 20 | 21 | ### Expected behavior 22 | 23 | 24 | ### Actual behavior 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | risky: true 2 | 3 | preset: laravel 4 | 5 | enabled: 6 | - strict 7 | - unalign_double_arrow 8 | 9 | disabled: 10 | - short_array_syntax 11 | 12 | finder: 13 | exclude: 14 | - "public" 15 | - "resources" 16 | - "tests" 17 | name: 18 | - "*.php" 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 laravel-enso 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Select 2 | 3 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/7925de362eed457fb55996828e30a5d8)](https://www.codacy.com/gh/laravel-enso/select?utm_source=github.com&utm_medium=referral&utm_content=laravel-enso/select&utm_campaign=Badge_Grade) 4 | [![StyleCI](https://github.styleci.io/repos/85489940/shield?branch=master)](https://github.styleci.io/repos/85489940) 5 | [![License](https://poser.pugx.org/laravel-enso/select/license)](https://packagist.org/packages/laravel-enso/select) 6 | [![Total Downloads](https://poser.pugx.org/laravel-enso/select/downloads)](https://packagist.org/packages/laravel-enso/select) 7 | [![Latest Stable Version](https://poser.pugx.org/laravel-enso/select/version)](https://packagist.org/packages/laravel-enso/select) 8 | 9 | Single and multi-select server-side option list builder 10 | 11 | This package can work independently of the [Enso](https://github.com/laravel-enso/Enso) ecosystem. 12 | 13 | The front end assets that utilize this api are present in the [select](https://github.com/enso-ui/select) package. 14 | 15 | For live examples and demos, you may visit [laravel-enso.com](https://www.laravel-enso.com) 16 | 17 | [![Watch the demo](https://laravel-enso.github.io/select/screenshots/bulma_031.png)](https://laravel-enso.github.io/select/videos/bulma_demo_01.mp4) 18 | 19 | click on the photo to view a short demo in compatible browsers 20 | 21 | ### Installation, Configuration & Usage 22 | 23 | Be sure to check out the full documentation for this package available at [docs.laravel-enso.com](https://docs.laravel-enso.com/backend/select.html) 24 | 25 | ## Contributions 26 | 27 | are welcome. Pull requests are great, but issues are good too. 28 | 29 | ## License 30 | 31 | This package is released under the MIT license. 32 | -------------------------------------------------------------------------------- /codesize.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | custom rules 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-enso/select", 3 | "description": "Bootstrap Select data builder with server-side data fetching capability and a VueJS component", 4 | "keywords": [ 5 | "laravel-enso", 6 | "select", 7 | "bootstrap-select", 8 | "select-server-side" 9 | ], 10 | "homepage": "https://github.com/laravel-enso/select", 11 | "type": "library", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Adrian Ocneanu", 16 | "email": "aocneanu@gmail.com", 17 | "homepage": "https://laravel-enso.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.0", 23 | "laravel/framework": "^10.0|^11.0", 24 | "laravel-enso/filters": "^2.0", 25 | "laravel-enso/helpers": "^3.0" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "LaravelEnso\\Select\\": "src/" 30 | } 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "LaravelEnso\\Select\\AppServiceProvider" 36 | ], 37 | "aliases": [] 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /config/select.php: -------------------------------------------------------------------------------- 1 | 'id', 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Query attributes 21 | |-------------------------------------------------------------------------- 22 | | The default query attributes used for every select. You can override 23 | | it by adding a protected $queryAttributes property in the local 24 | | Options controller. 25 | */ 26 | 27 | 'queryAttributes' => ['name'], 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | SQL comparison operator 32 | |-------------------------------------------------------------------------- 33 | | The comparison operator will be the default used for every select. 34 | | Possible values for comparison operator: LIKE, ILIKE 35 | */ 36 | 37 | 'comparisonOperator' => ComparisonOperators::Like, 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Search Mode 42 | |-------------------------------------------------------------------------- 43 | | Controls the global way in which wildcards are used in the query. 44 | | Can be customized for each select. Possible values for search mode: 45 | | SearchModes::Full, SearchModes::StartsWith, SearchModes::EndsWith 46 | */ 47 | 48 | 'searchMode' => SearchModes::Full, 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Sort By Options 53 | |-------------------------------------------------------------------------- 54 | | The sort by options used for every select. 55 | | Possible options are SORT_REGULAR, SORT_NUMERIC, SORT_STRING, 56 | | SORT_NATURAL, SORT_FLAG_CASE, ... 57 | | Ex : Case-insensitive sorting : SORT_NATURAL|SORT_FLAG_CASE 58 | | @link https://php.net/manual/en/array.constants.php 59 | */ 60 | 61 | 'sortByOptions' => SORT_REGULAR, 62 | ]; 63 | -------------------------------------------------------------------------------- /src/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | load() 12 | ->publish(); 13 | } 14 | 15 | private function load() 16 | { 17 | $this->mergeConfigFrom(__DIR__.'/../config/select.php', 'enso.select'); 18 | 19 | return $this; 20 | } 21 | 22 | private function publish() 23 | { 24 | $this->publishes([ 25 | __DIR__.'/../config' => config_path('enso'), 26 | ], ['select-config', 'enso-config']); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Exceptions/Query.php: -------------------------------------------------------------------------------- 1 | trackBy = Config::get('enso.select.trackBy'); 34 | $this->queryAttributes = new Collection(Config::get('enso.select.queryAttributes')); 35 | $this->searchMode = Config::get('enso.select.searchMode'); 36 | $this->resource = null; 37 | $this->appends = null; 38 | } 39 | 40 | public function toResponse($request) 41 | { 42 | $this->request = $request; 43 | 44 | return $this->resource 45 | ? App::make($this->resource, ['resource' => null])::collection($this->data()) 46 | : $this->data(); 47 | } 48 | 49 | public function trackBy(string $trackBy): self 50 | { 51 | $this->trackBy = $trackBy; 52 | 53 | return $this; 54 | } 55 | 56 | public function queryAttributes(array $queryAttributes): self 57 | { 58 | $this->queryAttributes = new Collection($queryAttributes); 59 | 60 | return $this; 61 | } 62 | 63 | public function searchMode(string $searchMode): self 64 | { 65 | $this->searchMode = $searchMode; 66 | 67 | return $this; 68 | } 69 | 70 | public function resource(?string $resource): self 71 | { 72 | $this->resource = $resource; 73 | 74 | return $this; 75 | } 76 | 77 | public function appends(?array $appends): self 78 | { 79 | $this->appends = $appends; 80 | 81 | return $this; 82 | } 83 | 84 | private function data(): Collection 85 | { 86 | return $this->init() 87 | ->applyParams() 88 | ->applyPivotParams() 89 | ->selected() 90 | ->search() 91 | ->order() 92 | ->limit() 93 | ->get(); 94 | } 95 | 96 | private function init(): self 97 | { 98 | $this->value = $this->request->has('value') 99 | ? (array) $this->request->get('value') 100 | : []; 101 | 102 | $attribute = $this->queryAttributes->first(); 103 | $this->orderBy = $this->isNested($attribute) ? null : $attribute; 104 | 105 | return $this; 106 | } 107 | 108 | private function applyParams(): self 109 | { 110 | $this->params()->each(fn ($value, $column) => $this->query->when( 111 | $value === null, 112 | fn ($query) => $query->whereNull($column), 113 | fn ($query) => $query->whereIn($column, (array) $value) 114 | )); 115 | 116 | return $this; 117 | } 118 | 119 | private function applyPivotParams(): self 120 | { 121 | $this->pivotParams()->each(fn ($value, $relation) => $this->query 122 | ->whereHas(Str::beforeLast($relation, '.'), fn ($query) => $query->when( 123 | $value === null, 124 | fn ($query) => $query->whereNull(Str::afterLast($relation, '.')), 125 | fn ($query) => $query->whereIn(Str::afterLast($relation, '.'), (array) $value) 126 | ))); 127 | 128 | return $this; 129 | } 130 | 131 | private function selected(): self 132 | { 133 | $this->selected = (clone $this->query) 134 | ->whereIn($this->trackBy, $this->value) 135 | ->get(); 136 | 137 | return $this; 138 | } 139 | 140 | private function search(): self 141 | { 142 | $search = $this->request->get('query'); 143 | 144 | if (! $search) { 145 | return $this; 146 | } 147 | 148 | (new Search($this->query, $this->attributes(), $search)) 149 | ->relations($this->relations()) 150 | ->searchMode($this->searchMode) 151 | ->comparisonOperator(Config::get('enso.select.comparisonOperator')) 152 | ->handle(); 153 | 154 | return $this; 155 | } 156 | 157 | private function attributes(): array 158 | { 159 | return $this->queryAttributes 160 | ->reject(fn ($attribute) => $this->isNested($attribute)) 161 | ->toArray(); 162 | } 163 | 164 | private function relations(): array 165 | { 166 | return $this->queryAttributes 167 | ->filter(fn ($attribute) => $this->isNested($attribute)) 168 | ->toArray(); 169 | } 170 | 171 | private function order(): self 172 | { 173 | $this->query->when($this->orderBy, fn ($query) => $query->orderBy($this->orderBy)); 174 | 175 | return $this; 176 | } 177 | 178 | private function limit(): self 179 | { 180 | $limit = $this->request->get('paginate') ?? self::Limit; 181 | 182 | $this->query->limit($limit); 183 | 184 | return $this; 185 | } 186 | 187 | private function get(): Collection 188 | { 189 | return $this->query->whereNotIn($this->trackBy, $this->value)->get() 190 | ->toBase() 191 | ->merge($this->selected) 192 | ->when($this->orderBy !== null, fn ($results) => $results 193 | ->sortBy($this->orderBy, Config::get('enso.select.sortByOptions'))) 194 | ->values() 195 | ->when($this->appends, fn ($results) => $results->each->setAppends($this->appends)); 196 | } 197 | 198 | private function params(): Collection 199 | { 200 | return new Collection(json_decode($this->request->get('params'), true)); 201 | } 202 | 203 | private function pivotParams(): Collection 204 | { 205 | $params = json_decode($this->request->get('pivotParams'), true); 206 | 207 | return Collection::wrap($params)->dot(); 208 | } 209 | 210 | private function isNested($attribute): bool 211 | { 212 | return Str::contains($attribute, '.'); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/Traits/OptionsBuilder.php: -------------------------------------------------------------------------------- 1 | response($request); 14 | } 15 | 16 | private function response(Request $request) 17 | { 18 | $query = method_exists($this, 'query') ? $this->query($request) : App::make($this->model)::query(); 19 | 20 | return App::make(Options::class, ['query' => $query]) 21 | ->when($request->has('trackBy'), fn ($options) => $options->trackBy($request->get('trackBy'))) 22 | ->when($request->has('searchMode'), fn ($options) => $options->searchMode($request->get('searchMode'))) 23 | ->when(isset($this->queryAttributes), fn ($options) => $options->queryAttributes($this->queryAttributes)) 24 | ->when(isset($this->resource), fn ($options) => $options->resource($this->resource)) 25 | ->when(isset($this->appends), fn ($options) => $options->appends($this->appends)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Traits/TypeaheadBuilder.php: -------------------------------------------------------------------------------- 1 | convert($request); 14 | 15 | return $this->response($request); 16 | } 17 | 18 | private function convert(Request $request) 19 | { 20 | $params = json_decode($request->get('params')); 21 | 22 | $request->replace([ 23 | 'query' => $request->get('query'), 24 | 'paginate' => $request->get('paginate'), 25 | 'params' => json_encode($params?->params ?? null), 26 | 'searchMode' => $request->get('searchMode'), 27 | 'pivotParams' => json_encode($params?->pivot ?? null), 28 | 'customParams' => json_encode($params?->custom ?? null), 29 | ]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/features/SelectTest.php: -------------------------------------------------------------------------------- 1 | faker = Factory::create(); 27 | 28 | $this->createTestModelTable(); 29 | $this->createRelationTable(); 30 | 31 | $this->testModel = $this->createTestModel(); 32 | 33 | $this->createRelation(); 34 | } 35 | 36 | /** @test */ 37 | public function can_get_options_without_filters() 38 | { 39 | $response = $this->requestResponse(); 40 | 41 | $this->assertCount(SelectTestModel::count(), $response); 42 | $this->assertTrue($this->whithinResponse($response)); 43 | } 44 | 45 | /** @test */ 46 | public function can_get_empty_options_with_restricting_query() 47 | { 48 | $response = $this->requestResponse(['query' => 'NO_VALUE']); 49 | 50 | $this->assertCount(0, $response); 51 | } 52 | 53 | /** @test */ 54 | public function can_get_empty_options_with_restricting_params() 55 | { 56 | $response = $this->requestResponse(['params' => ['id' => 0]]); 57 | 58 | $this->assertCount(0, $response); 59 | } 60 | 61 | /** @test */ 62 | public function can_get_empty_options_with_restricting_pivot_params() 63 | { 64 | $response = $this->requestResponse([ 65 | 'pivotParams' => ['relation' => ['id' => 0]], 66 | ]); 67 | 68 | $this->assertCount(0, $response); 69 | } 70 | 71 | /** @test */ 72 | public function can_get_selected_options_with_restricting_filter() 73 | { 74 | $response = $this->requestResponse([ 75 | 'value' => $this->testModel->id, 76 | 'query' => 'NO_VALUE', 77 | ]); 78 | 79 | $this->assertCount(1, $response); 80 | 81 | $this->assertTrue($this->whithinResponse($response)); 82 | } 83 | 84 | /** @test */ 85 | public function can_get_filtered_options() 86 | { 87 | $response = $this->requestResponse([ 88 | 'query' => $this->testModel->email, 89 | ]); 90 | 91 | $this->assertTrue($this->whithinResponse($response)); 92 | } 93 | 94 | /** @test */ 95 | public function can_get_filtered_on_nested_attrs_options() 96 | { 97 | $response = $this->requestResponse([ 98 | 'query' => $this->testModel->relation->name, 99 | ]); 100 | 101 | $this->assertTrue($this->whithinResponse($response)); 102 | } 103 | 104 | /** @test */ 105 | public function can_get_options_with_param() 106 | { 107 | $response = $this->requestResponse([ 108 | 'params' => ['email' => $this->testModel->email], 109 | ]); 110 | 111 | $this->assertTrue($this->whithinResponse($response)); 112 | } 113 | 114 | /** @test */ 115 | public function can_get_options_with_pivot_params() 116 | { 117 | $response = $this->requestResponse([ 118 | 'pivotParams' => ['relation' => ['name' => $this->testModel->relation->name]], 119 | ]); 120 | 121 | $this->assertTrue($this->whithinResponse($response)); 122 | } 123 | 124 | /** @test */ 125 | public function can_paginate() 126 | { 127 | $paginate = 0; 128 | $response = $this->requestResponse(['paginate' => $paginate]); 129 | 130 | $this->assertCount($paginate, $response); 131 | } 132 | 133 | /** @test */ 134 | public function can_use_resource() 135 | { 136 | $this->resource = SelectTestResource::class; 137 | $response = $this->requestResponse(); 138 | 139 | $this->assertCount(SelectTestModel::count(), $response); 140 | 141 | $this->assertTrue( 142 | $response->first()->resolve()['resource'] === 'resource' 143 | ); 144 | } 145 | 146 | /** @test */ 147 | public function can_use_accessors() 148 | { 149 | $this->appends = ['custom']; 150 | $response = $this->requestResponse(); 151 | 152 | $this->assertCount(SelectTestModel::count(), $response); 153 | $this->assertTrue( 154 | $response->first()->toArray()['custom'] === $this->testModel->custom 155 | ); 156 | } 157 | 158 | private function whithinResponse($response) 159 | { 160 | return $response->pluck('email') 161 | ->contains($this->testModel->email); 162 | } 163 | 164 | private function requestResponse(array $params = []) 165 | { 166 | $request = new Request(); 167 | 168 | Collection::wrap($params)->each(fn ($value, $key) => $request->merge([ 169 | $key => is_array($value) ? json_encode($value) : $value, 170 | ])); 171 | 172 | return new Collection( 173 | $this->__invoke($request)->toResponse($request) 174 | ); 175 | } 176 | 177 | public function query() 178 | { 179 | return SelectTestModel::query(); 180 | } 181 | 182 | private function createTestModel() 183 | { 184 | return SelectTestModel::create([ 185 | 'email' => $this->faker->email, 186 | ]); 187 | } 188 | 189 | private function createRelation() 190 | { 191 | return SelectRelation::create([ 192 | 'name' => $this->faker->name, 193 | 'parent_id' => $this->testModel->id, 194 | ]); 195 | } 196 | 197 | private function createTestModelTable() 198 | { 199 | Schema::create('select_test_models', function ($table) { 200 | $table->increments('id'); 201 | $table->string('email'); 202 | $table->timestamps(); 203 | }); 204 | } 205 | 206 | private function createRelationTable() 207 | { 208 | Schema::create('select_relations', function ($table) { 209 | $table->increments('id'); 210 | $table->integer('parent_id'); 211 | $table->foreign('parent_id')->references('id')->on('select_test_models'); 212 | $table->string('name'); 213 | $table->timestamps(); 214 | }); 215 | } 216 | } 217 | class SelectTestModel extends Model 218 | { 219 | protected $fillable = ['email']; 220 | 221 | public function relation() 222 | { 223 | return $this->hasOne(SelectRelation::class, 'parent_id'); 224 | } 225 | 226 | public function getCustomAttribute() 227 | { 228 | return 'custom'; 229 | } 230 | } 231 | class SelectRelation extends Model 232 | { 233 | protected $fillable = ['name', 'parent_id']; 234 | } 235 | 236 | class SelectTestResource extends JsonResource 237 | { 238 | public function toArray($request) 239 | { 240 | return ['resource' => 'resource']; 241 | } 242 | } 243 | --------------------------------------------------------------------------------