├── .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 | [](https://packagist.org/packages/arogachev/yii2-sortable)
7 | [](https://packagist.org/packages/arogachev/yii2-sortable)
8 | [](https://packagist.org/packages/arogachev/yii2-sortable)
9 | [](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 | = GridView::widget([
246 | // Other configuration
247 | 'columns' => [
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 |
--------------------------------------------------------------------------------