├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Approvable.php ├── ApprovableObserver.php ├── ApprovalEvents.php ├── ApprovalFactoryStates.php ├── ApprovalRequired.php ├── ApprovalSchemaMethods.php ├── ApprovalScope.php ├── ApprovalServiceProvider.php ├── ApprovalStatuses.php ├── HandlesApproval.php └── UiCommand.php ├── stubs └── ui │ ├── ApprovalButtons.vue │ └── ApprovalStatus.vue └── tests ├── ApprovableTest.php ├── ApprovalEventsTest.php ├── ApprovalRequiredtTest.php ├── ApprovalScopeTest.php ├── HandlesApprovalTest.php ├── Models ├── Entity.php └── EntityWithCustomColumns.php ├── SuspensionOnUpdateTest.php ├── TestCase.php └── database ├── factories └── EntityFactory.php └── migrations └── 2017_11_15_085536_create_entities_table.php /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | php: [8.0, 8.1, 8.2] 12 | illuminate: [^8.0, ^9.0, ^10.0, ^11.0] 13 | exclude: 14 | - php: 8.0 15 | illuminate: ^10.0 16 | - php: 8.0 17 | illuminate: ^11.0 18 | - php: 8.1 19 | illuminate: ^11.0 20 | 21 | name: PHP ${{ matrix.php }} & Illuminate ${{ matrix.illuminate }} 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: ${{ matrix.php }} 28 | tools: composer:v2 29 | - run: composer require "illuminate/database:${{ matrix.illuminate }}" --no-update 30 | - run: composer require "illuminate/support:${{ matrix.illuminate }}" --no-update 31 | - run: composer update --no-interaction 32 | - run: vendor/bin/phpunit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | .idea 4 | .phpunit.result.cache 5 | .phpunit.cache -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Mohammad Ali Tavassoli 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/mtvs/eloquent-approval/actions/workflows/build.yml/badge.svg) 2 | 3 | # Eloquent Approval 4 | 5 | Approval process for Laravel's Eloquent models. 6 | 7 | ![eloquent-approval-preview](https://user-images.githubusercontent.com/8286154/172069783-52fd5b91-e032-4c1a-9094-9611abe4e3c8.png) 8 | 9 | ## Why we need content approval in our apps 10 | 11 | Unless you're comfortable with unacceptable content, spam and any other 12 | violations that may appear in what the users post, you need to include some 13 | sort of content approval in your app. 14 | 15 | ## Why approval process with three states 16 | 17 | Although it's possible to approve a model by using a boolean field but a field 18 | that has three possible values: pending, approved and rejected gives us more 19 | power. It differentiates between the models waiting for the decision and the 20 | rejected ones and also makes it clear for the user if their content gets rejected. 21 | 22 | ## How it works 23 | 24 | After the setup, when new entities are being created, they are marked as 25 | _pending_. Then their status can be changed to _approved_ or _rejected_. 26 | 27 | Also, when an update occurs that modifies attributes that require approval the 28 | entity becomes _suspended_ again. 29 | 30 | By default the approval scope is applied on every query and filters out the 31 | _pending_ and _rejected_ entities, so only _approved_ entities are included. 32 | You can include the entities that aren't _approved_ by explicitly specifying it. 33 | 34 | ## Install 35 | 36 | ```sh 37 | $ composer require mtvs/eloquent-approval 38 | ``` 39 | 40 | ## Setup 41 | 42 | ### Registering the service provider 43 | 44 | By default the service provider is registered automatically by Laravel package 45 | discovery otherwise you need to register it in your `config\app.php` 46 | 47 | ```php 48 | Mtvs\EloquentApproval\ApprovalServiceProvider::class 49 | ``` 50 | ### Database 51 | 52 | The following method adds two columns to the schema, one to store 53 | the _approval status_ named `approval_status` and another to store the _timestamp_ at which the 54 | last status update is occurred named `approval_at`. 55 | 56 | ```php 57 | $table->approvals() 58 | ``` 59 | 60 | You can change the default column names but then you need to specify them on the model too. 61 | 62 | ### Model 63 | 64 | Add `Approvable` trait to the model 65 | 66 | ```php 67 | use Illuminate\Database\Eloquent\Model; 68 | use Mtvs\EloquentApproval\Approvable; 69 | 70 | class Entity extends Model 71 | { 72 | use Approvable; 73 | } 74 | ``` 75 | 76 | If you want to change the default column names you need to specify them 77 | by adding class constants to your model 78 | 79 | ```php 80 | use Illuminate\Database\Eloquent\Model; 81 | use Mtvs\EloquentApproval\Approvable; 82 | 83 | class Entity extends Model 84 | { 85 | use Approvable; 86 | 87 | const APPROVAL_STATUS = 'custom_approval_status'; 88 | const APPROVAL_AT = 'custom_approval_at'; 89 | } 90 | ``` 91 | 92 | > Add `approval_at` to the model `$dates` list to get `Carbon` instances when accessing it. 93 | 94 | #### Approval Required Attributes 95 | 96 | When an update occurs that modifies attributes that require 97 | approval, the entity becomes _suspended_ again. 98 | 99 | ```php 100 | $entity->update($attributes); // an update with approval required modification 101 | 102 | $entity->isPending(); // true 103 | ``` 104 | 105 | > Note that this happens only when you perform the _update_ on `Model` object 106 | itself not by using a query `Builder` instance. 107 | 108 | By default all attributes require approval. 109 | 110 | ```php 111 | /** 112 | * @return array 113 | */ 114 | public function approvalRequired() 115 | { 116 | return ['*']; 117 | } 118 | 119 | /** 120 | * @return array 121 | */ 122 | public function approvalNotRequired() 123 | { 124 | return []; 125 | } 126 | ``` 127 | 128 | You can override them to have a custom set of approval required attributes. 129 | 130 | They work like `$fillable` and `$guarded` in the Eloquent. `approvalRequired()` returns 131 | the _black list_ while `approvalNotRequired()` returns the _white list_. 132 | 133 | ## Usage 134 | 135 | Newly created entities are marked as _pending_ and by default excluded from 136 | queries on the model. 137 | 138 | ```php 139 | Entity::create(); // #1 pending 140 | 141 | Entity::all(); // [] 142 | 143 | Entity::find(1); // null 144 | ``` 145 | 146 | ### Including all the entities 147 | 148 | ```php 149 | Entity::anyApprovalStatus()->get(); // retrieving all 150 | 151 | Entity::anyApprovalStatus()->find(1); // retrieving one 152 | 153 | Entity::anyApprovalStatus()->delete(); // deleting all 154 | ``` 155 | 156 | If you want to disable the approval scope totally on every query, you can set 157 | the `approvalScopeDisabled` on the model. 158 | 159 | ```php 160 | use Illuminate\Database\Eloquent\Model; 161 | use Mtvs\EloquentApproval\Approvable; 162 | 163 | class Entity extends Model 164 | { 165 | use Approvable; 166 | 167 | public $approvalScopeDisabled = true; 168 | } 169 | ``` 170 | 171 | ### Limiting to only a specific status 172 | 173 | ```php 174 | Entity::onlyPending()->get(); // retrieving only pending entities 175 | Entity::onlyRejected()->get(); // retrieving only rejected entities 176 | Entity::onlyApproved()->get(); // retrieving only approved entities 177 | ``` 178 | 179 | ### Updating the status 180 | 181 | #### On model objects 182 | 183 | You can update the status of an entity by using provided methods on the `Model` 184 | object. 185 | 186 | ```php 187 | $entity->approve(); // returns bool if the entity exists otherwise null 188 | $entity->reject(); // returns bool if the entity exists otherwise null 189 | $entity->suspend(); // returns bool if the entity exists otherwise null 190 | ``` 191 | 192 | #### On `Builder` objects 193 | 194 | You can update the status of more than one entity by using provided methods on `Builder` 195 | objects. 196 | 197 | ```php 198 | Entity::whereIn('id', $updateIds)->approve(); // returns number of updated 199 | Entity::whereIn('id', $updateIds)->reject(); // returns number of updated 200 | Entity::whereIn('id', $updateIds)->suspend(); // returns number of updated 201 | ``` 202 | 203 | #### Approval Timestamp 204 | 205 | When you change the approval status of an entity its `approval_at` column updates. 206 | Before the first approval action on an entity its`approval_at` is `null`. 207 | 208 | ### Check the status of an entity 209 | 210 | You can check the status of an entity using provided methods on `Model` objects. 211 | 212 | ```php 213 | $entity->isApproved(); // returns bool if entity exists otherwise null 214 | $entity->isRejected(); // returns bool if entity exists otherwise null 215 | $entity->isPending(); // returns bool if entity exists otherwise null 216 | ``` 217 | 218 | ### Approval Events 219 | 220 | There are some model events that are dispatched before and after each approval action. 221 | 222 | | Action | Before | After | 223 | |---------|------------|-----------| 224 | | approve | approving | approved | 225 | | suspend | suspending | suspended | 226 | | reject | rejecting | rejected | 227 | 228 | Also, there is a general event named `approvalChanged` that is dispatched whenever 229 | the approval status is changed regardless of the actual status. 230 | 231 | You can hook to them by calling the provided `static` methods, which are named 232 | after them, and passing your callbacks. Or by registring observers with methods 233 | with the same names. 234 | 235 | ```php 236 | use Illuminate\Database\Eloquent\Model; 237 | use Mtvs\EloquentApproval\Approvable; 238 | 239 | class Entity extends Model 240 | { 241 | use Approvable; 242 | 243 | protected static function boot() 244 | { 245 | parent::boot(); 246 | 247 | static::approving(function ($entity) { 248 | // You can halt the process by returning false 249 | }); 250 | 251 | static::approved(function ($entity) { 252 | // $entity has been approved 253 | }); 254 | 255 | // or: 256 | 257 | static::observe(ApprovalObserver::class); 258 | } 259 | } 260 | 261 | class ApprovalObserver 262 | { 263 | public function approving($entity) 264 | { 265 | // You can halt the process by returning false 266 | } 267 | 268 | public function approved($entity) 269 | { 270 | // $entity has been approved 271 | } 272 | } 273 | ``` 274 | 275 | [Eloquent model events](https://laravel.com/docs/eloquent#events) can also be mapped to your application event classes. 276 | 277 | ## Duplicate Approvals 278 | 279 | Trying to set the approval status to the current value is ignored, i.e.: 280 | no event will be dispatched and the approval timestamp won't be updated. 281 | In this case the approval method returns `false`. 282 | 283 | ## The Model Factory 284 | 285 | Import the `ApprovalFactoryStates` to be able to use the approval states 286 | when using the model factory. 287 | 288 | ```php 289 | namespace Database\Factories; 290 | 291 | use Illuminate\Database\Eloquent\Factories\Factory; 292 | use Mtvs\EloquentApproval\ApprovalFactoryStates; 293 | 294 | class EntityFactory extends Factory 295 | { 296 | use ApprovalFactoryStates; 297 | 298 | public function definition() 299 | { 300 | // 301 | } 302 | } 303 | ``` 304 | ```php 305 | Entity::factory()->approved()->create(); 306 | Entity::factory()->rejected()->create(); 307 | Entity::factory()->suspended()->create(); 308 | ``` 309 | ## Handling Approval HTTP Requests 310 | 311 | You can import the `HandlesApproval` in a controller to perform the approval 312 | operations on a model. It contains an abstract method which has to be implemented 313 | to return the model's class name. 314 | 315 | ```php 316 | namespace App\Http\Controllers\Admin; 317 | 318 | use App\Http\Controllers\Controller; 319 | use App\Models\Entity; 320 | use Mtvs\EloquentApproval\HandlesApproval; 321 | 322 | class EntitiesController extends Controller 323 | { 324 | use HandlesApproval; 325 | 326 | protected function model() 327 | { 328 | return Entity::class; 329 | } 330 | } 331 | 332 | ``` 333 | 334 | The trait's `performApproval()` does the approval and the request should be 335 | routed to this method. It has the `key` and `request` parameters which are 336 | passed to it by the router. 337 | 338 | When do the routing, don't forget to apply the `auth` and `can` middlewares for 339 | authentication and authourization. 340 | 341 | ```php 342 | Route::post( 343 | 'admin/enitiy/{key}/approval', 344 | 'Admin\EntitiesController@performApproval' 345 | )->middleware(['auth', 'can:perform-approval']) 346 | ``` 347 | 348 | The request must have a `approval_status` key with 349 | one of the possible values: `approved`, `pending`, `rejected`. 350 | 351 | ## Frontend Components 352 | 353 | There are also some UI components here written for Vue.js and Bootstrap that 354 | you can use. First install them using the `approval:ui` artisan command and 355 | then register them in your app.js file. 356 | 357 | ### Approval Buttons Component 358 | 359 | Call `` and pass the `current-status` and the `approval-url` 360 | props to be able to make HTTP requests to set the approval status. 361 | 362 | It emits the `approval-changed` event when an approval action happens. 363 | The payload of the event is an object with the new `approval_status` and 364 | `approval_at` values. Use the event to modify the corresponding keys on the 365 | `entity` that in turn should change the `current-status` prop on the following 366 | cycle. 367 | 368 | ### Approval Status Component 369 | 370 | Call `` and pass the `value` prop to show the current status. 371 | 372 | 373 | ## Inspirations 374 | 375 | When I was searching for an existing package for approval functionality 376 | on eloquent models I encountered [hootlex/laravel-moderation](https://github.com/hootlex/laravel-moderation) 377 | even though I decided to write my own package I got some helpful inspirations from that one. 378 | 379 | I also wrote different parts of the code following the way that similar parts 380 | of [Eloquent](https://github.com/laravel/framework/tree/master/src/Illuminate/Database/Eloquent) itself is written. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mtvs/eloquent-approval", 3 | "description": "Approval process for Laravel Eloquent models.", 4 | "keywords": ["laravel", "eloquent", "approval"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Mohammad Ali Tavassoli", 9 | "email": "mtvs.dev@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "illuminate/database": "^8.0|^9.0|^10.0|^11.0", 14 | "illuminate/support": "^8.0|^9.0|^10.0|^11.0" 15 | }, 16 | "require-dev": { 17 | "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0", 18 | "phpunit/phpunit": "^8.3|^9.0|^10.0|^11.0", 19 | "mockery/mockery": "^1.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Mtvs\\EloquentApproval\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Mtvs\\EloquentApproval\\Tests\\": "tests/", 29 | "Mtvs\\EloquentApproval\\Tests\\Database\\Factories\\": "tests/database/factories/" 30 | } 31 | }, 32 | "scripts": { 33 | "test": "phpunit" 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "Mtvs\\EloquentApproval\\ApprovalServiceProvider" 39 | ] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests/ 6 | tests/database 7 | tests/Models/ 8 | tests/TestCase.php 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Approvable.php: -------------------------------------------------------------------------------- 1 | getTable().'.'.$this->getApprovalStatusColumn(); 27 | } 28 | 29 | public function getApprovalAtColumn() 30 | { 31 | return defined('static::APPROVAL_AT') ? static::APPROVAL_AT : 'approval_at'; 32 | } 33 | 34 | /** 35 | * @return bool|void 36 | */ 37 | public function approve() 38 | { 39 | return $this->updateApproval( 40 | ApprovalStatuses::APPROVED, 41 | 'approving', 42 | 'approved'); 43 | } 44 | 45 | /** 46 | * @return bool|void 47 | */ 48 | public function reject() 49 | { 50 | return $this->updateApproval( 51 | ApprovalStatuses::REJECTED, 52 | 'rejecting', 53 | 'rejected'); 54 | } 55 | 56 | /** 57 | * @return bool|void 58 | */ 59 | public function suspend() 60 | { 61 | return $this->updateApproval( 62 | ApprovalStatuses::PENDING, 63 | 'suspending', 64 | 'suspended'); 65 | } 66 | 67 | /** 68 | * @param $status 69 | * @param $beforeEvent 70 | * @param $afterEvent 71 | * @return bool|void 72 | * @throws Exception 73 | */ 74 | protected function updateApproval($status, $beforeEvent, $afterEvent) 75 | { 76 | if (is_null($this->getKeyName())) { 77 | throw new Exception('No primary key defined on model.'); 78 | } 79 | 80 | if (! $this->exists) { 81 | return; 82 | } 83 | 84 | if ($this->{$this->getApprovalStatusColumn()} == $status) 85 | { 86 | return false; 87 | } 88 | 89 | if ($this->fireModelEvent($beforeEvent) === false) { 90 | return false; 91 | } 92 | 93 | $this->{$this->getApprovalStatusColumn()} = $status; 94 | 95 | $time = $this->freshTimestamp(); 96 | 97 | $this->{$this->getApprovalAtColumn()} = $time; 98 | 99 | $columns = [ 100 | $this->getApprovalStatusColumn() => $status, 101 | 102 | $this->getApprovalAtColumn() => $this->fromDateTime($time) 103 | ]; 104 | 105 | $this->getConnection() 106 | ->table($this->getTable()) 107 | ->where($this->getKeyName(), $this->getKey()) 108 | ->update($columns); 109 | 110 | $this->fireModelEvent($afterEvent, false); 111 | $this->fireModelEvent('approvalChanged', false); 112 | 113 | return true; 114 | } 115 | 116 | /** 117 | * @return bool|void 118 | */ 119 | public function isPending() 120 | { 121 | return $this->hasApprovalStatus(ApprovalStatuses::PENDING); 122 | } 123 | 124 | /** 125 | * @return bool|void 126 | */ 127 | public function isApproved() 128 | { 129 | return $this->hasApprovalStatus(ApprovalStatuses::APPROVED); 130 | } 131 | 132 | /** 133 | * @return bool|void 134 | */ 135 | public function isRejected() 136 | { 137 | return $this->hasApprovalStatus(ApprovalStatuses::REJECTED); 138 | } 139 | 140 | /** 141 | * @param $status 142 | * @return bool|void 143 | */ 144 | protected function hasApprovalStatus($status) 145 | { 146 | if (! $this->exists) { 147 | return; 148 | } 149 | 150 | return $this->{$this->getApprovalStatusColumn()} == $status; 151 | } 152 | } -------------------------------------------------------------------------------- /src/ApprovableObserver.php: -------------------------------------------------------------------------------- 1 | initializeApprovalStatus($model); 12 | } 13 | 14 | public function updating(Model $model) 15 | { 16 | $this->resetApprovalStatus($model); 17 | } 18 | 19 | protected function initializeApprovalStatus(Model $model) 20 | { 21 | if ($model->isDirty($model->getApprovalStatusColumn())) { 22 | return; 23 | } 24 | 25 | $this->suspend($model); 26 | } 27 | 28 | protected function resetApprovalStatus(Model $model) 29 | { 30 | $modifiedAttributes = array_keys( 31 | $model->getDirty() 32 | ); 33 | 34 | foreach ($modifiedAttributes as $name) { 35 | if ($model->isApprovalRequired($name)) { 36 | $this->suspend($model); 37 | 38 | return; 39 | } 40 | } 41 | } 42 | 43 | protected function suspend(Model $model) 44 | { 45 | $model->setAttribute( 46 | $model->getApprovalStatusColumn(), 47 | ApprovalStatuses::PENDING 48 | ); 49 | 50 | $model->setAttribute( 51 | $model->getApprovalAtColumn(), 52 | null 53 | ); 54 | } 55 | } -------------------------------------------------------------------------------- /src/ApprovalEvents.php: -------------------------------------------------------------------------------- 1 | state(function () 10 | { 11 | return $this->approvalState(ApprovalStatuses::APPROVED); 12 | }); 13 | } 14 | 15 | public function suspended() 16 | { 17 | return $this->state(function () 18 | { 19 | return $this->approvalState(ApprovalStatuses::PENDING); 20 | }); 21 | } 22 | 23 | public function rejected() 24 | { 25 | return $this->state(function () 26 | { 27 | return $this->approvalState(ApprovalStatuses::REJECTED); 28 | }); 29 | } 30 | 31 | protected function approvalState($status) 32 | { 33 | $model = new ($this->modelName()); 34 | 35 | return [ 36 | $model->getApprovalStatusColumn() => $status, 37 | $model->getApprovalAtColumn() => now(), 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ApprovalRequired.php: -------------------------------------------------------------------------------- 1 | isApprovalNotRequired($key)) { 33 | return false; 34 | } 35 | 36 | if (in_array($key, $this->approvalRequired()) 37 | || $this->approvalRequired() == ['*']) { 38 | return true; 39 | } 40 | 41 | return ! empty($this->approvalNotRequired()); 42 | } 43 | 44 | public function isApprovalNotRequired($key) 45 | { 46 | return in_array($key, $this->approvalNotRequired()); 47 | } 48 | } -------------------------------------------------------------------------------- /src/ApprovalSchemaMethods.php: -------------------------------------------------------------------------------- 1 | enum($options['status_name'] ?? 'approval_status', [ 11 | 'pending', 'approved', 'rejected' 12 | ]); 13 | 14 | $this->timestamp($options['timestamp_name'] ?? 'approval_at') 15 | ->nullable(); 16 | }; 17 | } 18 | } -------------------------------------------------------------------------------- /src/ApprovalScope.php: -------------------------------------------------------------------------------- 1 | approvalScopeDisabled) { 32 | return; 33 | } 34 | 35 | $builder->where( 36 | $model->getQualifiedApprovalStatusColumn(), 37 | ApprovalStatuses::APPROVED 38 | ); 39 | } 40 | 41 | public function extend(Builder $builder) 42 | { 43 | foreach ($this->extensions as $extension) { 44 | $this->{'add'.$extension}($builder); 45 | } 46 | } 47 | 48 | protected function addAnyApprovalStatus(Builder $builder) 49 | { 50 | $builder->macro('anyApprovalStatus', function (Builder $builder) { 51 | return $builder->withoutGlobalScope($this); 52 | }); 53 | } 54 | 55 | protected function addOnlyPending(Builder $builder) 56 | { 57 | $builder->macro('onlyPending', function (Builder $builder) { 58 | return $this->onlyWithStatus($builder, ApprovalStatuses::PENDING); 59 | }); 60 | } 61 | 62 | protected function addOnlyRejected(Builder $builder) 63 | { 64 | $builder->macro('onlyRejected', function (Builder $builder) { 65 | return $this->onlyWithStatus($builder, ApprovalStatuses::REJECTED); 66 | }); 67 | } 68 | 69 | protected function addOnlyApproved(Builder $builder) 70 | { 71 | $builder->macro('onlyApproved', function (Builder $builder) { 72 | return $this->onlyWithStatus($builder, ApprovalStatuses::APPROVED); 73 | }); 74 | } 75 | 76 | protected function onlyWithStatus(Builder $builder, $status) 77 | { 78 | $model = $builder->getModel(); 79 | 80 | $builder->anyApprovalStatus()->where( 81 | $model->getQualifiedApprovalStatusColumn(), 82 | $status 83 | ); 84 | 85 | return $builder; 86 | } 87 | 88 | protected function addApprove(Builder $builder) 89 | { 90 | $builder->macro('approve', function (Builder $builder) { 91 | return $this->updateStatus($builder, ApprovalStatuses::APPROVED); 92 | }); 93 | } 94 | 95 | protected function addReject(Builder $builder) 96 | { 97 | $builder->macro('reject', function (Builder $builder) { 98 | return $this->updateStatus($builder, ApprovalStatuses::REJECTED); 99 | }); 100 | } 101 | 102 | protected function addSuspend(Builder $builder) 103 | { 104 | $builder->macro('suspend', function (Builder $builder) { 105 | return $this->updateStatus($builder, ApprovalStatuses::PENDING); 106 | }); 107 | } 108 | 109 | protected function updateStatus(Builder $builder, $status) 110 | { 111 | $model = $builder->getModel(); 112 | 113 | $builder->anyApprovalStatus(); 114 | 115 | $model->timestamps = false; 116 | 117 | return $builder->update([ 118 | $model->getApprovalStatusColumn() => $status, 119 | $model->getApprovalAtColumn() => $model->freshTimestampString() 120 | ]); 121 | } 122 | } -------------------------------------------------------------------------------- /src/ApprovalServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(ApprovableObserver::class, function () { 13 | return new ApprovableObserver(); 14 | }); 15 | 16 | if ($this->app->runningInConsole()) { 17 | $this->commands([ 18 | UiCommand::class, 19 | ]); 20 | } 21 | } 22 | 23 | public function boot() 24 | { 25 | Blueprint::mixin(new ApprovalSchemaMethods); 26 | } 27 | } -------------------------------------------------------------------------------- /src/ApprovalStatuses.php: -------------------------------------------------------------------------------- 1 | findOrFail($key); 12 | 13 | if ($request['approval_status'] == ApprovalStatuses::APPROVED) { 14 | $result = $model->approve(); 15 | } 16 | elseif ($request['approval_status'] == ApprovalStatuses::PENDING) { 17 | $result = $model->suspend(); 18 | } 19 | elseif ($request['approval_status'] == ApprovalStatuses::REJECTED) { 20 | $result = $model->reject(); 21 | } 22 | else { 23 | abort(422, 'Invalid approval_status value'); 24 | } 25 | 26 | if(! $result) 27 | { 28 | abort(403, 'The operation failed.'); 29 | } 30 | 31 | return [ 32 | $model->getApprovalStatusColumn() => 33 | $model->{$model->getApprovalStatusColumn()}, 34 | 35 | $model->getApprovalAtColumn() => 36 | $model->{$model->getApprovalAtColumn()} 37 | ]; 38 | 39 | } 40 | 41 | protected function findOrFail($key) 42 | { 43 | return $this->model()::anyApprovalStatus()-> 44 | findOrFail($key); 45 | } 46 | 47 | /** 48 | * Returns the model class 49 | * 50 | * @return string 51 | */ 52 | abstract protected function model(); 53 | } -------------------------------------------------------------------------------- /src/UiCommand.php: -------------------------------------------------------------------------------- 1 | makeDirectory($dir, 0755, true, true); 21 | 22 | $filesystem->copyDirectory( 23 | __DIR__.'/../stubs/ui', 24 | $dir 25 | ); 26 | 27 | $this->info("The approval UI components were installed successfully."); 28 | $this->comment("Don\'t forget to register the components."); 29 | } 30 | } -------------------------------------------------------------------------------- /stubs/ui/ApprovalButtons.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /stubs/ui/ApprovalStatus.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /tests/ApprovableTest.php: -------------------------------------------------------------------------------- 1 | create(); 18 | 19 | $this->assertArrayHasKey('approval_status', $entity->getAttributes()); 20 | 21 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status); 22 | 23 | $this->assertDatabaseHas('entities', [ 24 | 'id' => $entity->id, 25 | 'approval_status' => ApprovalStatuses::PENDING 26 | ]); 27 | } 28 | 29 | /** 30 | * @test 31 | */ 32 | public function its_approval_status_default_can_be_overridden() 33 | { 34 | $entity = Entity::factory()->create([ 35 | 'approval_status' => ApprovalStatuses::APPROVED 36 | ]); 37 | 38 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status); 39 | 40 | $this->assertDatabaseHas('entities', [ 41 | 'id' => $entity->id, 42 | 'approval_status' => ApprovalStatuses::APPROVED 43 | ]); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function it_has_default_for_approval_status_column() 50 | { 51 | $entity = new Entity(); 52 | 53 | $this->assertEquals('approval_status', $entity->getApprovalStatusColumn()); 54 | } 55 | 56 | /** 57 | * @test 58 | */ 59 | public function it_can_detect_custom_approval_status_column() 60 | { 61 | $entity = new EntityWithCustomColumns(); 62 | 63 | $this->assertEquals( 64 | EntityWithCustomColumns::APPROVAL_STATUS, 65 | $entity->getApprovalStatusColumn() 66 | ); 67 | } 68 | 69 | /** 70 | * @test 71 | */ 72 | public function it_has_default_for_approval_at_column() 73 | { 74 | $entity = new Entity(); 75 | 76 | $this->assertEquals('approval_at', $entity->getApprovalAtColumn()); 77 | } 78 | 79 | /** 80 | * @test 81 | */ 82 | public function it_can_detect_custom_approval_at_column() 83 | { 84 | $entity = new EntityWithCustomColumns(); 85 | 86 | $this->assertEquals( 87 | EntityWithCustomColumns::APPROVAL_AT, 88 | $entity->getApprovalAtColumn() 89 | ); 90 | } 91 | 92 | /** 93 | * @test 94 | */ 95 | public function it_can_approve_the_entity() 96 | { 97 | $entity = Entity::factory()->create(); 98 | 99 | $entity->approve(); 100 | 101 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status); 102 | 103 | $this->assertDatabaseHas('entities', [ 104 | 'id' => $entity->id, 105 | 'approval_status' => ApprovalStatuses::APPROVED 106 | ]); 107 | } 108 | 109 | /** 110 | * @test 111 | */ 112 | public function it_can_reject_the_entity() 113 | { 114 | $entity = Entity::factory()->create(); 115 | 116 | $entity->reject(); 117 | 118 | $this->assertEquals(ApprovalStatuses::REJECTED, $entity->approval_status); 119 | 120 | $this->assertDatabaseHas('entities', [ 121 | 'id' => $entity->id, 122 | 'approval_status' => ApprovalStatuses::REJECTED 123 | ]); 124 | } 125 | 126 | /** 127 | * @test 128 | */ 129 | public function it_can_suspend_the_entity() 130 | { 131 | $entity = Entity::factory()->create([ 132 | 'approval_status' => ApprovalStatuses::APPROVED 133 | ]); 134 | 135 | $entity->suspend(); 136 | 137 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status); 138 | 139 | $this->assertDatabaseHas('entities', [ 140 | 'id' => $entity->id, 141 | 'approval_status' => ApprovalStatuses::PENDING 142 | ]); 143 | } 144 | 145 | /** 146 | * @test 147 | */ 148 | public function it_refreshes_the_entity_approval_at_on_status_update() 149 | { 150 | $entity = Entity::factory()->create(); 151 | 152 | foreach ($this->approvalActions as $action) { 153 | $time = (new Entity())->freshTimestamp(); 154 | 155 | $entity->{$action}(); 156 | 157 | $this->assertEquals($time->timestamp, $entity->approval_at->timestamp); 158 | 159 | $this->assertDatabaseHas('entities', [ 160 | 'id' => $entity->id, 161 | 'approval_at' => $entity->fromDateTime($time) 162 | ]); 163 | 164 | $entity->newQuery()->where('id', $entity->id)->update([ 165 | 'approval_at' => $time->subHour() 166 | ]); 167 | } 168 | } 169 | 170 | /** @test */ 171 | public function it_does_not_refresh_the_entity_updated_at() 172 | { 173 | $entity = Entity::factory()->create([ 174 | 'updated_at' => $time = (new Entity())->freshTimestamp()->subHour(1) 175 | ]); 176 | 177 | foreach ($this->approvalActions as $action) { 178 | $entity->$action(); 179 | 180 | $this->assertEquals($time->timestamp, $entity->updated_at->timestamp); 181 | 182 | $this->assertDatabaseHas('entities', [ 183 | 'id' => $entity->id, 184 | 'updated_at' => $entity->fromDateTime($time) 185 | ]); 186 | } 187 | } 188 | 189 | 190 | /** 191 | * @test 192 | */ 193 | public function it_returns_true_when_updates_status() 194 | { 195 | $entity = Entity::factory()->create(); 196 | 197 | foreach ($this->approvalActions as $action) { 198 | $this->assertTrue($entity->{$action}()); 199 | } 200 | } 201 | 202 | /** 203 | * @test 204 | */ 205 | public function it_refuses_to_update_status_when_not_exists() 206 | { 207 | $entity = Entity::factory()->make(); 208 | 209 | foreach ($this->approvalActions as $action) { 210 | $this->assertNull($entity->{$action}()); 211 | 212 | $this->assertNull($entity->approval_at); 213 | } 214 | } 215 | 216 | /** 217 | * @test 218 | **/ 219 | public function it_rejects_the_duplicate_approvals() 220 | { 221 | $statuses = [ 222 | ApprovalStatuses::APPROVED, 223 | ApprovalStatuses::PENDING, 224 | ApprovalStatuses::REJECTED 225 | ]; 226 | 227 | $actions = [ 228 | 'approve', 229 | 'suspend', 230 | 'reject' 231 | ]; 232 | 233 | foreach(range(0, 2) as $i) 234 | { 235 | $entity = Entity::factory()->create([ 236 | 'approval_status' => $statuses[$i], 237 | 'approval_at' => now()->subHour(1), 238 | ]); 239 | 240 | $return = $entity->{$actions[$i]}(); 241 | 242 | $this->assertNotEquals(now()->timestamp, $entity->approval_at->timestamp); 243 | } 244 | $this->assertFalse($return); 245 | 246 | } 247 | 248 | /** 249 | * @test 250 | */ 251 | public function it_can_check_if_it_is_pending() 252 | { 253 | $pendingEntity = Entity::factory()->create(); 254 | $approvedEntity = Entity::factory()->create([ 255 | 'approval_status' => ApprovalStatuses::APPROVED 256 | ]); 257 | $rejectedEntity = Entity::factory()->create([ 258 | 'approval_status' => ApprovalStatuses::REJECTED 259 | ]); 260 | 261 | $this->assertTrue($pendingEntity->isPending()); 262 | $this->assertFalse($approvedEntity->isPending()); 263 | $this->assertFalse($rejectedEntity->isPending()); 264 | } 265 | 266 | /** 267 | * @test 268 | */ 269 | public function it_can_check_if_it_is_approved() 270 | { 271 | $pendingEntity = Entity::factory()->create(); 272 | $approvedEntity = Entity::factory()->create([ 273 | 'approval_status' => ApprovalStatuses::APPROVED 274 | ]); 275 | $rejectedEntity = Entity::factory()->create([ 276 | 'approval_status' => ApprovalStatuses::REJECTED 277 | ]); 278 | 279 | $this->assertFalse($pendingEntity->isApproved()); 280 | $this->assertTrue($approvedEntity->isApproved()); 281 | $this->assertFalse($rejectedEntity->isApproved()); 282 | } 283 | 284 | /** 285 | * @test 286 | */ 287 | public function it_can_check_if_it_is_rejected() 288 | { 289 | $pendingEntity = Entity::factory()->create(); 290 | $approvedEntity = Entity::factory()->create([ 291 | 'approval_status' => ApprovalStatuses::APPROVED 292 | ]); 293 | $rejectedEntity = Entity::factory()->create([ 294 | 'approval_status' => ApprovalStatuses::REJECTED 295 | ]); 296 | 297 | $this->assertFalse($pendingEntity->isRejected()); 298 | $this->assertFalse($approvedEntity->isRejected()); 299 | $this->assertTrue($rejectedEntity->isRejected()); 300 | } 301 | 302 | /** 303 | * @test 304 | */ 305 | public function it_refuses_to_check_status_when_not_exists() 306 | { 307 | $entity = Entity::factory()->make(); 308 | 309 | foreach ($this->approvalChecks as $check) { 310 | $this->assertNull($entity->{$check}()); 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /tests/ApprovalEventsTest.php: -------------------------------------------------------------------------------- 1 | create(); 47 | 48 | for ($i = 0; $i < count($this->actions); $i++) { 49 | $action = $this->actions[$i]; 50 | $event = $this->beforeEvents[$i]; 51 | $listener = $event.'Listener'; 52 | $mock = $this->getMockBuilder('stdClass') 53 | ->addMethods([$listener]) 54 | ->getMock(); 55 | $mock->expects($this->once())->method($listener); 56 | 57 | Entity::$event([$mock, $listener]); 58 | 59 | $entity->$action(); 60 | } 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | public function it_allows_listeners_of_before_action_events_halt_the_action_execution() 67 | { 68 | for ($i = 0; $i < count($this->actions); $i++) { 69 | $action = $this->actions[$i]; 70 | $beforeEvent = $this->beforeEvents[$i]; 71 | $beforeListener = $beforeEvent.'Listener'; 72 | $afterEvent = $this->afterEvents[$i]; 73 | $afterEventListener = $afterEvent.'Listener'; 74 | $mock = $this->getMockBuilder('stdClass') 75 | ->addMethods([$beforeListener, $afterEventListener]) 76 | ->getMock(); 77 | $mock->method($beforeListener)->will($this->returnValue(false)); 78 | $mock->expects($this->never())->method($afterEventListener); 79 | Entity::$beforeEvent([$mock, $beforeListener]); 80 | Entity::$afterEvent([$mock, $afterEventListener]); 81 | 82 | $entity = Entity::factory()->create([ 83 | 'approval_status' => Arr::random(Arr::except($this->statuses, [$i])) 84 | ]); 85 | 86 | $this->assertFalse($entity->$action()); 87 | 88 | $this->assertFalse($entity->{$this->checks[$i]}()); 89 | 90 | $this->assertDatabaseMissing('entities', [ 91 | 'id' => $entity->id, 92 | 'approval_status' => $this->statuses[$i] 93 | ]); 94 | } 95 | } 96 | 97 | /** 98 | * @test 99 | */ 100 | public function it_dispatches_events_after_approval_actions() 101 | { 102 | $entity = Entity::factory()->create(); 103 | 104 | for ($i = 0; $i < count($this->actions); $i++) { 105 | $action = $this->actions[$i]; 106 | $event = $this->afterEvents[$i]; 107 | $listener = $event.'Listener'; 108 | $mock = $this->getMockBuilder('stdClass') 109 | ->addMethods([$listener, 'approvalChangedListener']) 110 | ->getMock(); 111 | $mock->expects($this->once())->method($listener); 112 | $mock->expects($this->once())->method('approvalChangedListener'); 113 | 114 | Entity::$event([$mock, $listener]); 115 | Entity::getEventDispatcher()->forget("eloquent.approvalChanged: ".Entity::class); 116 | Entity::approvalChanged([$mock, 'approvalChangedListener']); 117 | 118 | $entity->$action(); 119 | } 120 | } 121 | 122 | /** 123 | * @test 124 | */ 125 | public function it_will_not_dispatch_the_events_on_the_duplicate_approvals() 126 | { 127 | for($i = 0; $i < count($this->statuses); $i++) 128 | { 129 | $entity = Entity::factory()->create([ 130 | 'approval_status' => $this->statuses[$i], 131 | 'approval_at' => (new Entity())->freshTimestamp() 132 | ]); 133 | 134 | $beforeEvent = $this->beforeEvents[$i]; 135 | $afterEvent = $this->afterEvents[$i]; 136 | 137 | $mock = $this->getMockBuilder('stdClass') 138 | ->addMethods([ 139 | 'beforeListener', 140 | 'afterListener', 141 | 'approvalChangedListener' 142 | ])->getMock(); 143 | 144 | $mock->expects($this->never())->method('beforeListener'); 145 | $mock->expects($this->never())->method('afterListener'); 146 | $mock->expects($this->never())->method('approvalChangedListener'); 147 | 148 | Entity::$beforeEvent([$mock, 'beforeListener']); 149 | Entity::$afterEvent([$mock, 'afterListener']); 150 | Entity::approvalChanged([$mock, 'approvalChangedListener']); 151 | 152 | $entity->{$this->actions[$i]}(); 153 | } 154 | } 155 | 156 | /** 157 | * @test 158 | */ 159 | public function it_supports_observers() 160 | { 161 | $observerMock = $this->getMockBuilder('stdClass') 162 | ->addMethods($events = array_merge($this->beforeEvents, $this->afterEvents)) 163 | ->getMock(); 164 | 165 | foreach ($events as $event) { 166 | $observerMock->expects($this->once())->method($event); 167 | } 168 | 169 | app()->singleton(get_class($observerMock), function () use ($observerMock) { 170 | return $observerMock; 171 | }); 172 | 173 | Entity::observe($observerMock); 174 | 175 | $entity = Entity::factory()->create(); 176 | 177 | foreach ($this->actions as $action) { 178 | $entity->$action(); 179 | } 180 | } 181 | } -------------------------------------------------------------------------------- /tests/ApprovalRequiredtTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(['*'], $entity->approvalRequired()); 21 | $this->assertEquals([], $entity->approvalNotRequired()); 22 | } 23 | 24 | /** 25 | * @test 26 | */ 27 | public function it_works_when_all_are_required() 28 | { 29 | $entity = new Class extends Model { 30 | use Approvable; 31 | }; 32 | 33 | $this->assertTrue($entity->isApprovalRequired('attr_1')); 34 | $this->assertTrue($entity->isApprovalRequired('attr_2')); 35 | $this->assertTrue($entity->isApprovalRequired('attr_3')); 36 | } 37 | 38 | /** 39 | * @test 40 | */ 41 | public function it_works_when_all_are_required_except_some_not_required() 42 | { 43 | $entity = new Class extends Model { 44 | use Approvable; 45 | 46 | public function approvalNotRequired() 47 | { 48 | return ['attr_3']; 49 | } 50 | }; 51 | 52 | $this->assertTrue($entity->isApprovalRequired('attr_1')); 53 | $this->assertTrue($entity->isApprovalRequired('attr_2')); 54 | $this->assertFalse($entity->isApprovalRequired('attr_3')); 55 | } 56 | 57 | /** 58 | * @test 59 | */ 60 | public function it_works_when_some_are_required_and_the_rest_not_required() 61 | { 62 | $entity = new Class extends Model { 63 | use Approvable; 64 | 65 | public function approvalRequired() 66 | { 67 | return ['attr_1']; 68 | } 69 | }; 70 | 71 | $this->assertTrue($entity->isApprovalRequired('attr_1')); 72 | $this->assertFalse($entity->isApprovalRequired('attr_2')); 73 | $this->assertFalse($entity->isApprovalRequired('attr_3')); 74 | } 75 | 76 | /** 77 | * @test 78 | */ 79 | public function it_works_when_non_is_required() 80 | { 81 | $entity = new Class extends Model 82 | { 83 | use Approvable; 84 | 85 | public function approvalRequired() 86 | { 87 | return []; 88 | } 89 | }; 90 | 91 | $this->assertFalse($entity->isApprovalRequired('attr_1')); 92 | $this->assertFalse($entity->isApprovalRequired('attr_2')); 93 | $this->assertFalse($entity->isApprovalRequired('attr_3')); 94 | } 95 | 96 | /** 97 | * @test 98 | */ 99 | public function it_works_when_some_are_required_and_some_not_required() 100 | { 101 | $entity = new Class extends Model 102 | { 103 | use Approvable; 104 | 105 | public function approvalRequired() 106 | { 107 | return ['attr_1']; 108 | } 109 | 110 | public function approvalNotRequired() 111 | { 112 | return ['attr_2']; 113 | } 114 | }; 115 | 116 | $this->assertTrue($entity->isApprovalRequired('attr_1')); 117 | $this->assertFalse($entity->isApprovalRequired('attr_2')); 118 | $this->assertTrue($entity->isApprovalRequired('attr_3')); 119 | } 120 | 121 | /** 122 | * @test 123 | */ 124 | public function it_works_when_some_are_not_required_and_the_rest_are_required() 125 | { 126 | $entity = new Class extends Model 127 | { 128 | use Approvable; 129 | 130 | public function approvalRequired() 131 | { 132 | return []; 133 | } 134 | 135 | public function approvalNotRequired() 136 | { 137 | return ['attr_1']; 138 | } 139 | }; 140 | 141 | $this->assertFalse($entity->isApprovalRequired('attr_1')); 142 | $this->assertTrue($entity->isApprovalRequired('attr_2')); 143 | $this->assertTrue($entity->isApprovalRequired('attr_3')); 144 | } 145 | } -------------------------------------------------------------------------------- /tests/ApprovalScopeTest.php: -------------------------------------------------------------------------------- 1 | createOneEntityFromEachStatus(); 18 | 19 | $entities = Entity::all(); 20 | 21 | $this->assertNotEmpty($entities); 22 | 23 | foreach ($entities as $entity) { 24 | $this->assertEquals( 25 | ApprovalStatuses::APPROVED, 26 | $entity->approval_status 27 | ); 28 | } 29 | } 30 | 31 | /** 32 | * @test 33 | */ 34 | public function it_can_retrieve_all() 35 | { 36 | $this->createOneEntityFromEachStatus(); 37 | 38 | $entities = Entity::anyApprovalStatus()->get(); 39 | 40 | $totalCount = Entity::withoutGlobalScope(new ApprovalScope())->count(); 41 | 42 | $this->assertCount($totalCount, $entities); 43 | } 44 | 45 | /** @test */ 46 | public function it_can_be_disabled_on_the_model() 47 | { 48 | $this->createOneEntityFromEachStatus(); 49 | 50 | $totalCount = Entity::withoutGlobalScope(new ApprovalScope())->count(); 51 | 52 | $entityWithApprovalScopeDisabled = new class extends Entity { 53 | protected $table = 'entities'; 54 | 55 | public $approvalScopeDisabled = true; 56 | }; 57 | 58 | $entities = $entityWithApprovalScopeDisabled->newQuery()->get(); 59 | 60 | $this->assertEquals($totalCount, count($entities)); 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | public function it_can_retrieve_only_pending() 67 | { 68 | $this->createOneEntityFromEachStatus(); 69 | 70 | $entities = Entity::onlyPending()->get(); 71 | 72 | $this->assertNotEmpty($entities); 73 | 74 | foreach ($entities as $entity) { 75 | $this->assertEquals( 76 | ApprovalStatuses::PENDING, 77 | $entity->approval_status 78 | ); 79 | } 80 | } 81 | 82 | /** 83 | * @test 84 | */ 85 | public function it_can_retrieve_only_rejected() 86 | { 87 | $this->createOneEntityFromEachStatus(); 88 | 89 | $entities = Entity::onlyRejected()->get(); 90 | 91 | $this->assertNotEmpty($entities); 92 | 93 | foreach ($entities as $entity) { 94 | $this->assertEquals( 95 | ApprovalStatuses::REJECTED, 96 | $entity->approval_status 97 | ); 98 | } 99 | } 100 | 101 | /** 102 | * @test 103 | */ 104 | public function it_can_retrieve_only_approved() 105 | { 106 | $this->createOneEntityFromEachStatus(); 107 | 108 | $entities = Entity::onlyApproved()->get(); 109 | 110 | $this->assertNotEmpty($entities); 111 | 112 | foreach ($entities as $entity) { 113 | $this->assertEquals( 114 | ApprovalStatuses::APPROVED, 115 | $entity->approval_status 116 | ); 117 | } 118 | } 119 | 120 | /** 121 | * @test 122 | */ 123 | public function it_can_approve_entities() 124 | { 125 | $this->createOneEntityFromEachStatus(); 126 | 127 | Entity::query()->approve(); 128 | 129 | $entities = Entity::withoutGlobalScope(new ApprovalScope())->get(); 130 | 131 | foreach ($entities as $entity) { 132 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status); 133 | } 134 | } 135 | 136 | /** 137 | * @test 138 | */ 139 | public function it_can_reject_entities() 140 | { 141 | $this->createOneEntityFromEachStatus(); 142 | 143 | Entity::query()->reject(); 144 | 145 | $entities = Entity::withoutGlobalScope(new ApprovalScope())->get(); 146 | 147 | foreach ($entities as $entity) { 148 | $this->assertEquals(ApprovalStatuses::REJECTED, $entity->approval_status); 149 | } 150 | } 151 | 152 | /** 153 | * @test 154 | */ 155 | public function it_can_suspend_entities() 156 | { 157 | $this->createOneEntityFromEachStatus(); 158 | 159 | Entity::query()->suspend(); 160 | 161 | $entities = Entity::withoutGlobalScope(new ApprovalScope())->get(); 162 | 163 | foreach ($entities as $entity) { 164 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status); 165 | } 166 | } 167 | 168 | /** 169 | * @test 170 | */ 171 | public function it_refreshes_approval_at_on_status_update() 172 | { 173 | foreach ($this->approvalActions as $action) { 174 | $entityId = Entity::factory()->create()->id; 175 | 176 | $timestampString = (new Entity())->freshTimestampString(); 177 | 178 | Entity::whereId($entityId)->{$action}(); 179 | 180 | $this->assertDatabaseHas('entities', [ 181 | 'id' => $entityId, 182 | 'approval_at' => $timestampString 183 | ]); 184 | } 185 | } 186 | 187 | /** 188 | * @test 189 | */ 190 | public function it_does_not_refresh_updated_at_on_status_update() 191 | { 192 | 193 | foreach ($this->approvalActions as $action) { 194 | $timestampString = 195 | (New Entity())->fromDateTime(Carbon::now()->subHour()); 196 | 197 | $entityId = Entity::factory()->create([ 198 | 'updated_at' => $timestampString 199 | ])->id; 200 | 201 | Entity::whereId($entityId)->{$action}(); 202 | 203 | $this->assertDatabaseHas('entities', [ 204 | 'id' => $entityId, 205 | 'updated_at' => $timestampString 206 | ]); 207 | } 208 | } 209 | 210 | /** 211 | * @test 212 | */ 213 | public function it_returns_number_of_updated_entities_on_status_update() 214 | { 215 | Entity::factory(3)->create(); 216 | 217 | foreach ($this->approvalActions as $action) { 218 | $this->assertEquals(1, Entity::whereId(1)->{$action}()); 219 | $this->assertEquals(3, Entity::query()->{$action}()); 220 | $this->assertEquals(0, Entity::whereId(0)->{$action}()); 221 | } 222 | } 223 | 224 | protected function createOneEntityFromEachStatus() 225 | { 226 | Entity::factory()->suspended()->create(); 227 | 228 | Entity::factory()->approved()->create(); 229 | 230 | Entity::factory()->rejected()->create(); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /tests/HandlesApprovalTest.php: -------------------------------------------------------------------------------- 1 | create(); 25 | 26 | $key = $entity->id; 27 | 28 | foreach($this->approvalStatuses as $approval_status) { 29 | $request = Request::create("/admin/enitiy/$key/approval", 'POST', [ 30 | 'approval_status' => $approval_status 31 | ]); 32 | 33 | $response = $this->handleRequestUsing($request, function ($request) use($key) { 34 | return $this->performApproval($key, $request); 35 | }); 36 | 37 | $this->assertDatabaseHas('entities', [ 38 | 'id' => $key, 39 | 'approval_status' => $approval_status, 40 | 'approval_at' => now() 41 | ]); 42 | 43 | $response->assertStatus(200); 44 | } 45 | } 46 | 47 | protected function handleRequestUsing(Request $request, callable $callback) 48 | { 49 | try { 50 | $response = response( 51 | (new Pipeline($this->app)) 52 | ->send($request) 53 | ->through([]) 54 | ->then($callback) 55 | ); 56 | } catch (\Throwable $e) { 57 | $this->app[ExceptionHandler::class] 58 | ->report($e); 59 | 60 | $response = $this->app[ExceptionHandler::class] 61 | ->render($request, $e); 62 | } 63 | 64 | return new TestResponse($response); 65 | } 66 | } -------------------------------------------------------------------------------- /tests/Models/Entity.php: -------------------------------------------------------------------------------- 1 | approved()->raw(); 24 | 25 | with($entity = new class ($attributes) extends Entity { 26 | protected $table = 'entities'; 27 | 28 | public function approvalRequired() 29 | { 30 | return ['*']; 31 | } 32 | 33 | public function approvalNotRequired() 34 | { 35 | return []; 36 | } 37 | })->save(); 38 | 39 | $entity->update([ 40 | 'attr_1' => $this->faker->word 41 | ]); 42 | 43 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status); 44 | $this->assertNull($entity->approval_at); 45 | 46 | $this->assertDatabaseHas('entities', [ 47 | 'id' => $entity->id, 48 | 'approval_status' => ApprovalStatuses::PENDING, 49 | 'approval_at' => null, 50 | ]); 51 | } 52 | 53 | /** 54 | * @test 55 | */ 56 | public function it_works_when_some_attributes_do_not_require_approval_on_update() 57 | { 58 | $attributes = Entity::factory()->approved()->raw(); 59 | 60 | // it isn't suspended on update of the attributes that don't require approval 61 | with($entity = new class ($attributes) extends Entity { 62 | protected $table = 'entities'; 63 | 64 | public function approvalRequired() 65 | { 66 | return ['*']; 67 | } 68 | 69 | public function approvalNotRequired() 70 | { 71 | return ['attr_1',]; 72 | } 73 | })->save(); 74 | 75 | $entity->update([ 76 | 'attr_1' => $this->faker->word, 77 | ]); 78 | 79 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status); 80 | $this->assertNotNull($entity->approval_at); 81 | 82 | $this->assertDatabaseHas('entities', [ 83 | 'id' => $entity->id, 84 | 'approval_status' => ApprovalStatuses::APPROVED, 85 | 'approval_at' => $attributes['approval_at'] 86 | ]); 87 | 88 | // it is suspended on update of the attributes that require approval 89 | with($entity = new class ($attributes) extends Entity { 90 | protected $table = 'entities'; 91 | 92 | public function approvalRequired() 93 | { 94 | return ['*']; 95 | } 96 | 97 | public function approvalNotRequired() 98 | { 99 | return ['attr_1',]; 100 | } 101 | })->save(); 102 | 103 | $entity->update([ 104 | 'attr_2' => $this->faker->word, 105 | ]); 106 | 107 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status); 108 | $this->assertNull($entity->approval_at); 109 | 110 | $this->assertDatabaseHas('entities', [ 111 | 'id' => $entity->id, 112 | 'approval_status' => ApprovalStatuses::PENDING, 113 | 'approval_at' => null, 114 | ]); 115 | } 116 | 117 | /** 118 | * @test 119 | */ 120 | public function it_works_when_some_attributes_require_approval_on_update() 121 | { 122 | $attributes = Entity::factory()->approved()->raw(); 123 | 124 | // it isn't suspended on update of the attributes that don't require approval 125 | with($entity = new class ($attributes) extends Entity { 126 | protected $table = 'entities'; 127 | 128 | public function approvalRequired() 129 | { 130 | return ['attr_1',]; 131 | } 132 | 133 | public function approvalNotRequired() 134 | { 135 | return []; 136 | } 137 | })->save(); 138 | 139 | $entity->update([ 140 | 'attr_2' => $this->faker->word, 141 | 'attr_3' => $this->faker->word, 142 | ]); 143 | 144 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status); 145 | $this->assertNotNull($entity->approval_at); 146 | 147 | $this->assertDatabaseHas('entities', [ 148 | 'id' => $entity->id, 149 | 'approval_status' => ApprovalStatuses::APPROVED, 150 | 'approval_at' => $attributes['approval_at'] 151 | ]); 152 | 153 | // it is suspended on update of the attributes that require approval 154 | with($entity = new class ($attributes) extends Entity { 155 | protected $table = 'entities'; 156 | 157 | public function approvalRequired() 158 | { 159 | return ['attr_1',]; 160 | } 161 | 162 | public function approvalNotRequired() 163 | { 164 | return []; 165 | } 166 | })->save(); 167 | 168 | $entity->update([ 169 | 'attr_1' => $this->faker->word, 170 | ]); 171 | 172 | $this->assertEquals(ApprovalStatuses::PENDING, $entity->approval_status); 173 | $this->assertNull($entity->approval_at); 174 | 175 | $this->assertDatabaseHas('entities', [ 176 | 'id' => $entity->id, 177 | 'approval_status' => ApprovalStatuses::PENDING, 178 | 'approval_at' => null, 179 | ]); 180 | } 181 | 182 | /** 183 | * @test 184 | */ 185 | public function it_works_when_no_attribute_requires_approval_on_update() 186 | { 187 | $attributes = Entity::factory()->approved()->raw(); 188 | 189 | with($entity = new class ($attributes) extends Entity { 190 | protected $table = 'entities'; 191 | 192 | public function approvalRequired() 193 | { 194 | return []; 195 | } 196 | 197 | public function approvalNotRequired() 198 | { 199 | return []; 200 | } 201 | })->save(); 202 | 203 | $entity->update([ 204 | 'attr_1' => $this->faker->word, 205 | 'attr_2' => $this->faker->word, 206 | 'attr_3' => $this->faker->word, 207 | ]); 208 | 209 | $this->assertEquals(ApprovalStatuses::APPROVED, $entity->approval_status); 210 | $this->assertNotNull($entity->approval_status); 211 | 212 | $this->assertDatabaseHas('entities', [ 213 | 'id' => $entity->id, 214 | 'approval_status' => ApprovalStatuses::APPROVED, 215 | 'approval_at' => $attributes['approval_at'] 216 | ]); 217 | } 218 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/database/migrations'); 26 | 27 | $this->withoutExceptionHandling(); 28 | } 29 | 30 | protected function getPackageProviders($app) 31 | { 32 | return [ 33 | ApprovalServiceProvider::class, 34 | ]; 35 | } 36 | } -------------------------------------------------------------------------------- /tests/database/factories/EntityFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->word, 19 | 'attr_2' => $this->faker->word, 20 | 'attr_3' => $this->faker->word, 21 | ]; 22 | } 23 | } -------------------------------------------------------------------------------- /tests/database/migrations/2017_11_15_085536_create_entities_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | 19 | $table->string('attr_1'); 20 | $table->string('attr_2'); 21 | $table->string('attr_3'); 22 | 23 | $table->approvals(); 24 | 25 | $table->timestamps(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | * 32 | * @return void 33 | */ 34 | public function down() 35 | { 36 | Schema::dropIfExists('entities'); 37 | } 38 | } 39 | --------------------------------------------------------------------------------