├── tests ├── unit │ ├── _bootstrap.php │ ├── _config.php │ ├── IntervalTest.php │ └── ContinuousTest.php ├── unit.suite.yml ├── _support │ └── UnitHelper.php ├── _bootstrap.php └── _data │ ├── Category.php │ ├── IntervalQuestion.php │ ├── ContinuousQuestion.php │ └── dump.sql ├── .gitignore ├── codeception.yml ├── src ├── assets │ ├── src │ │ ├── css │ │ │ └── sortable-column.css │ │ └── js │ │ │ └── sortable-column.js │ └── SortableColumnAsset.php ├── messages │ ├── ru │ │ └── sortable.php │ └── el │ │ └── sortable.php ├── widgets │ └── Sortable.php ├── controllers │ └── SortController.php ├── behaviors │ ├── numerical │ │ ├── ContinuousNumericalSortableBehavior.php │ │ ├── IntervalNumericalSortableBehavior.php │ │ └── BaseNumericalSortableBehavior.php │ └── BaseSortableBehavior.php └── grid │ └── SortableColumn.php ├── composer.json ├── LICENSE.md └── README.md /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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/_data/Category.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/ContinuousTest.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 | -------------------------------------------------------------------------------- /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/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 = '