/yii2-sortable-behavior

Sort ActiveRecords and related records

Primary LanguagePHPMIT LicenseMIT

Yii2 Sortable

Latest Stable Version Total Downloads License

This package contains five classes to handle the sorting of ActiveRecords:

  • SortableGridView - extended GridView widget;
  • SortableListView - extended ListView widget;
  • Sortable - ActiveRecord Behavior to handle the sorting of the records themselves, or of the one-to-many related records;
  • PivotRecord - base class for the ActiveRecord of the pivot table in a many-to-many relation.
  • MMSortable - ActiveRecord Behavior to handle the sorting of many-to-many related records.

The previous version of Sortable was dependent on jQuery. It now has no dependencies at all. It should work in all modern desktop browsers. At the time of writing the new HTML Drag and Drop, and thus Sortable, only works for few mobile browsers.

The old jQuery-dependent widgets are still available. Some may prefer their esthetics.

A demonstration of the Sortable suit is here.

Installation

The preferred way to install Sortable is through Composer. Either add the following to the require section of your composer.json file:

"sjaakp/yii2-sortable-behavior": "*"

Or run:

composer require sjaakp/yii2-sortable-behavior "*"

You can manually install Sortable by downloading the source in ZIP-format.

SortableGridView and SortableListView

These widgets are derived from the standard GridView and ListView classes, but have one extra capability: the items can be moved to another position by means of drag and drop (using HTML Drag and Drop functionality). If an item is dropped on a new position, a message is posted with the following data:

  • key: the value of the item's primary key;
  • pos: the zero-indexed new position of the item.

SortableGridView and SortableListView have one configurable property:

orderUrl

array|string. The URL which is called after a sorting operation. The format is that of yii\helpers\Url::toRoute.

SortableGridView and SortableListView don't go together with the Pjax-widget. However, Sortable is not very usable with paged data-widgets anyway.

Sortable

With this Behavior, an ActiveRecord becomes 'sortable'. It has one configurable property and one extra method:

orderAttribute

string|array. The order attribute(s) of the ActiveRecord.

This can take the following values:

  • string - the order attribute name;
  • array of:
    • string - the order attribute name,
    • foreignKeyName => orderAttrName - limit ordering to ActiveRecords with the same foreign key value, i.e. of the same owner.

Default is "ord".

order()

public function order( $newPosition, $foreignKeyName = null )

This method puts the owner on the new position $newPosition by manipulating the order attribute. The order attribute is a zero-indexed, contiguous integer.

If $foreignKeyName is null (default) all the records are ordered.

If it is a string, the ordering is restricted to the records with the same value of $foreignKeyName. $foreignKeyName must be a key in orderAttribute. This comes in handy with one-to-many relations.


Usage scenario 1

Simple sorting

Suppose we have a very simple table of movie titles:

CREATE TABLE movie (
  id int(10) unsigned NOT NULL AUTO_INCREMENT,
  ord int(10) unsigned NOT NULL,
  title tinytext NOT NULL,
  PRIMARY KEY (id)
)

Where ord will be our order attribute.

We can make the Movie ActiveRecord sortable like this:

class Movie extends ActiveRecord
{
    public function behaviors( ) {
	    return [
	        [
	            'class' => 'sjaakp\sortable\Sortable',
	        ],
	    ];
	}
	...
}

In the controller we define an index action and an order action:

class MovieController extends Controller
{
	...
    public function actionIndex( )
	{
	    $dataProvider = new ActiveDataProvider( [
	        'query' => Movie::find( )->orderBy( 'ord' ),	// notice the orderBy clause
	        'sort' => false,
	        'pagination' => false
	    ] );

	    return $this->render( 'index', [
	        'dataProvider' => $dataProvider,
	    ] );
	}
	...
    public function actionOrder( )   {
        $post = Yii::$app->request->post( );
        if (isset( $post['key'], $post['pos'] ))   {
            $this->findModel( $post['key'] )->order( $post['pos'] );
        }
    }
	...
}

In the index view, we use a SortableGridView:

use sjaakp\sortable\SortableGridView;
...
<?= SortableGridView::widget( [
    'dataProvider' => $dataProvider,
    'orderUrl' => ['order'],
    'columns' => [
		...
        'title:ntext',
		...
    ],
	...
] ); ?>

And bingo! The list of movie titles is now sortable by drag and drop.


Usage scenario 2

One-to-many sorting

Suppose we also have a list of directors. Each director has many movies, each movie belongs to one director (thinking of the Coen brothers, I know this is not necessarily true in reality).

We add two columns to our movie table:

CREATE TABLE movie (
  id int(10) unsigned NOT NULL AUTO_INCREMENT,
  ord int(10) unsigned NOT NULL,
  title tinytext NOT NULL,
  director_id int(10) unsigned NOT NULL,
  director_ord int(10) unsigned NOT NULL,
  PRIMARY KEY (id)
)

Where director_ord is the order attribute just for movies belonging to the same director.

In the Director model we define a one-to-many relation, like we would normally do (notice the orderBy clause):

class Director extends ActiveRecord	{
	...
	public function getMovies( ) {
    	return $this->hasMany( Movie::class, ['director_id' => 'id'] )
    	    ->orderBy( 'director_ord' );
	}
	...
}

The Movie model is sortable like before, but with another orderAttribute:

class Movie extends ActiveRecord
{
    public function behaviors( ) {
	    return [
	        [
	            'class' => 'sjaakp\sortable\Sortable',
                'orderAttribute' => [
                    'director_id' => 'director_ord'
	            ]
	        ],
	    ];
	}
	...
}

This time, DirectorController sports an extra action:

class DirectorController extends Controller
{
	...
    public function actionMovieOrder( )   {
        $post = Yii::$app->request->post( );
        if (isset( $post['key'], $post['pos'] ))   {
            $movie = Movie::findOne( $post['key'] );
            if ($movie) $movie->order( $post['pos'], 'director_id' );
        }
    }
	...
}

Let's now use a SortableGridView to display all the movies of the director in director/view:

use sjaakp\sortable\SortableGridView;
...
$movies = new ActiveDataProvider( [
	'query' => $model->getMovies( ),  // Do not use $model->movies, it returns array of Movies in stead of an ActiveQueryInterface
	'sort' => false,
	'pagination' => false
] );
...
<h1><?= Html::encode( $model->name ) ?></h1>
...
<?= SortableGridView::widget( [
    'dataProvider' => $movies,
    'orderUrl' => ['movie-order'],
    'columns' => [
		...
        'title:ntext',
		...
    ],
	...
] ); ?>

Now each director's view shows a sortable list of his or her movies.

It's easy to combine Usage scenario's 1 and 2, so that all movies are sortable in movie/index and only the director's movies in director/view. Just initialize Movie's Sortable behavior like this:

class Movie extends ActiveRecord
{
    public function behaviors( ) {
	    return [
	        [
	            'class' => 'sjaakp\sortable\Sortable',
                'orderAttribute' => [
					'ord',
                    'director_id' => 'director_ord'
	            ]
	        ],
	    ];
	}
	...
}

PivotRecord

This is the base ActiveRecord for the pivot table of two sortable Models in a many-to-many relation.

The ordering information is stored in the pivot table as well.

A pivot table might look something like this:

CREATE TABLE actor_movie (
  actor_id int(10) unsigned NOT NULL,	# actor's primary key
  movie_id int(10) unsigned NOT NULL,	# movie's primary key
  actor_ord int(10) unsigned NOT NULL,	# actor's order
  movie_ord int(10) unsigned NOT NULL,	# movie's order
  PRIMARY KEY (actor_id,movie_id),
)

Using best practices it means:

  • the table name is a concatenation of the two related table names in lexicographic order, separated by an underscore ('_');
  • the primary key column names consist of the related table name followed by '_id';
  • the order column names consist of the related table name followed by '_ord';

Of course, it would be wise to add some indexes.

A concrete pivot record has to be derived from PivotRecord. Two static functions must be defined in the derived class:

aClass() and bClass()

protected static function aClass( ) protected static function bClass( )

These static member functions should return the fully qualified class names of the related Models.

aClass and bClass are completely equivalent. PivotRecord is in any respect a symmetric class.

A complete definition of a pivot record might look like this:

namespace app\models;
use sjaakp\sortable\PivotRecord;

class MovieActor extends PivotRecord    {

    protected static function aClass( )   {
        return Movie::class;
    }

    protected static function bClass( )   {
        return Actor::class;
    }
}

Notice that you can define some other static values as well, for special cases. Refer to the source code if you need this.

A PivotRecord-derived class has the following extra functions.

getAs() and getBs()

public static function getAs( ActiveRecord $b )

Get the ordered records of classA belonging to classB $b.

public static function getBs( ActiveRecord $a )

Get the ordered records of classB belonging to classA $a.

The result is returned as an ActiveQuery, which can be modified further, or used as source of an ActiveDataProvider.

Notice these are static functions, not referring to any instantiation of the PivotRecord-derived class.

orderA() and orderB()

public function orderA( $newPosition )

Place classA at $newPosition in the list of all classA's belonging to classB.

public function orderB( $newPosition )

Place classB at $newPosition in the list of all classB's belonging to classA.

These are member functions. The id's of classA and classB are stored in the current PivotRecord.

MMSortable

This is a Behavior of both partner ActiveRecords in a many-to-many relations. PivotRecord relies on it. MMSortable performs some housekeeping and has no (interesting) member functions. However, two properties have to be configured:

pivotClass

string. The fully classified class name of the pivot class (the PivotRecord-derived class).

pivotIdAttr

string. The attribute name of the owner's id in the pivot class. If this is not set, it will be derived from the owner's class name; for instance: if the owner is class Movie, $pivotIdAttr will be "movie_id".


Usage scenario 3

Many-to-many sorting

Apart from our movie table, we also have an actor table. They are linked via an actor_movie pivot table: each movie can have many actors, and each actor can have many movies.

First, we define a pivot class, like so:

namespace app\models;
use sjaakp\sortable\PivotRecord;

class MovieActor extends PivotRecord    {

    protected static function aClass( )   {
        return Movie::class;
    }

    protected static function bClass( )   {
        return Actor::class;
    }
}

Then, we make sure that both Movie and Actor have a MMSortable Behavior:

class Movie extends ActiveRecord	{
	...
    public function behaviors( ) {
        return [
            [
                'class' => 'sjaakp\sortable\MMSortable',
                'pivotClass' => MovieActor::class
            ]
        ];
    }
	...
}

For convenience, we add a very simple member function to Movie:

class Movie extends ActiveRecord	{
	...
    public function getActors( ) {
        return MovieActor::getBs( $this );
    }
	...
}

Define an order-actor-action in MovieController:

class MovieController extends Controller
{
	...
    public function actionOrderActor( $id )   {
        $post = Yii::$app->request->post( );
        if (isset( $post['key'], $post['pos'] ))   {
            $piv = MovieActor::find( )->where( [
                'movie_id' => $id,
                'actor_id' => $post['key']
            ] )->one( );
            $piv->orderB( $post['pos'] );
        }
    }
	...
}

Now, in movie/view, we can display a SortableGridView with all the actors appearing in the movie.

use sjaakp\sortable\SortableGridView;
...
$actors = new ActiveDataProvider( [
	'query' => $model->getActors( ),
	'sort' => false,
	'pagination' => false
] );
...
<h1><?= Html::encode( $model->title ) ?></h1>
...
<?= SortableGridView::widget( [
    'dataProvider' => $actors,
    'orderUrl' => ['order-actor', 'id' => $model->getPrimaryKey()],
    'columns' => [
		...
        'name:ntext',
		...
    ],
	...
] ); ?>

Sortable with jQuery

The previous version of Sortable (1.0) used jQuery Draggable and Sortable. The old jQuery-widgets are still available as SortableGridViewJquery and SortableListViewJquery. They are exchangeable with their non-jQuery counterparts.

You may prefer the esthetics of the jQuery variants. Also, the new HTML Drag and Drop may not work for all mobile browsers.

SortableGridViewJquery and SortableListViewJquery have two extra configurable properties:

sortOptions

array. The options for the jQuery sortable object. See https://api.jqueryui.com/sortable/.

Notice that the options 'items', 'helper', and 'update' will be overwritten.

Default: [] (empty array).

sortAxis

boolean|string The 'axis' option for the jQuery sortable. If false, it is not set. Default: 'y'.

For compatibility, SortableGridView and SortableListView have these options as well, but they are not functional.

Thanks

  • mike-kramer (sortAxis option)
  • menshakov (use updateAttributes)
  • robsch (subtle order bug)