├── .gitignore ├── LICENSE.md ├── README.md ├── codeception.yml ├── composer.json ├── src ├── assets │ ├── SortableColumnAsset.php │ └── src │ │ ├── css │ │ └── sortable-column.css │ │ └── js │ │ └── sortable-column.js ├── behaviors │ ├── BaseSortableBehavior.php │ └── numerical │ │ ├── BaseNumericalSortableBehavior.php │ │ ├── ContinuousNumericalSortableBehavior.php │ │ └── IntervalNumericalSortableBehavior.php ├── controllers │ └── SortController.php ├── grid │ └── SortableColumn.php ├── messages │ ├── el │ │ └── sortable.php │ └── ru │ │ └── sortable.php └── widgets │ └── Sortable.php └── tests ├── _bootstrap.php ├── _data ├── Category.php ├── ContinuousQuestion.php ├── IntervalQuestion.php └── dump.sql ├── _support └── UnitHelper.php ├── unit.suite.yml └── unit ├── ContinuousTest.php ├── IntervalTest.php ├── _bootstrap.php └── _config.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | 4 | /tests/_output 5 | /tests/unit/UnitTester.php 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Yii 2 Sortable extension for Yii 2 framework is free software. 2 | It is released under the terms of the following BSD License. 3 | 4 | Copyright © 2015, Alexey Rogachev (https://github.com/arogachev) 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name of test nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yii 2 Sortable 2 | 3 | This extension allows to manage order of ActiveRecord models via different behaviors. 4 | Choose one to fit your needs. 5 | 6 | [![Latest Stable Version](https://poser.pugx.org/arogachev/yii2-sortable/v/stable)](https://packagist.org/packages/arogachev/yii2-sortable) 7 | [![Total Downloads](https://poser.pugx.org/arogachev/yii2-sortable/downloads)](https://packagist.org/packages/arogachev/yii2-sortable) 8 | [![Latest Unstable Version](https://poser.pugx.org/arogachev/yii2-sortable/v/unstable)](https://packagist.org/packages/arogachev/yii2-sortable) 9 | [![License](https://poser.pugx.org/arogachev/yii2-sortable/license)](https://packagist.org/packages/arogachev/yii2-sortable) 10 | 11 | - [Installation](#installation) 12 | - [Features](#features) 13 | - [Behaviors types](#behaviors-types) 14 | - [Preparing table structure](#preparing-table-structure) 15 | - [Attaching behavior](#attaching-behavior) 16 | - [Configuring behavior](#configuring-behavior) 17 | - [Changing order of models inside sortable scope](#changing-order-of-models-inside-sortable-scope) 18 | - [GUI for changing order](#gui-for-changing-order) 19 | - [Custom GUI for changing order](#custom-gui-for-changing-order) 20 | 21 | ## Installation 22 | 23 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 24 | 25 | Either run 26 | 27 | ``` 28 | php composer.phar require --prefer-dist arogachev/yii2-sortable 29 | ``` 30 | 31 | or add 32 | 33 | ``` 34 | "arogachev/yii2-sortable": "*" 35 | ``` 36 | 37 | to the require section of your `composer.json` file. 38 | 39 | ## Features 40 | 41 | - Several implemented algorithms. Choose one to fit your needs. 42 | - Setting of sortable scope. In this case the order of models in each scope is managed separately. 43 | - Additional setting of sortable condition. For example, if the model can be marked as active or deleted, 44 | you can additionally specify that condition and it will be considered when changing these attributes values. 45 | - Order auto adjustment when adding new model, removing out of sortable scope, 46 | moving between the sortable scopes. 47 | - Changing order of models inside sortable scope. 48 | - GUI for managing order of models in `GridView` (`SortableColumn`). 49 | - Sort controller for simplifying writing of own GUI 50 | 51 | ## Behaviors types 52 | 53 | There are several behaviors to choose from: 54 | 55 | - `ContinuousNumericalSortableBehavior` 56 | - `IntervalNumericalSortableBehavior` 57 | - `LinkedListSortableBehavior` (currently not implemented) 58 | 59 | The first two are numerical behaviors and they have one thing in common - they store position of each model as number. 60 | 61 | `ContinuousNumericalSortableBehavior`: 62 | 63 | Stored number is equal to exact position. 64 | 65 | **Advantages:** 66 | 67 | - You can get current position without additional queries 68 | 69 | **Disadvantages:** 70 | 71 | - Amount of `UPDATE` queries can be large depending on amount of sortable models and situation. 72 | It relates to adjustment order. For example no extra queries will be performed in case of switching models 73 | with 3 and 4 position (only 2 `UPDATE` queries). But if you have 1000 models and you move the last model 74 | to the very beginning there will be 1000 `UPDATE` queries (so it depends on interval length). 75 | 76 | `IntervalNumericalSortableBehavior`: 77 | 78 | The numbers are stored with certain intervals (initially with equal size). 79 | You can see the basic description of the used algorithm [here](http://stackoverflow.com/questions/6804166/what-is-the-most-efficient-way-to-store-a-sort-order-on-a-group-of-records-in-a/6804302#6804302). 80 | 81 | **Advantages:** 82 | 83 | - For adding or deletion there is no need to adjust order of other models. And for changing order for most of the times 84 | only few `SELECT` and one `UPDATE` query will be executed. 85 | The full adjustment of order of all models inside of sortable scope is only required in case of conflict. 86 | The conflicts don't happen often if you set interval size big enough 87 | and don't move models to the same position over and over again. 88 | 89 | **Disadvantages:** 90 | 91 | - For getting positions of models extra query is used. 92 | 93 | ### Preparing table structure 94 | 95 | In case of using numerical behaviors add this to migration: 96 | 97 | ```php 98 | $this->addColumn('table_name', 'sort', Schema::TYPE_INTEGER . ' NOT NULL'); 99 | ``` 100 | 101 | ## Attaching behavior 102 | 103 | Add this to your model for minimal setup: 104 | 105 | ```php 106 | use arogachev\sortable\behaviors\numerical\ContinuousNumericalSortableBehavior; 107 | ``` 108 | 109 | ```php 110 | /** 111 | * @inheritdoc 112 | */ 113 | public function behaviors() 114 | { 115 | return [ 116 | [ 117 | 'class' => ContinuousNumericalSortableBehavior::className(), 118 | ], 119 | ]; 120 | } 121 | ``` 122 | 123 | ### Configuring behavior 124 | 125 | **Common properties for all behaviors:** 126 | 127 | `scope` - sortable scope. Specify it if you want to separate models by condition 128 | and manage order independently in each one. It expects closure returning `ActiveQuery`, but `where` part must be 129 | specified as array only. Example: 130 | 131 | ```php 132 | function () { 133 | return Question::find()->where(['test_id' => $this->test_id]); 134 | } 135 | ``` 136 | 137 | You can use `$model` parameter to generate model related queries: 138 | 139 | ```php 140 | function ($model) { 141 | return $model->getNeighbors(); 142 | } 143 | ``` 144 | 145 | where `getNeighbors()` implementation can be like this: 146 | 147 | ```php 148 | /** 149 | * @return \yii\db\ActiveQuery 150 | */ 151 | public function getNeighbors() 152 | { 153 | return static::find()->where(['parent_id' => $this->parent_id]); 154 | } 155 | ``` 156 | 157 | If this property is not set, all models considered as one sortable scope. 158 | 159 | `sortableCondition` - additional property to filter sortable models. You should specify it as conditional array: 160 | 161 | ```php 162 | [ 163 | 'is_active' => 1, 164 | 'is_deleted' => 0, 165 | ], 166 | ``` 167 | 168 | `prependAdded` - insert added sortable model to the beginning of sortable scope. Defaults to `false` which means 169 | inserting to the end. 170 | 171 | `access` - closure for checking access to sort for current user. Example: 172 | 173 | ```php 174 | function () { 175 | return Yii::$app->user->can('questions.sort'); 176 | } 177 | ``` 178 | 179 | **Numerical behaviors properties:** 180 | 181 | `sortAttribute` - name of the sort attribute column. Defaults to `sort`. 182 | 183 | **`IntervalNumericalSortableBehavior` properties:** 184 | 185 | `intervalSize` - size of the interval. Defaults to `1000`. When specifying bigger numbers, 186 | conflicts will happen less often. 187 | 188 | `increasingLimit` - the number of times user can continuously move item to the end of the sortable scope. 189 | Used to prevent increasing of numbers. Defaults to `10`. 190 | 191 | ## Changing order of models inside sortable scope 192 | 193 | The behavior provides few methods to change any sortable model order: 194 | 195 | - `moveToPosition($position)` - basic method for moving model to any position inside sortable scope 196 | - `moveBefore($pk = null)` - move model before another model of this sortable scope. 197 | If `$pk` is not specified it will be moved to the very end 198 | - `moveAfter($pk = null)` - move model after another model of this sortable scope 199 | If `$pk` is not specified it will be moved to the very beginning 200 | - `moveBack()` - move back by one position 201 | - `moveForward()` - move forward by one position 202 | - `moveAsFirst()` - move to the very beginning 203 | - `moveAsLast()` - move to the very end 204 | 205 | ## GUI for changing order 206 | 207 | There is special `SortableColumn` for `GridView`. 208 | 209 | **Features:** 210 | 211 | - It doesn't force you to use the whole another `GridView` 212 | - No need to attach additional actions every time 213 | - Multiple `GridView` on one page support 214 | - Displaying current position 215 | - Inline editing of current position 216 | - Moving with drag and drop (with `jQuery UI Sortable`) with special handle icon, 217 | so you can interact with other data without triggering sort change 218 | - Moving back and forward by one position 219 | - Moving as first and last 220 | 221 | Include once this to your application config: 222 | 223 | ```php 224 | 'controllerMap' => [ 225 | 'sort' => [ 226 | 'class' => 'arogachev\sortable\controllers\SortController', 227 | ], 228 | ], 229 | ``` 230 | 231 | Then configure `GridView`: 232 | 233 | - Wrap it with `Pjax` widget for working without page reload 234 | - Add `id` for unchangeable root container 235 | - Include column in `columns` section 236 | 237 | ```php 238 | use arogachev\sortable\grid\SortableColumn; 239 | ``` 240 | 241 | ```php 242 |
243 | 244 | 245 | [ 248 | [ 249 | 'class' => SortableColumn::className(), 250 | 'gridContainerId' => 'question-sortable', 251 | 'baseUrl' => '/sort/', // Optional, defaults to '/sort/' 252 | 'confirmMove' => true, // Optional, defaults to true 253 | ], 254 | // Other columns 255 | ], 256 | ]) ?> 257 | 258 | 259 |
260 | ``` 261 | 262 | You can configure display through `template` and `buttons` properties (similar to [ActionColumn](http://www.yiiframework.com/doc-2.0/yii-grid-actioncolumn.html)). 263 | 264 | The available tags are: 265 | 266 | - `currentPosition` 267 | - `moveWithDragAndDrop` 268 | - `moveForward` 269 | - `moveBack` 270 | - `moveAsFirst` 271 | - `moveAsLast` 272 | 273 | You can extend it with your own. Example of overriding: 274 | 275 | ```php 276 | 'template' => '
{moveWithDragAndDrop}
277 |
{currentPosition}
278 |
{moveForward} {moveBack}
', 279 | 'buttons' => [ 280 | 'moveForward' => function () { 281 | return Html::tag('i', '', [ 282 | 'class' => 'fa fa-arrow-circle-left', 283 | 'title' => Yii::t('sortable', 'Move forward'), 284 | ]); 285 | }, 286 | 'moveBack' => function () { 287 | return Html::tag('i', '', [ 288 | 'class' => 'fa fa-arrow-circle-right', 289 | 'title' => Yii::t('sortable', 'Move back'), 290 | ]); 291 | }, 292 | ], 293 | ``` 294 | 295 | ## Custom GUI for changing order 296 | 297 | If you want to write your own GUI for changing order without using `GridView`, you can use the `SortController` actions: 298 | 299 | - `move-before` (requires `pk` of the next element after move sent via `POST`) 300 | - `move-after` (requires `pk` of the previous element after move sent via `POST`) 301 | - `move-back` 302 | - `move-forward` 303 | - `move-as-first` 304 | - `move-as-last` 305 | - `move-to-position` (requires `position` sent via `POST`) 306 | 307 | For all of the actions these two parameters must exist in `POST`: 308 | 309 | - `modelClass` - model full class name with namespace 310 | - `modelPk` - moved model primary key value (pass object in case of primary keys) 311 | -------------------------------------------------------------------------------- /codeception.yml: -------------------------------------------------------------------------------- 1 | actor: Tester 2 | paths: 3 | tests: tests 4 | log: tests/_output 5 | data: tests/_data 6 | helpers: tests/_support 7 | settings: 8 | bootstrap: _bootstrap.php 9 | colors: true 10 | memory_limit: 1024M 11 | modules: 12 | config: 13 | Db: 14 | dsn: 'sqlite:tests/_output/temp.db' 15 | user: '' 16 | password: '' 17 | dump: tests/_data/dump.sql 18 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arogachev/yii2-sortable", 3 | "description": "Sortable ActiveRecord for Yii 2 framework", 4 | "keywords": [ 5 | "yii2", 6 | "sortable", 7 | "sort", 8 | "behavior", 9 | "active record", 10 | "gridview" 11 | ], 12 | "homepage": "https://github.com/arogachev/yii2-sortable", 13 | "type": "yii2-extension", 14 | "license": "BSD-3-Clause", 15 | "authors": [ 16 | { 17 | "name": "Alexey Rogachev", 18 | "email": "arogachev90@gmail.com" 19 | } 20 | ], 21 | "require": { 22 | "yiisoft/yii2": "*", 23 | "yiisoft/yii2-jui": "*" 24 | }, 25 | "require-dev": { 26 | "yiisoft/yii2-codeception": "~2.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "arogachev\\sortable\\": "src" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/assets/SortableColumnAsset.php: -------------------------------------------------------------------------------- 1 | YII_DEBUG, 19 | ]; 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public $css = [ 25 | 'css/sortable-column.css', 26 | ]; 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public $js = [ 32 | 'js/sortable-column.js', 33 | ]; 34 | 35 | /** 36 | * @inheritdoc 37 | */ 38 | public $depends = [ 39 | 'yii\jui\JuiAsset', 40 | ]; 41 | } 42 | -------------------------------------------------------------------------------- /src/assets/src/css/sortable-column.css: -------------------------------------------------------------------------------- 1 | .sortable-cell { 2 | min-width: 60px; 3 | } 4 | 5 | .sortable-section { 6 | text-align: center; 7 | } 8 | 9 | .sortable-section:not(:first-child) { 10 | margin: 5px 0 0 0; 11 | } 12 | 13 | .sortable-section .label:empty { 14 | display: inline; 15 | } 16 | 17 | .sortable-section .glyphicon { 18 | cursor: pointer; 19 | } 20 | 21 | .sortable-section .glyphicon-sort { 22 | cursor: move; 23 | } 24 | 25 | .sortable-section .glyphicon-fast-forward { 26 | margin: 0 0 0 5px; 27 | } 28 | -------------------------------------------------------------------------------- /src/assets/src/js/sortable-column.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | $.fn.yiiGridViewRow = function (method) { 3 | if (typeof method === 'string' && methods[method]) { 4 | var row = new Row(this); 5 | 6 | return methods[method].apply(row, Array.prototype.slice.call(arguments, 1)); 7 | } else if (typeof method === 'object' || !method) { 8 | var row = new Row(this); 9 | 10 | return this; 11 | } else { 12 | $.error('Method ' + method + ' does not exist on jQuery.yiiGridViewRow'); 13 | 14 | return false; 15 | } 16 | }; 17 | 18 | function Row($el) { 19 | this.$el = $el; 20 | } 21 | 22 | $.extend(Row.prototype, { 23 | getGridView: function() { 24 | return this.$el.closest('.grid-view'); 25 | }, 26 | 27 | getSortable: function() { 28 | return this.$el.closest('.ui-sortable'); 29 | }, 30 | 31 | getSortableCell: function() { 32 | return this.$el.find('.sortable-cell'); 33 | }, 34 | 35 | getPositionEl: function() { 36 | return this.getSortableCell().find('.label'); 37 | }, 38 | 39 | getPosition: function() { 40 | return this.getSortableCell().data('position'); 41 | }, 42 | 43 | isSortable: function() { 44 | return this.getPosition() != 0; 45 | }, 46 | 47 | getCount: function() { 48 | return this.getSortable().sortable('option', 'modelsCount'); 49 | }, 50 | 51 | isFirst: function() { 52 | return this.getPosition() == 1; 53 | }, 54 | 55 | isLast: function() { 56 | return this.getPosition() == this.getCount(); 57 | }, 58 | 59 | isPositionValid: function(position) { 60 | position = parseInt(position); 61 | 62 | if (isNaN(position)) { 63 | return false; 64 | } 65 | 66 | return (position >= 1 && position <= this.getCount()); 67 | }, 68 | 69 | resetPosition: function() { 70 | this.getPositionEl().text(this.getPosition()); 71 | }, 72 | 73 | getClass: function() { 74 | return this.getSortable().sortable('option', 'modelClass'); 75 | }, 76 | 77 | getPk: function() { 78 | return this.$el.data('key'); 79 | }, 80 | 81 | getBaseUrl: function() { 82 | return this.getSortable().sortable('option', 'baseUrl'); 83 | }, 84 | 85 | isMoveConfirmed: function() { 86 | return this.getSortable().sortable('option', 'confirmMove'); 87 | }, 88 | 89 | getMoveConfirmationText: function() { 90 | return this.getSortable().sortable('option', 'moveConfirmationText'); 91 | }, 92 | 93 | move: function(action, additionalParams) { 94 | var row = this; 95 | 96 | if (!this.isSortable()) { 97 | return; 98 | } 99 | 100 | if (this.isMoveConfirmed() && !confirm(this.getMoveConfirmationText())) { 101 | this.resetPosition(); 102 | this.getSortable().sortable('cancel'); 103 | 104 | return; 105 | } 106 | 107 | this.getPositionEl().removeClass('label-info').addClass('label-warning'); 108 | 109 | var params = { 110 | modelClass: this.getClass(), 111 | modelPk: this.getPk() 112 | }; 113 | var allParams = !additionalParams ? params : $.extend({}, params, additionalParams); 114 | 115 | $.post( 116 | this.getBaseUrl() + action, 117 | allParams, 118 | function () { 119 | row.getPositionEl().removeClass('label-warning').addClass('label-success'); 120 | row.getGridView().yiiGridView('applyFilter'); 121 | } 122 | ); 123 | } 124 | }); 125 | 126 | var methods = { 127 | moveToPosition: function(position) { 128 | if (!this.isPositionValid(position)) { 129 | this.resetPosition(); 130 | 131 | return; 132 | } 133 | 134 | if (position != this.getPosition()) { 135 | this.move('move-to-position', { position: position }); 136 | } 137 | }, 138 | 139 | moveWithDragAndDrop: function() { 140 | var $prevRow = this.$el.prev(); 141 | if ($prevRow.length) { 142 | var prevRow = new Row($prevRow); 143 | if (prevRow.isSortable()) { 144 | this.move('move-after', { pk: prevRow.getPk() }); 145 | 146 | return; 147 | } 148 | } 149 | 150 | var $nextRow = this.$el.next(); 151 | if ($nextRow.length) { 152 | var nextRow = new Row($nextRow); 153 | if (nextRow.isSortable()) { 154 | this.move('move-before', { pk: nextRow.getPk() }); 155 | return; 156 | } 157 | } 158 | 159 | this.getSortable().sortable('cancel'); 160 | }, 161 | 162 | moveForward: function() { 163 | if (!this.isFirst()) { 164 | this.move('move-to-position', { position: this.getPosition() - 1 }); 165 | } 166 | }, 167 | 168 | moveBack: function() { 169 | if (!this.isLast()) { 170 | this.move('move-to-position', { position: this.getPosition() + 1 }); 171 | } 172 | }, 173 | 174 | moveAsFirst: function() { 175 | if (!this.isFirst()) { 176 | this.move('move-as-first'); 177 | } 178 | }, 179 | 180 | moveAsLast: function() { 181 | if (!this.isLast()) { 182 | this.move('move-as-last'); 183 | } 184 | } 185 | }; 186 | })(window.jQuery); 187 | -------------------------------------------------------------------------------- /src/behaviors/BaseSortableBehavior.php: -------------------------------------------------------------------------------- 1 | $this->getSortableCount()) { 66 | throw new InvalidParamException("Position must be a number between 1 and {$this->getSortableCount()}."); 67 | } 68 | 69 | // The model is in the same position 70 | if ($position == $this->getSortablePosition()) { 71 | return true; 72 | } 73 | 74 | return false; 75 | } 76 | 77 | public function moveBack() 78 | { 79 | $this->moveToPosition($this->getSortablePosition() + 1); 80 | } 81 | 82 | public function moveForward() 83 | { 84 | $this->moveToPosition($this->getSortablePosition() - 1); 85 | } 86 | 87 | public function moveAsFirst() 88 | { 89 | $this->moveToPosition(1); 90 | } 91 | 92 | public function moveAsLast() 93 | { 94 | $this->moveToPosition($this->getSortableCount()); 95 | } 96 | 97 | /** 98 | * @param boolean $useOldAttributes 99 | * @return boolean 100 | */ 101 | public function isSortable($useOldAttributes = false) 102 | { 103 | if (!$this->sortableCondition) { 104 | return true; 105 | } 106 | 107 | $values = $useOldAttributes ? $this->_oldModel->attributes : $this->model->attributes; 108 | $sortableValues = array_intersect_key($values, $this->sortableCondition); 109 | 110 | foreach ($this->sortableCondition as $name => $value) { 111 | if ($sortableValues[$name] != $value) { 112 | return false; 113 | } 114 | } 115 | 116 | return true; 117 | } 118 | 119 | /** 120 | * @return boolean 121 | */ 122 | public function isSortableByCurrentUser() 123 | { 124 | return !$this->access ? true : call_user_func($this->access); 125 | } 126 | 127 | /** 128 | * @return integer 129 | */ 130 | public function getSortableCount() 131 | { 132 | return $this->query->orderBy(null)->count(); 133 | } 134 | 135 | /** 136 | * @return array 137 | * @throws InvalidConfigException 138 | */ 139 | public function getSortableScopeCondition() 140 | { 141 | if (!$this->scope) { 142 | return []; 143 | } 144 | 145 | /* @var $scopeQuery \yii\db\ActiveQuery */ 146 | $scopeQuery = call_user_func($this->scope, $this->model); 147 | 148 | if (!is_array($scopeQuery->where)) { 149 | throw new InvalidConfigException('"where" part of $scope query must be specified as array.'); 150 | } 151 | 152 | return $scopeQuery->where; 153 | } 154 | 155 | /** 156 | * @return \yii\db\ActiveRecord 157 | */ 158 | protected function getModel() 159 | { 160 | return $this->owner; 161 | } 162 | 163 | /** 164 | * @return \yii\db\ActiveQuery 165 | */ 166 | protected function getQuery() 167 | { 168 | $model = $this->model; 169 | 170 | return $model::find() 171 | ->select($model->primaryKey()) 172 | ->andFilterWhere($this->getSortableScopeCondition()) 173 | ->andFilterWhere($this->sortableCondition); 174 | } 175 | 176 | /** 177 | * @return array|\yii\db\ActiveRecord[] 178 | */ 179 | protected function getAllModels() 180 | { 181 | return $this->query->all(); 182 | } 183 | 184 | /** 185 | * @return null|boolean 186 | */ 187 | protected function getSortableDiff() 188 | { 189 | $isSortableBefore = $this->isSortable(true); 190 | $isSortableAfter = $this->isSortable(); 191 | 192 | if (!$isSortableBefore && $isSortableAfter) { 193 | return true; 194 | } elseif ($isSortableBefore && !$isSortableAfter) { 195 | return false; 196 | } else { 197 | return null; 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/behaviors/numerical/BaseNumericalSortableBehavior.php: -------------------------------------------------------------------------------- 1 | 'modelInit', 42 | ActiveRecord::EVENT_AFTER_FIND => 'afterFind', 43 | ActiveRecord::EVENT_BEFORE_INSERT => 'beforeInsert', 44 | ActiveRecord::EVENT_AFTER_INSERT => 'afterInsert', 45 | ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeUpdate', 46 | ActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdate', 47 | ]); 48 | } 49 | 50 | public function modelInit() 51 | { 52 | $this->_oldModel = clone $this->model; 53 | $this->_isSortProcessed = false; 54 | } 55 | 56 | public function afterFind() 57 | { 58 | $this->modelInit(); 59 | } 60 | 61 | public function beforeInsert() 62 | { 63 | $this->processSort(); 64 | } 65 | 66 | public function afterInsert() 67 | { 68 | $this->processSort(true); 69 | $this->modelInit(); 70 | } 71 | 72 | public function beforeUpdate() 73 | { 74 | $this->processSort(); 75 | 76 | if ($this->isScopeChanged()) { 77 | $this->addSort(); 78 | } 79 | } 80 | 81 | public function afterUpdate() 82 | { 83 | $this->processSort(true); 84 | } 85 | 86 | /** 87 | * @inheritdoc 88 | */ 89 | public function moveBefore($pk = null) 90 | { 91 | if (!$pk) { 92 | $this->moveAsLast(); 93 | 94 | return; 95 | } 96 | 97 | $prevModel = $this->findModel($pk); 98 | 99 | if ($this->getSortablePosition() > $prevModel->getSortablePosition()) { 100 | $position = $prevModel->getSortablePosition(); 101 | } else { 102 | $position = $prevModel->getSortablePosition() - 1; 103 | } 104 | 105 | $this->moveToPosition($position); 106 | } 107 | 108 | /** 109 | * @inheritdoc 110 | */ 111 | public function moveAfter($pk = null) 112 | { 113 | if (!$pk) { 114 | $this->moveAsFirst(); 115 | 116 | return; 117 | } 118 | 119 | $nextModel = $this->findModel($pk); 120 | 121 | if ($this->getSortablePosition() > $nextModel->getSortablePosition()) { 122 | $position = $nextModel->getSortablePosition() + 1; 123 | } else { 124 | $position = $nextModel->getSortablePosition(); 125 | } 126 | 127 | $this->moveToPosition($position); 128 | } 129 | 130 | /** 131 | * @return integer 132 | */ 133 | public function getSort() 134 | { 135 | return $this->model->{$this->sortAttribute}; 136 | } 137 | 138 | public function reindexAll() 139 | { 140 | $models = $this->getAllModels(); 141 | $position = 1; 142 | 143 | foreach ($models as $model) { 144 | $model->updateAttributes([$this->sortAttribute => $this->getInitialSortByPosition($position)]); 145 | 146 | $position++; 147 | } 148 | } 149 | 150 | /** 151 | * @return \yii\db\ActiveQuery 152 | */ 153 | protected function getQuery() 154 | { 155 | return parent::getQuery() 156 | ->addSelect($this->sortAttribute) 157 | ->orderBy([$this->sortAttribute => SORT_ASC]); 158 | } 159 | 160 | /** 161 | * @return integer 162 | */ 163 | protected function getFirstSort() 164 | { 165 | $model = $this->query->one(); 166 | 167 | return $model->getSort(); 168 | } 169 | 170 | /** 171 | * @return integer 172 | */ 173 | protected function getLastSort() 174 | { 175 | $model = $this->query 176 | ->orderBy([$this->sortAttribute => SORT_DESC]) 177 | ->one(); 178 | 179 | return $model ? $model->getSort() : 0; 180 | } 181 | 182 | /** 183 | * @param $value 184 | */ 185 | protected function setSort($value) 186 | { 187 | $this->model->{$this->sortAttribute} = $value; 188 | } 189 | 190 | protected function addSort() 191 | { 192 | if ($this->prependAdded) { 193 | $this->prependAdded(); 194 | } else { 195 | $this->setSort($this->getLastSort() + $this->_intervalSize); 196 | } 197 | } 198 | 199 | protected function resetSort() 200 | { 201 | $this->setSort(0); 202 | } 203 | 204 | protected function updateSort() 205 | { 206 | $this->model->updateAttributes([$this->sortAttribute => $this->getSort()]); 207 | } 208 | 209 | /** 210 | * @param boolean $updateSort 211 | */ 212 | protected function processSort($updateSort = false) 213 | { 214 | if ($this->_isSortProcessed) { 215 | return; 216 | } 217 | 218 | $isSortable = $this->model->isNewRecord ? $this->isSortable() : $this->getSortableDiff(); 219 | if ($isSortable === true) { 220 | $this->addSort(); 221 | } elseif ($isSortable === false) { 222 | $this->resetSort(); 223 | } 224 | 225 | if ($isSortable !== null && $updateSort) { 226 | $this->updateSort(); 227 | } 228 | 229 | if ($isSortable !== null) { 230 | $this->_isSortProcessed = true; 231 | } 232 | } 233 | 234 | /** 235 | * @param integer|array $pk 236 | * @return array 237 | */ 238 | protected function getPkCondition($pk) 239 | { 240 | if (count($this->model->primaryKey()) > 1) { 241 | return $pk; 242 | } 243 | 244 | return [$this->model->primaryKey()[0] => $pk]; 245 | } 246 | 247 | /** 248 | * @param integer|array $pk 249 | * @return ActiveRecord|BaseNumericalSortableBehavior 250 | */ 251 | protected function findModel($pk) 252 | { 253 | $model = $this->query->andWhere($this->getPkCondition($pk))->one(); 254 | 255 | if (!$model) { 256 | throw new InvalidParamException('The model not found by given primary key.'); 257 | } 258 | 259 | return $model; 260 | } 261 | 262 | /** 263 | * @param integer $newPosition 264 | * @param boolean $updateCurrentModel 265 | */ 266 | protected function resolveConflict($newPosition, $updateCurrentModel = true) 267 | { 268 | $models = $this->getAllModels(); 269 | $position = 1; 270 | 271 | foreach ($models as $model) { 272 | $isCurrentModel = $model->primaryKey == $this->model->primaryKey; 273 | 274 | if ($position == $newPosition) { 275 | $position++; 276 | } 277 | 278 | $updatedPosition = $isCurrentModel ? $newPosition : $position; 279 | $sort = $this->getInitialSortByPosition($updatedPosition); 280 | 281 | if ($isCurrentModel && !$updateCurrentModel) { 282 | $this->setSort($sort); 283 | } else { 284 | $model->updateAttributes([$this->sortAttribute => $sort]); 285 | } 286 | 287 | if (!$isCurrentModel) { 288 | $position++; 289 | } 290 | } 291 | } 292 | 293 | /** 294 | * @return boolean 295 | */ 296 | protected function isScopeChanged() 297 | { 298 | foreach ($this->getSortableScopeCondition() as $attribute => $value) { 299 | if ($this->model->isAttributeChanged($attribute)) { 300 | return true; 301 | } 302 | } 303 | 304 | return false; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/behaviors/numerical/ContinuousNumericalSortableBehavior.php: -------------------------------------------------------------------------------- 1 | _intervalSize = 1; 20 | 21 | parent::init(); 22 | } 23 | 24 | /** 25 | * @inheritdoc 26 | */ 27 | public function events() 28 | { 29 | return array_merge(parent::events(), [ 30 | ActiveRecord::EVENT_AFTER_DELETE => 'afterDelete', 31 | ]); 32 | } 33 | 34 | public function beforeUpdate() 35 | { 36 | parent::beforeUpdate(); 37 | 38 | $this->_reindexOldModel = $this->isScopeChanged() ? true : false; 39 | } 40 | 41 | public function afterUpdate() 42 | { 43 | parent::afterUpdate(); 44 | 45 | if (!$this->getSort()) { 46 | $this->reindexAfterDelete(); 47 | } 48 | 49 | if ($this->_reindexOldModel) { 50 | $this->_oldModel->reindexAfterDelete(true); 51 | } 52 | 53 | $this->modelInit(); 54 | } 55 | 56 | public function afterDelete() 57 | { 58 | $this->reindexAfterDelete(); 59 | } 60 | 61 | /** 62 | * @inheritdoc 63 | */ 64 | public function getSortablePosition() 65 | { 66 | return $this->getSort(); 67 | } 68 | 69 | /** 70 | * @inheritdoc 71 | */ 72 | public function moveToPosition($position) 73 | { 74 | if (parent::moveToPosition($position)) { 75 | return; 76 | } 77 | 78 | $currentPosition = $this->getSortablePosition(); 79 | 80 | if ($position < $currentPosition) { 81 | // Moving forward 82 | $oldSortFrom = $position; 83 | $oldSortTo = $currentPosition - 1; 84 | $addedValue = 1; 85 | } else { 86 | // Moving back 87 | $oldSortFrom = $currentPosition + 1; 88 | $oldSortTo = $position; 89 | $addedValue = -1; 90 | } 91 | 92 | $models = $this->query 93 | ->andWhere(['>=', $this->sortAttribute, $oldSortFrom]) 94 | ->andWhere(['<=', $this->sortAttribute, $oldSortTo]) 95 | ->andWhere(['<>', $this->sortAttribute, $currentPosition]) 96 | ->all(); 97 | 98 | foreach ($models as $model) { 99 | $sort = $model->getSort() + $addedValue; 100 | $model->updateAttributes([$this->sortAttribute => $sort]); 101 | } 102 | 103 | $this->model->updateAttributes([$this->sortAttribute => $position]); 104 | } 105 | 106 | /** 107 | * @param boolean $useCurrentSort 108 | */ 109 | public function reindexAfterDelete($useCurrentSort = false) 110 | { 111 | $sort = $useCurrentSort ? $this->model->getSort() : $this->_oldModel->getSort(); 112 | 113 | $models = $this->query 114 | ->andWhere(['>', $this->sortAttribute, $sort]) 115 | ->all(); 116 | 117 | foreach ($models as $model) { 118 | $model->updateAttributes([$this->sortAttribute => $sort]); 119 | 120 | $sort++; 121 | } 122 | } 123 | 124 | /** 125 | * @inheritdoc 126 | */ 127 | protected function getInitialSortByPosition($position) 128 | { 129 | return $position; 130 | } 131 | 132 | /** 133 | * @inheritdoc 134 | */ 135 | protected function prependAdded() 136 | { 137 | $this->resolveConflict(1, false); 138 | $this->setSort($this->getInitialSortByPosition(1)); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/behaviors/numerical/IntervalNumericalSortableBehavior.php: -------------------------------------------------------------------------------- 1 | _intervalSize) { 28 | $this->_intervalSize = 1000; 29 | } 30 | 31 | parent::init(); 32 | } 33 | 34 | public function afterFind() 35 | { 36 | parent::afterFind(); 37 | 38 | if (!isset(self::$_positionsMap[$this->getPositionMapKey()])) { 39 | $this->fillPositionsMap(); 40 | } 41 | } 42 | 43 | public function afterUpdate() 44 | { 45 | parent::afterUpdate(); 46 | 47 | $this->modelInit(); 48 | } 49 | 50 | /** 51 | * @inheritdoc 52 | */ 53 | public function getSortablePosition() 54 | { 55 | return self::$_positionsMap[$this->getPositionMapKey()]; 56 | } 57 | 58 | /** 59 | * @inheritdoc 60 | */ 61 | public function moveToPosition($position) 62 | { 63 | if (parent::moveToPosition($position)) { 64 | return; 65 | } 66 | 67 | if ($position == 1) { 68 | // Move as first 69 | $this->moveToInterval([0, $this->getFirstSort()], $position); 70 | } elseif ($position == $this->getSortableCount()) { 71 | // Move as last 72 | if ($this->isIncreasedLimitReached()) { 73 | $this->resolveConflict($position); 74 | } else { 75 | $this->model->updateAttributes([ 76 | $this->sortAttribute => $this->getLastSort() + $this->_intervalSize, 77 | ]); 78 | } 79 | } else { 80 | $this->moveToInterval($this->getNewInterval($position), $position); 81 | } 82 | } 83 | 84 | /** 85 | * @param integer $value 86 | */ 87 | public function setIntervalSize($value) 88 | { 89 | $this->_intervalSize = $value; 90 | } 91 | 92 | /** 93 | * @return \yii\db\ActiveQuery 94 | */ 95 | protected function getQuery() 96 | { 97 | return parent::getQuery()->select(null); 98 | } 99 | 100 | /** 101 | * @inheritdoc 102 | */ 103 | protected function getInitialSortByPosition($position) 104 | { 105 | return $position * $this->_intervalSize; 106 | } 107 | 108 | /** 109 | * @inheritdoc 110 | */ 111 | protected function prependAdded() 112 | { 113 | $sort = $this->getSortByInterval([0, $this->getFirstSort()]); 114 | if (!$sort) { 115 | $this->resolveConflict(1, false); 116 | $this->setSort($this->getInitialSortByPosition(1)); 117 | 118 | return; 119 | } 120 | 121 | $this->setSort($sort); 122 | } 123 | 124 | protected function fillPositionsMap() 125 | { 126 | $elements = $this->query 127 | ->select($this->model->primaryKey()) 128 | ->asArray() 129 | ->all(); 130 | $position = 1; 131 | 132 | foreach ($elements as $element) { 133 | self::$_positionsMap[$this->getPositionMapKey($element)] = $position; 134 | 135 | $position++; 136 | } 137 | } 138 | 139 | /** 140 | * @param null|array $pk 141 | * @return mixed 142 | */ 143 | protected function getPositionMapKey($pk = null) 144 | { 145 | $pk = $pk ?: $this->model->getPrimaryKey(true); 146 | 147 | if (count($pk) == 1) { 148 | return reset($pk); 149 | } 150 | 151 | $key = ''; 152 | 153 | foreach ($pk as $name => $value) { 154 | $key = $name . self::POSITIONS_MAP_KEY_DIVIDER . $value; 155 | } 156 | 157 | return rtrim($key, self::POSITIONS_MAP_KEY_DIVIDER); 158 | } 159 | 160 | /** 161 | * @param array $interval 162 | * @param integer $position 163 | */ 164 | protected function moveToInterval($interval, $position) 165 | { 166 | $sort = $this->getSortByInterval($interval); 167 | if (!$sort) { 168 | $this->resolveConflict($position); 169 | 170 | return; 171 | } 172 | 173 | $this->model->updateAttributes([$this->sortAttribute => $sort]); 174 | } 175 | 176 | /** 177 | * @param array $interval 178 | * @return boolean|integer 179 | */ 180 | protected function getSortByInterval($interval) 181 | { 182 | $difference = $interval[1] - $interval[0]; 183 | if ($difference < 2) { 184 | return false; 185 | } 186 | 187 | return (int) ($interval[0] + round($difference / 2)); 188 | } 189 | 190 | /** 191 | * @param integer $position 192 | * @return array 193 | */ 194 | protected function getNewInterval($position) 195 | { 196 | if ($position < $this->getSortablePosition()) { 197 | // Moving forward 198 | $offset = $position - 2; 199 | } else { 200 | // Moving back 201 | $offset = $position - 1; 202 | } 203 | 204 | $result = $this->query 205 | ->select($this->sortAttribute) 206 | ->offset($offset) 207 | ->limit(2) 208 | ->asArray() 209 | ->all(); 210 | 211 | return ArrayHelper::getColumn($result, $this->sortAttribute); 212 | } 213 | 214 | /** 215 | * @return boolean 216 | */ 217 | protected function isIncreasedLimitReached() 218 | { 219 | $sort = $this->getLastSort() + $this->_intervalSize; 220 | 221 | return round($sort / $this->_intervalSize) - $this->getSortableCount() > $this->increasingLimit; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/controllers/SortController.php: -------------------------------------------------------------------------------- 1 | ContentNegotiator::className(), 31 | 'formats' => [ 32 | 'application/json' => Response::FORMAT_JSON, 33 | ], 34 | ], 35 | ]; 36 | } 37 | 38 | /** 39 | * @inheritdoc 40 | */ 41 | public function beforeAction($action) 42 | { 43 | if (!parent::beforeAction($action)) { 44 | return false; 45 | } 46 | 47 | if (!Yii::$app->request->isAjax) { 48 | throw new ForbiddenHttpException('This page is not allowed for view.'); 49 | } 50 | 51 | $modelClass = Yii::$app->request->post('modelClass'); 52 | if (!$modelClass) { 53 | throw new BadRequestHttpException('Model class must be specified in order to find model.'); 54 | } 55 | 56 | $pk = Yii::$app->request->post('modelPk'); 57 | if (!$pk) { 58 | throw new BadRequestHttpException('Model primary key must be specified in order to find model.'); 59 | } 60 | 61 | $model = $modelClass::findOne($pk); 62 | if (!$model) { 63 | throw new NotFoundHttpException('Model not found.'); 64 | } 65 | 66 | if (!($model instanceof ActiveRecord)) { 67 | throw new BadRequestHttpException('Valid ActiveRecord model class must be specified in order to find model.'); 68 | } 69 | 70 | if (!$model->isSortableByCurrentUser()) { 71 | throw new ForbiddenHttpException(Yii::t('yii', 'You are not allowed to perform this action.')); 72 | } 73 | 74 | $this->_model = $model; 75 | 76 | return true; 77 | } 78 | 79 | /** 80 | * @inheritdoc 81 | */ 82 | public function afterAction($action, $result) 83 | { 84 | $result = parent::afterAction($action, $result); 85 | 86 | return [ 87 | 'sort' => [ 88 | 'errors' => !empty($result), 89 | ], 90 | ]; 91 | } 92 | 93 | public function actionMoveBefore() 94 | { 95 | $this->_model->moveBefore(Yii::$app->request->post('pk')); 96 | } 97 | 98 | public function actionMoveAfter() 99 | { 100 | $this->_model->moveAfter(Yii::$app->request->post('pk')); 101 | } 102 | 103 | public function actionMoveBack() 104 | { 105 | $this->_model->moveBack(); 106 | } 107 | 108 | public function actionMoveForward() 109 | { 110 | $this->_model->moveForward(); 111 | } 112 | 113 | public function actionMoveAsFirst() 114 | { 115 | $this->_model->moveAsFirst(); 116 | } 117 | 118 | public function actionMoveAsLast() 119 | { 120 | $this->_model->moveAsLast(); 121 | } 122 | 123 | public function actionMoveToPosition() 124 | { 125 | $this->_model->moveToPosition(Yii::$app->request->post('position')); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/grid/SortableColumn.php: -------------------------------------------------------------------------------- 1 | grid->dataProvider; 59 | if (!($dataProvider instanceof ActiveDataProvider)) { 60 | throw new InvalidConfigException('SortableColumn works only with ActiveDataProvider.'); 61 | } 62 | 63 | if (!$this->gridContainerId) { 64 | throw new InvalidConfigException('$gridContainerId property must be set.'); 65 | } 66 | 67 | Yii::setAlias('@sortable', dirname(__DIR__)); 68 | Yii::$app->i18n->translations['sortable'] = [ 69 | 'class' => PhpMessageSource::className(), 70 | 'basePath' => '@sortable/messages', 71 | ]; 72 | 73 | /* @var $query \yii\db\ActiveQuery */ 74 | $query = $dataProvider->query; 75 | 76 | $this->_model = new $query->modelClass; 77 | 78 | $this->contentOptions = function ($model) { 79 | /* @var $model ActiveRecord|BaseNumericalSortableBehavior */ 80 | 81 | return [ 82 | 'class' => 'sortable-cell', 83 | 'data-position' => $model->getSortablePosition(), 84 | ]; 85 | }; 86 | 87 | if (!$this->header) { 88 | $this->header = Yii::t('sortable', 'Sort'); 89 | } 90 | 91 | $this->visible = $this->isVisible(); 92 | 93 | if (!$this->template) { 94 | $this->template = '
{currentPosition}
95 |
{moveWithDragAndDrop}
96 |
{moveForward} {moveBack}
97 |
{moveAsFirst} {moveAsLast}
'; 98 | } 99 | 100 | $this->initDefaultButtons(); 101 | 102 | if (!Yii::$app->request->isAjax) { 103 | $this->registerJs(); 104 | } 105 | } 106 | 107 | /** 108 | * @inheritdoc 109 | */ 110 | protected function renderDataCellContent($model, $key, $index) 111 | { 112 | /* @var $model ActiveRecord|BaseNumericalSortableBehavior */ 113 | if (!$model->isSortable()) { 114 | return Html::tag('span', Yii::t('sortable', 'Not sortable item'), ['class' => 'label label-danger']); 115 | } 116 | 117 | return preg_replace_callback('/\\{([\w\-\/]+)\\}/', function ($matches) use ($model, $key, $index) { 118 | $name = $matches[1]; 119 | 120 | if (isset($this->buttons[$name])) { 121 | return call_user_func($this->buttons[$name], $model, $key, $index); 122 | } else { 123 | return ''; 124 | } 125 | }, $this->template); 126 | } 127 | 128 | protected function initDefaultButtons() 129 | { 130 | if (!isset($this->buttons['currentPosition'])) { 131 | $this->buttons['currentPosition'] = function ($model) { 132 | /* @var $model ActiveRecord|BaseNumericalSortableBehavior */ 133 | 134 | return Html::tag('span', $model->getSortablePosition(), [ 135 | 'class' => 'label label-info', 136 | 'contenteditable' => true, 137 | 'title' => Yii::t('sortable', 'Current position') . ' / ' . Yii::t('sortable', 'Change position'), 138 | ]); 139 | }; 140 | } 141 | 142 | if (!isset($this->buttons['moveWithDragAndDrop'])) { 143 | $this->buttons['moveWithDragAndDrop'] = function () { 144 | return Html::tag('span', '', [ 145 | 'class' => 'glyphicon glyphicon-sort', 146 | 'title' => Yii::t('sortable', 'Move with drag and drop'), 147 | ]); 148 | }; 149 | } 150 | 151 | if (!isset($this->buttons['moveForward'])) { 152 | $this->buttons['moveForward'] = function () { 153 | return Html::tag('span', '', [ 154 | 'class' => 'glyphicon glyphicon-arrow-up', 155 | 'title' => Yii::t('sortable', 'Move forward'), 156 | ]); 157 | }; 158 | } 159 | 160 | if (!isset($this->buttons['moveBack'])) { 161 | $this->buttons['moveBack'] = function () { 162 | return Html::tag('span', '', [ 163 | 'class' => 'glyphicon glyphicon-arrow-down', 164 | 'title' => Yii::t('sortable', 'Move back'), 165 | ]); 166 | }; 167 | } 168 | 169 | if (!isset($this->buttons['moveAsFirst'])) { 170 | $this->buttons['moveAsFirst'] = function () { 171 | return Html::tag('span', '', [ 172 | 'class' => 'glyphicon glyphicon-fast-backward', 173 | 'title' => Yii::t('sortable', 'Move as first'), 174 | ]); 175 | }; 176 | } 177 | 178 | if (!isset($this->buttons['moveAsLast'])) { 179 | $this->buttons['moveAsLast'] = function () { 180 | return Html::tag('span', '', [ 181 | 'class' => 'glyphicon glyphicon-fast-forward', 182 | 'title' => Yii::t('sortable', 'Move as last'), 183 | ]); 184 | }; 185 | } 186 | } 187 | 188 | protected function isVisible() 189 | { 190 | if (!$this->_model->isSortableByCurrentUser()) { 191 | return false; 192 | } 193 | 194 | if ($this->grid->filterModel) { 195 | $scopeAttributes = array_keys($this->_model->getSortableScopeCondition()); 196 | $sortableAttributes = array_keys($this->_model->sortableCondition); 197 | $formData = Yii::$app->request->get($this->grid->filterModel->formName(), []); 198 | 199 | foreach ($scopeAttributes as $attribute) { 200 | if (!ArrayHelper::getValue($formData, $attribute) && !Yii::$app->request->get($attribute)) { 201 | return false; 202 | } 203 | } 204 | 205 | foreach ($sortableAttributes as $attribute => $value) { 206 | $formValue = ArrayHelper::getValue($formData, $attribute); 207 | if ($formValue && $formValue != $value) { 208 | return false; 209 | } 210 | } 211 | } 212 | 213 | $sort = $this->grid->dataProvider->getSort(); 214 | 215 | return $sort->orders == [$this->_model->sortAttribute => SORT_ASC]; 216 | } 217 | 218 | protected function registerJs() 219 | { 220 | SortableColumnAsset::register(Yii::$app->view); 221 | 222 | $model = $this->_model; 223 | 224 | Sortable::widget([ 225 | 'id' => $this->gridContainerId, 226 | 'clientOptions' => [ 227 | 'items' => 'tbody tr', 228 | 'handle' => '.glyphicon-sort', 229 | 'modelClass' => $model::className(), 230 | 'modelsCount' => $this->grid->dataProvider->getTotalCount(), 231 | 'baseUrl' => $this->baseUrl, 232 | 'confirmMove' => $this->confirmMove, 233 | 'moveConfirmationText' => Yii::t('sortable', 'Are you sure you want to move this item?'), 234 | ], 235 | 'clientEvents' => [ 236 | 'update' => new JsExpression("function(event, ui) { 237 | $(ui.item).yiiGridViewRow('moveWithDragAndDrop'); 238 | }"), 239 | 'blur' => [ 240 | '.label-info' => new JsExpression("function() { 241 | $(this.closest('tr')).yiiGridViewRow('moveToPosition', $(this).text()); 242 | }"), 243 | ], 244 | 'keypress' => [ 245 | '.label-info' => new JsExpression("function(e) { 246 | if (e.which == 13) { 247 | $(this).trigger('blur'); 248 | } 249 | }"), 250 | ], 251 | 'click' => [ 252 | '.sortable-section .glyphicon-arrow-up' => new JsExpression("function() { 253 | $(this.closest('tr')).yiiGridViewRow('moveForward'); 254 | }"), 255 | '.sortable-section .glyphicon-arrow-down' => new JsExpression("function() { 256 | $(this.closest('tr')).yiiGridViewRow('moveBack'); 257 | }"), 258 | '.sortable-section .glyphicon-fast-backward' => new JsExpression("function() { 259 | $(this.closest('tr')).yiiGridViewRow('moveAsFirst'); 260 | }"), 261 | '.sortable-section .glyphicon-fast-forward' => new JsExpression("function() { 262 | $(this.closest('tr')).yiiGridViewRow('moveAsLast'); 263 | }"), 264 | ], 265 | ], 266 | ]); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/messages/el/sortable.php: -------------------------------------------------------------------------------- 1 | 'Ταξινόμηση', 5 | 'Not sortable item' => 'Μη ταξινομημένο αντικείμενο', 6 | 'Are you sure you want to move this item?' => 'Είσαι σίγουρος ότι θέλεις να μετακινήσεις αυτό το αντικείμενο;', 7 | 'Current position' => 'Τρέχουσα θέση', 8 | 'Change position' => 'Αλλαγή θέσης', 9 | 'Move with drag and drop' => 'Μετακίνηση με drag and drop', 10 | 'Move forward' => 'Μετακίνηση μπροστά', 11 | 'Move back' => 'Μετακίνηση πίσω', 12 | 'Move as first' => 'Μετακίνηση στην πρώτη θέση', 13 | 'Move as last' => 'Μετακίνηση στο τέλος', 14 | ]; 15 | -------------------------------------------------------------------------------- /src/messages/ru/sortable.php: -------------------------------------------------------------------------------- 1 | 'Сортировка', 5 | 'Not sortable item' => 'Несортируемый элемент', 6 | 'Are you sure you want to move this item?' => 'Вы уверены, что хотите переместить этот элемент?', 7 | 'Current position' => 'Текущая позиция', 8 | 'Change position' => 'Изменить позицию', 9 | 'Move with drag and drop' => 'Переместить перетаскиванием', 10 | 'Move forward' => 'Переместить вперед', 11 | 'Move back' => 'Переместить назад', 12 | 'Move as first' => 'Переместить первым', 13 | 'Move as last' => 'Переместить последним', 14 | ]; 15 | -------------------------------------------------------------------------------- /src/widgets/Sortable.php: -------------------------------------------------------------------------------- 1 | clientEvents)) { 15 | $js = []; 16 | foreach ($this->clientEvents as $event => $handler) { 17 | if (!is_array($handler)) { 18 | if (isset($this->clientEventMap[$event])) { 19 | $eventName = $this->clientEventMap[$event]; 20 | } else { 21 | $eventName = strtolower($name . $event); 22 | } 23 | $js[] = "jQuery('#$id').on('$eventName', $handler);"; 24 | } else { 25 | foreach ($handler as $selector => $singleHandler) { 26 | $js[] = "jQuery('#$id').on('$event', '$selector', $singleHandler);"; 27 | } 28 | } 29 | } 30 | $this->getView()->registerJs(implode("\n", $js)); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/_bootstrap.php: -------------------------------------------------------------------------------- 1 | [ 23 | 'class' => ContinuousNumericalSortableBehavior::className(), 24 | 'scope' => function ($model) { 25 | /* @var $model Category */ 26 | return $model->getNeighbors(); 27 | }, 28 | ], 29 | ]; 30 | } 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public static function tableName() 36 | { 37 | return 'categories'; 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | public function rules() 44 | { 45 | return [ 46 | ['name', 'string', 'max' => 255], 47 | ]; 48 | } 49 | 50 | /** 51 | * @return \yii\db\ActiveQuery 52 | */ 53 | public function getNeighbors() 54 | { 55 | return static::find()->where(['parent_id' => $this->parent_id]); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/_data/ContinuousQuestion.php: -------------------------------------------------------------------------------- 1 | [ 31 | 'class' => ContinuousNumericalSortableBehavior::className(), 32 | 'scope' => function () { 33 | return ContinuousQuestion::find()->where(['test_id' => $this->test_id]); 34 | }, 35 | 'sortableCondition' => [ 36 | 'is_active' => 1, 37 | ], 38 | ] 39 | ]; 40 | } 41 | 42 | /** 43 | * @inheritdoc 44 | */ 45 | public static function tableName() 46 | { 47 | return 'continuous_questions'; 48 | } 49 | 50 | /** 51 | * @inheritdoc 52 | */ 53 | public function rules() 54 | { 55 | return [ 56 | ['is_active', 'default', 'value' => 1], 57 | ]; 58 | } 59 | 60 | /** 61 | * @inheritdoc 62 | */ 63 | public function beforeSave($insert) 64 | { 65 | if (parent::beforeSave($insert)) { 66 | if ($this->runBeforeSave) { 67 | $this->is_active = !$this->is_active; 68 | } 69 | 70 | return true; 71 | } else { 72 | return false; 73 | } 74 | } 75 | 76 | /** 77 | * @return array 78 | */ 79 | public function getOtherQuestionsSort() 80 | { 81 | $questions = static::find() 82 | ->where(['test_id' => $this->test_id]) 83 | ->andWhere(['<>', 'id', $this->id]) 84 | ->orderBy(['id' => SORT_ASC]) 85 | ->all(); 86 | 87 | return ArrayHelper::getColumn($questions, 'sort'); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/_data/IntervalQuestion.php: -------------------------------------------------------------------------------- 1 | [ 31 | 'class' => IntervalNumericalSortableBehavior::className(), 32 | 'scope' => function () { 33 | return IntervalQuestion::find()->where(['test_id' => $this->test_id]); 34 | }, 35 | 'sortableCondition' => [ 36 | 'is_active' => 1, 37 | ], 38 | ] 39 | ]; 40 | } 41 | 42 | /** 43 | * @inheritdoc 44 | */ 45 | public static function tableName() 46 | { 47 | return 'interval_questions'; 48 | } 49 | 50 | /** 51 | * @inheritdoc 52 | */ 53 | public function rules() 54 | { 55 | return [ 56 | ['is_active', 'default', 'value' => 1], 57 | ]; 58 | } 59 | 60 | /** 61 | * @inheritdoc 62 | */ 63 | public function beforeSave($insert) 64 | { 65 | if (parent::beforeSave($insert)) { 66 | if ($this->runBeforeSave) { 67 | $this->is_active = !$this->is_active; 68 | } 69 | 70 | return true; 71 | } else { 72 | return false; 73 | } 74 | } 75 | 76 | /** 77 | * @return array 78 | */ 79 | public function getOtherQuestionsSort() 80 | { 81 | $questions = static::find() 82 | ->where(['test_id' => $this->test_id]) 83 | ->andWhere(['<>', 'id', $this->id]) 84 | ->orderBy(['id' => SORT_ASC]) 85 | ->all(); 86 | 87 | return ArrayHelper::getColumn($questions, 'sort'); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/_data/dump.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | CREATE TABLE "tests" ( 4 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | "name" text NOT NULL 6 | ); 7 | 8 | INSERT INTO "tests" ("id", "name") VALUES (1, 'Common test'); 9 | INSERT INTO "tests" ("id", "name") VALUES (2, 'Programming test'); 10 | 11 | CREATE TABLE "continuous_questions" ( 12 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 13 | "test_id" integer NOT NULL, 14 | "sort" integer NOT NULL, 15 | "content" text NOT NULL, 16 | "is_active" integer NOT NULL DEFAULT '1', 17 | FOREIGN KEY ("test_id") REFERENCES "tests" ("id") ON DELETE CASCADE ON UPDATE CASCADE 18 | ); 19 | 20 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (1, 1, 1, 'What''s your name?', 1); 21 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (2, 1, 2, 'Where are you from?', 1); 22 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (3, 1, 3, 'How old are you?', 1); 23 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (4, 1, 4, 'What''s your profession', 1); 24 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (5, 1, 5, 'What''s your hobby?', 1); 25 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (6, 2, 1, 'What''s your programming experience?', 1); 26 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (7, 2, 2, 'What programming languages do you know?', 1); 27 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (8, 2, 3, 'What VCS do you use?', 1); 28 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (9, 2, 4, 'How good you are as system administrator?', 1); 29 | INSERT INTO "continuous_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (10, 2, 5, 'Do you have GitHub account?', 1); 30 | 31 | CREATE TABLE "interval_questions" ( 32 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 33 | "test_id" integer NOT NULL, 34 | "sort" integer NOT NULL, 35 | "content" text NOT NULL, 36 | "is_active" integer NOT NULL DEFAULT '1', 37 | FOREIGN KEY ("test_id") REFERENCES "tests" ("id") ON DELETE CASCADE ON UPDATE CASCADE 38 | ); 39 | 40 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (1, 1, 1000, 'What''s your name?', 1); 41 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (2, 1, 2000, 'Where are you from?', 1); 42 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (3, 1, 3000, 'How old are you?', 1); 43 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (4, 1, 4000, 'What''s your profession', 1); 44 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (5, 1, 5000, 'What''s your hobby?', 1); 45 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (6, 2, 1000, 'What''s your programming experience?', 1); 46 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (7, 2, 2000, 'What programming languages do you know?', 1); 47 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (8, 2, 3000, 'What VCS do you use?', 1); 48 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (9, 2, 4000, 'How good you are as system administrator?', 1); 49 | INSERT INTO "interval_questions" ("id", "test_id", "sort", "content", "is_active") VALUES (10, 2, 5000, 'Do you have GitHub account?', 1); 50 | 51 | CREATE TABLE "categories" ( 52 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 53 | "parent_id" integer, 54 | "name" text NOT NULL, 55 | "sort" integer NOT NULL 56 | ); 57 | 58 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (1, NULL, 'Category 1', 1); 59 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (2, 1, 'Category 1.1', 1); 60 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (3, 1, 'Category 1.2', 2); 61 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (4, 1, 'Category 1.3', 3); 62 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (5, NULL, 'Category 2', 2); 63 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (6, 2, 'Category 2.1', 1); 64 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (7, 2, 'Category 2.2', 2); 65 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (8, 2, 'Category 2.3', 3); 66 | INSERT INTO "categories" ("id", "parent_id", "name", "sort") VALUES (9, 8, 'Category 2.3.1', 1); 67 | 68 | COMMIT; 69 | -------------------------------------------------------------------------------- /tests/_support/UnitHelper.php: -------------------------------------------------------------------------------- 1 | 1, 20 | 'content' => 'Do you have a car?', 21 | ]); 22 | $question->save(); 23 | 24 | $this->checkQuestion($question, 6, [1, 2, 3, 4, 5]); 25 | } 26 | 27 | public function testCreateNotActive() 28 | { 29 | $question = new ContinuousQuestion([ 30 | 'test_id' => 1, 31 | 'content' => 'Do you have a car?', 32 | 'is_active' => false, 33 | ]); 34 | $question->save(); 35 | 36 | $this->checkQuestion($question, 0, [1, 2, 3, 4, 5]); 37 | } 38 | 39 | public function testCreatePrependAdded() 40 | { 41 | $question = new ContinuousQuestion(); 42 | $behaviorConfig = $question->behaviors()['sort']; 43 | $behaviorConfig['prependAdded'] = true; 44 | $question->detachBehavior('sort'); 45 | $question->attachBehavior('sort', $behaviorConfig); 46 | $question->setAttributes([ 47 | 'test_id' => 1, 48 | 'content' => 'Do you have a car?', 49 | ], false); 50 | $question->save(); 51 | 52 | $this->checkQuestion($question, 1, [2, 3, 4, 5, 6]); 53 | 54 | // Prepend to scope with single model 55 | $category = new Category(); 56 | $behaviorConfig = $category->behaviors()['sort']; 57 | $behaviorConfig['prependAdded'] = true; 58 | $category->detachBehavior('sort'); 59 | $category->attachBehavior('sort', $behaviorConfig); 60 | $category->setAttributes([ 61 | 'parent_id' => 8, 62 | 'name' => 'Category 2.3.2', 63 | ], false); 64 | $category->save(); 65 | $sort = Category::find()->select('sort')->orderBy(['id' => SORT_ASC])->column(); 66 | 67 | $this->assertEquals([1, 1, 2, 3, 2, 1, 2, 3, 2, 1], $sort); 68 | } 69 | 70 | public function testUpdate() 71 | { 72 | $question = $this->findQuestion(3); 73 | $question->is_active = false; 74 | $question->save(); 75 | 76 | $this->checkQuestion($question, 0, [1, 2, 3, 4]); 77 | 78 | $question->is_active = true; 79 | $question->save(); 80 | 81 | $this->checkQuestion($question, 5, [1, 2, 3, 4]); 82 | 83 | $question->runBeforeSave = true; 84 | $question->save(); 85 | 86 | $this->checkQuestion($question, 0, [1, 2, 3, 4]); 87 | 88 | $question->save(); 89 | 90 | $this->checkQuestion($question, 5, [1, 2, 3, 4]); 91 | } 92 | 93 | public function testDelete() 94 | { 95 | $question = $this->findQuestion(3); 96 | $question->delete(); 97 | 98 | $this->checkQuestion($question, null, [1, 2, 3, 4]); 99 | } 100 | 101 | public function testMoveToOtherScope() 102 | { 103 | $question = $this->findQuestion(3); 104 | $question->test_id = 2; 105 | $question->save(); 106 | 107 | $this->checkQuestion($question, 6, [1, 2, 3, 4, 5], false); 108 | $this->checkOtherTestQuestions(1, [1, 2, 3, 4]); 109 | 110 | // Model related scope 111 | 112 | /* @var $category Category */ 113 | $category = Category::findOne(3); 114 | $category->parent_id = 2; 115 | $category->save(); 116 | $category->moveAfter(7); 117 | $sort = Category::find()->select('sort')->orderBy(['id' => SORT_ASC])->column(); 118 | 119 | $this->assertEquals([1, 1, 3, 2, 2, 1, 2, 4, 1], $sort); 120 | } 121 | 122 | public function testMoveToPosition() 123 | { 124 | $question = $this->findQuestion(3); 125 | $question->moveToPosition(2); 126 | 127 | $this->checkQuestion($question, 2, [1, 3, 4, 5]); 128 | } 129 | 130 | public function testMoveBefore() 131 | { 132 | $question = $this->findQuestion(3); 133 | $question->moveBefore(2); 134 | 135 | $this->checkQuestion($question, 2, [1, 3, 4, 5]); 136 | } 137 | 138 | public function testMoveAfter() 139 | { 140 | $question = $this->findQuestion(3); 141 | $question->moveAfter(4); 142 | 143 | $this->checkQuestion($question, 4, [1, 2, 3, 5]); 144 | } 145 | 146 | public function testMoveBack() 147 | { 148 | $question = $this->findQuestion(3); 149 | $question->moveBack(); 150 | 151 | $this->checkQuestion($question, 4, [1, 2, 3, 5]); 152 | } 153 | 154 | public function testMoveForward() 155 | { 156 | $question = $this->findQuestion(3); 157 | $question->moveForward(); 158 | 159 | $this->checkQuestion($question, 2, [1, 3, 4, 5]); 160 | } 161 | 162 | public function testMoveAsFirst() 163 | { 164 | $question = $this->findQuestion(3); 165 | $question->moveAsFirst(); 166 | 167 | $this->checkQuestion($question, 1, [2, 3, 4, 5]); 168 | } 169 | 170 | public function testMoveAsLast() 171 | { 172 | $question = $this->findQuestion(3); 173 | $question->moveAsLast(); 174 | 175 | $this->checkQuestion($question, 5, [1, 2, 3, 4]); 176 | } 177 | 178 | /** 179 | * @param integer $testId 180 | * @param array $ids 181 | */ 182 | protected function checkOtherTestQuestions($testId, $ids = [1, 2, 3, 4, 5]) 183 | { 184 | $questions = ContinuousQuestion::find() 185 | ->where(['test_id' => $testId]) 186 | ->orderBy(['id' => SORT_ASC]) 187 | ->all(); 188 | $questionsSort = ArrayHelper::getColumn($questions, 'sort'); 189 | 190 | $this->assertEquals($ids, $questionsSort, 'Other scope models sort matches'); 191 | } 192 | 193 | 194 | /** 195 | * @param integer $id 196 | * @return ContinuousQuestion 197 | */ 198 | protected function findQuestion($id) 199 | { 200 | return ContinuousQuestion::findOne($id); 201 | } 202 | 203 | /** 204 | * @param ContinuousQuestion $question 205 | * @param null|integer $questionSort 206 | * @param array $otherQuestionsSort 207 | * @param boolean $checkOtherTestQuestions 208 | */ 209 | protected function checkQuestion($question, $questionSort = null, $otherQuestionsSort, $checkOtherTestQuestions = true) 210 | { 211 | if ($questionSort) { 212 | $this->assertEquals($questionSort, $question->sort, 'Model sort matches'); 213 | } 214 | 215 | $this->assertEquals($otherQuestionsSort, $question->getOtherQuestionsSort(), 'Other models sort matches'); 216 | 217 | if ($checkOtherTestQuestions) { 218 | $this->checkOtherTestQuestions(2); 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /tests/unit/IntervalTest.php: -------------------------------------------------------------------------------- 1 | 1, 19 | 'content' => 'Do you have a car?', 20 | ]); 21 | $question->save(); 22 | 23 | $this->checkQuestion($question, 6000, [1000, 2000, 3000, 4000, 5000]); 24 | } 25 | 26 | public function testCreateNotActive() 27 | { 28 | $question = new IntervalQuestion([ 29 | 'test_id' => 1, 30 | 'content' => 'Do you have a car?', 31 | 'is_active' => false, 32 | ]); 33 | $question->save(); 34 | 35 | $this->checkQuestion($question, 0, [1000, 2000, 3000, 4000, 5000]); 36 | } 37 | 38 | public function testCreatePrependAdded() 39 | { 40 | $question = new IntervalQuestion(); 41 | $behaviorConfig = $question->behaviors()['sort']; 42 | $behaviorConfig['prependAdded'] = true; 43 | $question->detachBehavior('sort'); 44 | $question->attachBehavior('sort', $behaviorConfig); 45 | $question->setAttributes([ 46 | 'test_id' => 1, 47 | 'content' => 'Do you have a car?', 48 | ], false); 49 | $question->save(); 50 | 51 | $this->checkQuestion($question, 500, [1000, 2000, 3000, 4000, 5000]); 52 | } 53 | 54 | public function testUpdate() 55 | { 56 | $question = $this->findQuestion(3); 57 | $question->is_active = false; 58 | $question->save(); 59 | 60 | $this->checkQuestion($question, 0, [1000, 2000, 4000, 5000]); 61 | 62 | $question->is_active = true; 63 | $question->save(); 64 | 65 | $this->checkQuestion($question, 6000, [1000, 2000, 4000, 5000]); 66 | 67 | $question->runBeforeSave = true; 68 | $question->save(); 69 | 70 | $this->checkQuestion($question, 0, [1000, 2000, 4000, 5000]); 71 | 72 | $question->save(); 73 | 74 | $this->checkQuestion($question, 6000, [1000, 2000, 4000, 5000]); 75 | } 76 | 77 | public function testDelete() 78 | { 79 | $question = $this->findQuestion(3); 80 | $question->delete(); 81 | 82 | $this->checkQuestion($question, null, [1000, 2000, 4000, 5000]); 83 | } 84 | 85 | public function testMoveToOtherScope() 86 | { 87 | $question = $this->findQuestion(3); 88 | $question->test_id = 2; 89 | $question->save(); 90 | 91 | $this->checkQuestion($question, 6000, [1000, 2000, 3000, 4000, 5000], false); 92 | $this->checkOtherTestQuestions(1, [1000, 2000, 4000, 5000]); 93 | } 94 | 95 | public function testMoveToPosition() 96 | { 97 | $question = $this->findQuestion(3); 98 | $question->moveToPosition(2); 99 | 100 | $this->checkQuestion($question, 1500, [1000, 2000, 4000, 5000]); 101 | } 102 | 103 | public function testMoveBefore() 104 | { 105 | $question = $this->findQuestion(3); 106 | $question->moveBefore(2); 107 | 108 | $this->checkQuestion($question, 1500, [1000, 2000, 4000, 5000]); 109 | } 110 | 111 | public function testMoveAfter() 112 | { 113 | $question = $this->findQuestion(3); 114 | $question->moveAfter(4); 115 | 116 | $this->checkQuestion($question, 4500, [1000, 2000, 4000, 5000]); 117 | } 118 | 119 | public function testMoveBack() 120 | { 121 | $question = $this->findQuestion(3); 122 | $question->moveBack(); 123 | 124 | $this->checkQuestion($question, 4500, [1000, 2000, 4000, 5000]); 125 | } 126 | 127 | public function testMoveForward() 128 | { 129 | $question = $this->findQuestion(3); 130 | $question->moveForward(); 131 | 132 | $this->checkQuestion($question, 1500, [1000, 2000, 4000, 5000]); 133 | } 134 | 135 | public function testMoveAsFirst() 136 | { 137 | $question = $this->findQuestion(3); 138 | $question->moveAsFirst(); 139 | 140 | $this->checkQuestion($question, 500, [1000, 2000, 4000, 5000]); 141 | } 142 | 143 | public function testMoveAsLast() 144 | { 145 | $question = $this->findQuestion(3); 146 | $question->moveAsLast(); 147 | 148 | $this->checkQuestion($question, 6000, [1000, 2000, 4000, 5000]); 149 | } 150 | 151 | /** 152 | * @param integer $testId 153 | * @param array $ids 154 | */ 155 | protected function checkOtherTestQuestions($testId, $ids = [1000, 2000, 3000, 4000, 5000]) 156 | { 157 | $questions = IntervalQuestion::find() 158 | ->where(['test_id' => $testId]) 159 | ->orderBy(['id' => SORT_ASC]) 160 | ->all(); 161 | $questionsSort = ArrayHelper::getColumn($questions, 'sort'); 162 | 163 | $this->assertEquals($ids, $questionsSort, 'Other scope models sort matches'); 164 | } 165 | 166 | 167 | /** 168 | * @param integer $id 169 | * @return IntervalQuestion 170 | */ 171 | protected function findQuestion($id) 172 | { 173 | return IntervalQuestion::findOne($id); 174 | } 175 | 176 | /** 177 | * @param IntervalQuestion $question 178 | * @param null|integer $questionSort 179 | * @param array $otherQuestionsSort 180 | * @param boolean $checkOtherTestQuestions 181 | */ 182 | protected function checkQuestion($question, $questionSort = null, $otherQuestionsSort, $checkOtherTestQuestions = true) 183 | { 184 | if ($questionSort) { 185 | $this->assertEquals($questionSort, $question->sort, 'Model sort matches'); 186 | } 187 | 188 | $this->assertEquals($otherQuestionsSort, $question->getOtherQuestionsSort(), 'Other models sort matches'); 189 | 190 | if ($checkOtherTestQuestions) { 191 | $this->checkOtherTestQuestions(2); 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /tests/unit/_bootstrap.php: -------------------------------------------------------------------------------- 1 | 'app-console', 4 | 'class' => 'yii\console\Application', 5 | 'basePath' => \Yii::getAlias('@tests'), 6 | 'runtimePath' => \Yii::getAlias('@tests/_output'), 7 | 'bootstrap' => [], 8 | 'components' => [ 9 | 'db' => [ 10 | 'class' => '\yii\db\Connection', 11 | 'dsn' => 'sqlite:' . \Yii::getAlias('@tests/_output/temp.db'), 12 | 'username' => '', 13 | 'password' => '', 14 | ], 15 | ], 16 | ]; 17 | --------------------------------------------------------------------------------