[Из песочницы] Мультиязычные деревья в Yii2 на примере создания модуля меню

habr.png

Вступление

Многие начинающие веб-разработчики сталкиваются с необходимостью создания меню, каталогов или рубрикаторов для своего проекта на Yii2, которые бы имели иерархическую структуру, но при этом поддерживали мультиязычность. Задача довольно простая, но не совсем очевидная в рамках данного фреймворка. Есть большое количество готовых расширений для создания древовидных структур (меню, каталогов итд.), но довольно сложно найти решение, которое бы поддерживало полноценную работу с несколькими языками. Причём речь тут идёт не о переводе интерфейса штатными средствами фреймворка, а про хранение данных в базе на нескольких языках. Также достаточно сложно найти удобный и полностью работоспособный виджет для управления деревом, который мог бы также работать с многоязычным контентом без сложных манипуляций с кодом.

Я хотел бы поделиться рецептом того, как можно создавать подобные модули на примере реализации модуля меню. Для примера я буду использовать шаблон приложения Yii2 App Basic, но вы можете адаптировать всё под свой шаблон, если он отличается от базового.


Подготовка

Для реализации задачи нам понадобится несколько замечательных расширений, а именно:


  • Adjacency List — для хранения древовидной структуры
    меню в БД;
  • Yii2 Bootstrap Treeview — виджет
    для удобного отображения меню в виде дерева;
  • Translateable Behavior — поведение для
    поддержки мультиязычности в моделях;

Устанавливаем данные расширения через composer:

composer require paulzi/yii2-adjacency-list
composer require execut/yii2-widget-bootstraptreeview
composer require creocoder/yii2-translateable

Для реализации меню в виде модуля, с помощью Gii генератора (либо вручную) создаём новый модуль menu и подключаем его в настройках приложения.

В проекте также должен быть настроен механизм переключения языков. Я предпочитаю использовать вот это расширение для Yii2.


Создание моделей

Для хранения меню (либо другой сущности, которая имеет мультиязычность) в базе данных нам необходимо создать две таблицы. На самом деле, для хранения мультиязычных данных могут использоваться разные методики, но вариант с двумя таблицами, одна из которых хранит саму сущность, а вторая — её языковые вариации, мне нравится больше остальных. Для создания таблиц удобно использовать миграции. Вот пример такой миграции:


m180819_083502_menu_init.php
db = 'db';
        parent::init();
    }

    public function safeUp()
    {
        $tableOptions = 'ENGINE=InnoDB';
        $this->createTable('{{%menu}}', [
            'id'=> $this->primaryKey(11),
            'parent_id'=> $this->integer(11)->null()->defaultValue(null),
            'link'=> $this->string(255)->notNull()->defaultValue('#'),
            'link_attributes'=> $this->text()->notNull(),
            'icon_class'=> $this->string(255)->notNull(),
            'sort'=> $this->integer(11)->notNull()->defaultValue(0),
            'status'=> $this->tinyInteger(1)->notNull()->defaultValue(1),
        ], $tableOptions);
        $this->createIndex('parent_sort', '{{%menu}}', ['parent_id','sort'], false);
        $this->createTable('{{%menu_lang}}', [
            'owner_id'=> $this->integer(11)->notNull(),
            'language'=> $this->string(2)->notNull(),
            'name'=> $this->string(255)->notNull(),
            'title'=> $this->text()->notNull(),
        ], $tableOptions);
        $this->addPrimaryKey('pk_on_menu_lang', '{{%menu_lang}}', ['owner_id','language']);
        $this->addForeignKey(
            'fk_menu_lang_owner_id',
            '{{%menu_lang}}',
            'owner_id',
            '{{%menu}}',
            'id',
            'CASCADE',
            'CASCADE'
        );
        // Insert sample data
        $this->batchInsert(
            '{{%menu}}',
            ['id', 'parent_id', 'link', 'link_attributes', 'icon_class', 'sort', 'status'],
            [
                [
                    'id' => '1',
                    'parent_id' => null,
                    'link' => '#',
                    'link_attributes' => '',
                    'icon_class' => '',
                    'sort' => '0',
                    'status' => '0',
                ],
                [
                    'id' => '2',
                    'parent_id' => '1',
                    'link' => '/',
                    'link_attributes' => '',
                    'icon_class' => 'fa fa-home',
                    'sort' => '0',
                    'status' => '1',
                ],
            ]
        );
        $this->batchInsert(
            '{{%menu_lang}}',
            ['owner_id', 'language', 'name', 'title'],
            [
                [
                    'owner_id' => '1',
                    'language' => 'ru',
                    'name' => 'Главное меню',
                    'title' => '',
                ],
                [
                    'owner_id' => '1',
                    'language' => 'en',
                    'name' => 'Main menu',
                    'title' => '',
                ],
                [
                    'owner_id' => '2',
                    'language' => 'ru',
                    'name' => 'Главная',
                    'title' => 'Главная страница сайта',
                ],
                [
                    'owner_id' => '2',
                    'language' => 'en',
                    'name' => 'Home',
                    'title' => 'Site homepage',
                ],
            ]
        );
    }

    public function safeDown()
    {
        $this->truncateTable('{{%menu}} CASCADE');
        $this->dropForeignKey('fk_menu_lang_owner_id', '{{%menu_lang}}');
        $this->dropTable('{{%menu}}');
        $this->dropPrimaryKey('pk_on_menu_lang', '{{%menu_lang}}');
        $this->dropTable('{{%menu_lang}}');
    }
}

Поместим данный файл миграции в папку /migrations нашего проекта, и
выполним в консоли команду:

php yii migrate

После того, как мы создали необходимые таблицы и добавили в них новое меню с помощью миграции, нам нужно создать модели. Так как в проекте мультиязычность и деревья могут встречаться не только в меню, но и в других сущностях (например, страницы сайта), я предлагаю вынести методы, которые реализуют механизм мультиязычности и организацию дерева, в отдельные трейты, чтобы в дальнейшем мы могли легко использовать их в других моделях без дублирования кода. Создадим в корне приложения папочку traits (если её там ещё нет) и поместим туда два файла:


LangTrait.php
 [
                'class' => TranslateableBehavior::class,
                'translationAttributes' => $translationAttributes,
                'translationRelation' => 'translations',
                'translationLanguageAttribute' => 'language',
            ],
        ];
    }

    public function transactions()
    {
        return [
            self::SCENARIO_DEFAULT => self::OP_INSERT | self::OP_UPDATE,
        ];
    }

    public function getLang()
    {
        return $this->hasOne(self::langClass(), ['owner_id' => 'id'])->where([self::langTableName() . '.language' => Yii::$app->language]);
    }

    public function getTranslations()
    {
        return $this->hasMany(self::langClass(), ['owner_id' => 'id']);
    }

}


TreeTrait.php
 [
                'class' => AdjacencyListBehavior::class,
                'parentAttribute' => 'parent_id',
                'sortable' => [
                    'step' => 10,
                ],
                'checkLoop' => false,
                'parentsJoinLevels' => 5,
                'childrenJoinLevels' => 5,
            ],
        ];
    }

    public static function find()
    {
        $queryClass = self::getQueryClass();
        return new $queryClass(get_called_class());
    }

    public static function listTree($node = null, $level = 1, $nameAttribute = 'name', $prefix = '-->')
    {
        $result = [];
        if (!$node) {
            $node = self::find()->roots()->one()->populateTree();
        }
        if ($node->isRoot()) {
            $result[$node['id']] = mb_strtoupper($node[$nameAttribute ?: 'slug']);
        }
        if ($node['children']) {
            foreach ($node['children'] as $child) {
                $result[$child['id']] = str_repeat($prefix, $level) . $child[$nameAttribute];
                $result = $result + self::listTree($child, $level + 1, $nameAttribute);
            }
        }
        return $result;
    }

    public static function treeViewData($node = null)
    {
        if ($node === null) {
            $node = self::find()->roots()->one()->populateTree();
        }
        $result = null;
        $items = null;
        $children = null;
        if ($node['children']) {
            foreach ($node['children'] as $child) {
                $items[] = self::treeViewData($child);
            }
            $children = call_user_func_array('array_merge', $items);
        }
        $result[] = [
            'text' => Html::a($node['lang']['name'] ?: $node['id'], ['update', 'id' => $node['id']], ['title' => Yii::t('app', 'Редактировать элемент')]),
            'tags' => [
                Html::a(
                    '',
                    ['move-down', 'id' => $node['id']],
                    ['title' => Yii::t('app', 'Передвинуть вниз')]
                ),
                Html::a(
                    '',
                    ['move-up', 'id' => $node['id']],
                    ['title' => Yii::t('app', 'Передвинуть вверх')]
                )
            ],
            'backColor' => $node['status'] == 0 ? '#ccc' : '#fff',
            'selectable' => false,
            'nodes' => $children,
        ];
        return $result;
    }
}

Теперь создадим непосредственно сами модели для работы с меню, в которых подключим трейты для дерева и мультиязычности. Модели помещаем в /modules/menu/models:


Menu.php
treeBehaviors(),
            $this->langBehaviors(['name', 'title'])
        );
    }

    public static function tableName()
    {
        return 'menu';
    }

    public function rules()
    {
        return [
            [['parent_id', 'sort', 'status'], 'integer'],
            [['link', 'icon_class'], 'string', 'max' => 255],
            [['link_attributes'], 'string'],
            [['link'], 'default', 'value' => '#'],
            [['link_attributes', 'icon_class'], 'default', 'value' => ''],
            [['parent_id'], 'exist', 'skipOnError' => true, 'targetClass' => self::class, 'targetAttribute' => ['parent_id' => 'id']],
        ];
    }

    public function attributeLabels()
    {
        return [
            'id' => Yii::t('app', 'ID'),
            'parent_id' => Yii::t('app', 'Родитель'),
            'link' => Yii::t('app', 'Ссылка'),
            'link_attributes' => Yii::t('app', 'Атрибуты ссылки (JSON массив)'),
            'icon_class' => Yii::t('app', 'Класс иконки'),
            'sort' => Yii::t('app', 'Сортировка'),
            'status' => Yii::t('app', 'Опубликован'),
        ];
    }

    public static function menuItems($node = null)
    {
        if ($node === null) {
            $node = self::find()->roots()->one()->populateTree();
        }
        $result = null;
        $items = null;
        $children = null;
        if ($node['children']) {
            foreach ($node['children'] as $child) {
                $items[] = self::menuItems($child);
            }
            $children = call_user_func_array('array_merge', $items);
        }
        $result[] = [
            'label' => ($node['icon_class'] ? ' ' . ($node['lang']['name'] ?: $node['id']) : ($node['lang']['name'] ?: $node['id'] )),
            'encode' => ($node['icon_class'] ? false : true),
            'url' => [$node['link'], 'language' => Yii::$app->language],
            'active' => $node['link'] == Yii::$app->request->url ? true : false,
            'linkOptions' => ($node['link_attributes'] ? array_merge(json_decode($node['link_attributes'], true), ['title' => ($node['lang']['title'] ?: $node['lang']['name'])]) : ['title' => ($node['lang']['title'] ?: $node['lang']['name'])]),
            'items' => $children,
        ];
        return $result;
    }
}


MenuLang.php
 255],
        ];
    }

    public function attributeLabels()
    {
        return [
            'owner_id' => Yii::t('app', 'Владелец'),
            'language' => Yii::t('app', 'Язык'),
            'name' => Yii::t('app', 'Название'),
            'title' => Yii::t('app', 'Всплывающая подсказка'),
        ];
    }

    public function getOwner()
    {
        return $this->hasOne(Menu::class, ['id' => 'owner_id']);
    }
}


MenuQuery.php


MenuSearch.php
joinWith(['lang']);
        $dataProvider = new ActiveDataProvider([
            'query' => $query,
            'sort' => ['defaultOrder' => ['sort' => SORT_ASC]]
        ]);
        $dataProvider->sort->attributes['name'] = [
            'asc' => [
                'menu_lang.name' => SORT_ASC,
            ],
            'desc' => [
                'menu_lang.name' => SORT_DESC,
            ],
        ];
        $this->load($params);
        if (!$this->validate()) {
            return $dataProvider;
        }
        $query->andFilterWhere([
            'id' => $this->id,
            'parent_id' => $this->parent_id,
            'sort' => $this->sort,
            'status' => $this->status,
        ]);
        $query->andFilterWhere(['like', 'link', $this->link]);
        $query->andFilterWhere(['like', 'link_attributes', $this->link_attributes]);
        $query->andFilterWhere(['like', 'icon_class', $this->icon_class]);
        $query->andFilterWhere(['like', 'name', $this->name]);
        return $dataProvider;
    }
}


Создание контроллеров

Для осуществления CRUD операций над мультиязычными деревьями нам нужен контроллер. Чтобы упростить себе жизнь в будущем, мы создадим один базовый контроллер, в котором будут все необходимые действия, а для разных сущностей, будь то меню, или каталог, или страницы — будем наследоваться от него.

Классы нашего проекта, которые мы будем использовать как базовые, мы разместим в папке /base. Создадим файл /base/controllers/AdminLangTreeController.php. Этот контроллер у нас будет базовым для CRUD всех сущностей, в которых реализовано дерево и мультиязычность:


AdminLangTreeController.php
 [
                'class' => VerbFilter::class,
                'actions' => [
                    'delete' => ['POST'],
                ],
            ],
        ];
    }

    public function actionIndex()
    {
        // Если корневой элемент дерева отсутствует, он будет создан автоматически
        if (count($this->modelClass::find()->roots()->all()) == 0) {
            $model = new $this->modelClass;
            $model->makeRoot()->save();
            Yii::$app->session->setFlash('info', Yii::t('app', 'Корневой элемент создан автоматически'));
            return $this->redirect(['index']);
        }
        $searchModel = new $this->modelClassSearch;
        $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
        $dataProvider->pagination = false;
        return $this->render('index', [
            'searchModel' => $searchModel,
            'dataProvider' => $dataProvider,
        ]);
    }

    public function actionCreate()
    {
        // Проверка наличия корневого элемента
        if (count($this->modelClass::find()->roots()->all()) == 0) {
            return $this->redirect(['index']);
        }
        // Создание новой записи и привязка её к дереву
        $model = new $this->modelClass;
        $root = $model::find()->roots()->one();
        $model->parent_id = $root->id;
        // Загрузка моделей из формы
        if ($model->load(Yii::$app->request->post()) && $model->validate()) {
            $parent = $model::findOne($model->parent_id);
            $model->appendTo($parent)->save();
            // Сохраняем мультиязычные данные            
            foreach (Yii::$app->request->post($this->modelNameLang, []) as $language => $data) {
                foreach ($data as $attribute => $translation) {
                    $model->translate($language)->$attribute = $translation;
                }
            }
            $model->save();
            Yii::$app->session->setFlash('success', Yii::t('app', 'Создание прошло успешно'));
            return $this->redirect(['update', 'id' => $model->id]);
        } else {
            return $this->render('create', [
                'model' => $model,
            ]);
        }
    }

    public function actionUpdate($id)
    {
        // Находим нужную модель
        $model = $this->modelClass::find()->with('translations')->where(['id' => $id])->one();
        if ($model === null) {
            throw new NotFoundHttpException(Yii::t('app', 'Страница не найдена'));
        }
        // Загрузка данных из формы
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            foreach (Yii::$app->request->post($this->modelNameLang, []) as $language => $data) {
                foreach ($data as $attribute => $translation) {
                    $model->translate($language)->$attribute = $translation;
                }
            }
            $model->save();
            Yii::$app->session->setFlash('success', Yii::t('app', 'Обновление произведено успешно'));
            if (Yii::$app->request->post('save') !== null) {
                return $this->redirect(['index']);
            }
            return $this->redirect(['update', 'id' => $model->id]);
        } else {
            return $this->render('update', [
                'model' => $model,
            ]);
        }
    }

    public function actionDelete($id)
    {
        $model = $this->findModel($id);
        // Запрещаем удаление узла, если у него есть потомки
        if (count($model->children) > 0) {
            Yii::$app->session->setFlash('error', Yii::t('app', 'Элемент не может быть удалён, так как содержит дочерние элементы. Сначала нужно удалить все дочерние элементы'));
            return $this->redirect(['index']);
        }
        // Запрещаем удаление корневого элемента
        if ($model->isRoot()) {
            Yii::$app->session->setFlash('error', Yii::t('app', 'Нельзя удалять корневой элемент'));
            return $this->redirect(['index']);
        }
        // Удаляем элемент
        if ($model->delete()) {
            Yii::$app->session->setFlash('success', Yii::t('app', 'Удаление произведено успешно'));
        }
        return $this->redirect(['index']);
    }

    public function actionMoveUp($id)
    {
        $model = $this->findModel($id);
        if ($prev = $model->getPrev()->one()) {
            $model->moveBefore($prev)->save();
            $model->reorder(false);
        } else {
            Yii::$app->session->setFlash('error', Yii::t('app', 'Невозможно передвинуть элемент вверх'));
        }
        return $this->redirect(Yii::$app->request->referrer);
    }

    public function actionMoveDown($id)
    {
        $model = $this->findModel($id);
        if ($next = $model->getNext()->one()) {
            $model->moveAfter($next)->save();
            $model->reorder(false);
        } else {
            Yii::$app->session->setFlash('error', Yii::t('app', 'Невозможно передвинуть элемент вниз'));
        }
        return $this->redirect(Yii::$app->request->referrer);
    }

    protected function findModel($id)
    {
        if (($model = $this->modelClass::findOne($id)) !== null) {
            return $model;
        } else {
            throw new NotFoundHttpException(Yii::t('app', 'Страница не найдена'));
        }
    }
}

Теперь в модуле создадим файл /modules/menu/controllers/AdminController.php. Это будет основной контроллер для управления меню, и, так как он реализует дерево и мультиязычность, будет наследоваться от базового, который мы уже создали в предыдущем шаге:


AdminController.php

Как видите, код данного контроллера содержит лишь названия моделей и их классов. Тоесть, для создания CRUD контроллеров других модулей (каталога, рубрикатора итд.), которые также будут использовать дерево и мультиязычность, можно поступать аналогичным способом — расширять базовый контроллер.


Создание интерфейса для управления меню

Завершающий этап — создание интерфейса для управления мультиязычным деревом. С задачей отображения дерева отлично справляется расширение Bootstrap Treeview, которое можно достаточно гибко настроить и оно поддерживает множество удобных функций (например, поиск по дереву). Создадим индексный view для отображения самого дерева, и поместим его в /modules/menu/views/admin/index.php:


index.php
title = Yii::t('app', 'Меню сайта');
$this->params['breadcrumbs'][] = $this->title;
?>
'btn btn-success btn-flat']) ?>
'tree', 'data' => $searchModel::treeViewData($searchModel::find()->roots()->one()), 'header' => Yii::t('app', 'Меню сайта'), 'searchOptions' => [ 'inputOptions' => [ 'placeholder' => Yii::t('app', 'Поиск по дереву') . '...' ], ], 'clientOptions' => [ 'selectedBackColor' => 'rgb(40, 153, 57)', 'borderColor' => '#fff', 'levels' => 10, 'showTags' => true, 'tagsClass' => 'badge', 'enableLinks' => true, ], ]) ?>

Вот мы дошли до самого интересного этапа данного кейса: как правильно создать форму для создания/редактирования мультиязычных данных. Создаём в папке /modules/menu/views/admin три файла:


create.php
title = Yii::t('app', 'Создать');
$this->params['breadcrumbs'][] = ['label' => Yii::t('app', 'Меню сайта'), 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title;

echo $this->render('_form', [
    'model' => $model,
]);


update.php
title = Yii::t('app', 'Обновить') . ': ' . $model->name;
$this->params['breadcrumbs'][] = ['label' => Yii::t('app', 'Меню сайта'), 'url' => ['index']];
$this->params['breadcrumbs'][] = ['label' => $model->name, 'url' => ['update', 'id' => $model->id]];
$this->params['breadcrumbs'][] = Yii::t('app', 'Обновить');

echo $this->render('_form', [
    'model' => $model,
]);


_form.php
isNewRecord) {
    $model->status = true;
}
?>
isRoot()) { ?> field($model, 'parent_id')->dropDownList($model::listTree()) ?> field($model, 'link')->textInput(['maxlength' => true]) ?> field($model, 'link_attributes')->textInput(['maxlength' => true]) ?> field($model, 'icon_class')->textInput(['maxlength' => true]) ?> field($model, 'status')->checkbox() ?>
urlManager->languages as $key => $language) { ?>
field($model->translate($language), "[$language]name")->textInput() ?> field($model->translate($language), "[$language]title")->textInput() ?>

Не забываем, что в приложении должен быть указан язык по умолчанию (параметр language), а в параметрах UrlManager — массив со списком языков (languages), которые мы будем использовать. Язык по умолчанию должен быть первым в это массиве.


Заключение

В итоге мы должны получить следующее:


  • Готовый модуль для мультиязычного древовидного меню сайта с удобным и настраиваемым интерфейсом;
  • Базовый CRUD контроллер, который можно наследовать при создании других модулей, в которых используется дерево и мультиязычность;
  • Два трейта (мультиязычность и дерево), которые можно подключать к моделям для имплементации соответствующих функций.

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

© Habrahabr.ru