В данной статье мой вариант расширения для фреймворка Yii2 работающего с древовидной структурой данных хранимых в базе данных по методу Nested Sets, которое я создал на базе другого расширения ASlatius/yii2-nestable. Ссылка на GitHub.

Что я добавил/изменил – в основном адаптировал его для использования со стандартным виджетом Yii2 GridView. Теперь вместо такой страницы:



получаем:



Элементы (в данном примере пункты меню) можно перемещать перетаскиванием мышью (Drag-and-drop), что сразу (AJAX) сохраняет их новое положение в базе данных. Без визуального отображения структуры и перемещения путем перетаскивания было бы сложно, т.к. в базе данных хранится числовое обозначение текущего местоположения элемента, которое вычисляется специальным методом.

Расширение позволяет изменять структуру и вложенность элементов, что можно использовать в древовидных структурах, например в рубриках, сложном меню для сайта и тд. Такие структуры, со множеством вложенных элементов, создаются по методу Nested Sets, подробнее про который можно почитать, например, тут.

Для использования метода Nested Sets при работе с древовидными структурами, а именно (выборке, вставке и тд.) в фреймворке yii2 популярностью пользуется расширение yii2-nested-sets, ссылка на GitHub. Оно используется и здесь так же – прописано в качестве зависимостей в файле composer.json и будет установлено автоматически.



Установка и настройка.

composer require klisl/yii2-nested-sets-drag-and-drop

Далее создаем таблицу в базе данных с древовидной структурой и подключаем расширение для работы с ним согласно описания:
https://github.com/creocoder/yii2-nested-sets
Там в качестве примера приведена миграция для создания меню сайта. Вы можете добавлять любые свои поля в таблицу, главное сохранить основу:
  • id
  • lft
  • rgt
  • depth
  • name
по которым вычисляется расположение нужного элемента. Поле name можно переименовать, но тогда его нужно будет переопределить в коде.
В своем примере я тоже буду создавать меню сайта.

После этого необходимо создать модель, для этого можно воспользоваться gii генератором.

Далее, как следует из описания установки расширения yii2-nested-sets, подключаем поведение и два небольших метода в созданную модель и потом создаем отдельный файл, который будет добавлять свое поведение в класс ActiveQuery. Например создадим его в common\models, с указанием пространства имен:
namespace common\models;
Кроме этого в модели нужно убрать проверку на заполнение полей 'lft', 'rgt', 'depth' из метода rules. Т.е. не применять к ним правило «required».


После этого воспользуемся gii, а именно CRUD Generator. Таким образом автоматически у нас создадутся все нужные действия контроллера, такие как просмотр/создание/редактирование/удаление элементов меню и соответствующие файлы представления. Всем этим действиям будут соответствовать стандартные, сгенерированные автоматически виды, кроме основного – index. Например это будет административная часть (backend) для работы с меню сайта.

Изменяем действие index у контроллера (у меня это MenuController), меняем
public function actionIndex()
{
    $searchModel = new MenuSearch();
    $dataProvider = $searchModel->search(Yii::$app->request->queryParams);

      return $this->render('index', [
        'searchModel' => $searchModel,
        'dataProvider' => $dataProvider,
    ]);
 }
на
public function actionIndex()
{
    //объект ActiveQuery содержащий данные для дерева. depth = 0 - корень.
    $query = Menu::find()->where(['depth' => '0']);

    return $this->render('index', [
        'query' => $query,
    ]);
}

Т.к. searchModel не используется, удаляем данный файл, который был сгенерирован (у меня это MenuSearch).

Что еще нужно поменять в контроллере – метод actionCreate, т.к. при использовании Nested Sets и создании нового элемента, значения полей lft, rgt, depth должны вычисляться расширением. А вставка новых элементов будет происходить в конец корневого, с дальнейшей корректировкой путем перетаскивания.
public function actionCreate()
{
    /** @var  $model Menu|NestedSetsBehavior */
    $model = new Menu ();

    //Поиск корневого элемента
    $root = $model->find()->where(['depth' => '0'])->one();

    if ($model->load(Yii::$app->request->post())) {
        //Если нет корневого элемента (пустая таблица)
        if (!$root) {
            /** @var  $rootModel Menu|NestedSetsBehavior */
            $rootModel = new Menu(['name' => 'root', 'url' => '/']);
            $rootModel->makeRoot(); //делаем корневой
            $model->appendTo($rootModel);
        } else {
            $model->appendTo($root); //вставляем в конец корневого элемента
        }

        if ($model->save()){
            return $this->redirect('index');
        }
    }

    return $this->render('create', [
        'model' => $model,
        'root' => $root
    ]);
}

Тут сначала проверяется наличие корневого элемента (у которого 'depth' равно 0) и если его нет – сначала создает корневой элемент с названием 'root', а далее привязывает все к нему. Это требование метода Nested Sets – всегда должен быть корневой элемент.

И еще в контроллере добавляем отдельное действие nodeMove из расширения путем добавления метода actions:
public function actions() {
    return [
        'nodeMove' => [
            'class' => 'klisl\nestable\NodeMoveAction',
            'modelName' => Menu::className(),
        ],
    ];
}

Про отдельные действия я писал тут: http://klisl.com/individual_actions.html
Теперь при перемещении элементов меню будет вызываться метод nodeMove расширения для сохранения новой позиции в базе данных используя AJAX.


Теперь займемся файлами представлений. Прежде всего в backend/views/menu/_form.php удаляем строки
<?= $form->field($model, 'lft')->textInput() ?>
<?= $form->field($model, 'rgt')->textInput() ?>
<?= $form->field($model, 'depth')->textInput() ?>

т.к. значения данных полей не нужно корректировать вручную, это будет делать расширение.

И меняем полностью содержимое файла backend/views/menu/index.php на
<?php

use yii\helpers\Html;
use klisl\nestable\Nestable;
use yii\helpers\Url;

/* @var $query \yii\db\ActiveQuery */

$this->title = 'Menus';
$this->params['breadcrumbs'][] = $this->title;
?>

<div class="menu-index">

    <h1><?= Html::encode($this->title) ?></h1>
    <p><?= Html::a('Create new item', ['create'], ['class' => 'btn btn-default']) ?></p>

    <?= Nestable::widget([
        'type' => Nestable::TYPE_WITH_HANDLE,
        'query' => $query,
        'modelOptions' => [
            'name' => 'name', //поле из БД с названием элемента (отображается в дереве)
        ],
        'pluginEvents' => [
            'change' => 'function(e) {}', //js событие при выборе элемента
        ],
        'pluginOptions' => [
            'maxDepth' => 10, //максимальное кол-во уровней вложенности
        ],
        'update' => Url::to(['menu/update']), //действие по обновлению
        'delete' => Url::to(['menu/delete']), //действие по удалению
        'viewItem' => Url::to(['menu/view']), //действие по удалению
    ]);
    ?>

    <div id="nestable-menu">
        <button class="btn btn-default" type="button" data-action="expand-all">Expand All</button>
        <button class="btn btn-default" type="button" data-action="collapse-all">Collapse All</button>
    </div>

</div>
Все готово!