[Из песочницы] Мультиязычные деревья в Yii2 на примере создания модуля меню
Вступление
Многие начинающие веб-разработчики сталкиваются с необходимостью создания меню, каталогов или рубрикаторов для своего проекта на 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.
Создание моделей
Для хранения меню (либо другой сущности, которая имеет мультиязычность) в базе данных нам необходимо создать две таблицы. На самом деле, для хранения мультиязычных данных могут использоваться разные методики, но вариант с двумя таблицами, одна из которых хранит саму сущность, а вторая — её языковые вариации, мне нравится больше остальных. Для создания таблиц удобно использовать миграции. Вот пример такой миграции:
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 (если её там ещё нет) и поместим туда два файла:
[
'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']);
}
}
[
'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:
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;
}
}
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']);
}
}
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 всех сущностей, в которых реализовано дерево и мультиязычность:
[
'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. Это будет основной контроллер для управления меню, и, так как он реализует дерево и мультиязычность, будет наследоваться от базового, который мы уже создали в предыдущем шаге:
Как видите, код данного контроллера содержит лишь названия моделей и их классов. Тоесть, для создания CRUD контроллеров других модулей (каталога, рубрикатора итд.), которые также будут использовать дерево и мультиязычность, можно поступать аналогичным способом — расширять базовый контроллер.
Создание интерфейса для управления меню
Завершающий этап — создание интерфейса для управления мультиязычным деревом. С задачей отображения дерева отлично справляется расширение Bootstrap Treeview, которое можно достаточно гибко настроить и оно поддерживает множество удобных функций (например, поиск по дереву). Создадим индексный view для отображения самого дерева, и поместим его в /modules/menu/views/admin/index.php:
title = Yii::t('app', 'Меню сайта');
$this->params['breadcrumbs'][] = $this->title;
?>
= Html::a(Yii::t('app', 'Создать'), ['create'], ['class' => 'btn btn-success btn-flat']) ?>
= TreeView::widget([
'id' => '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 три файла:
title = Yii::t('app', 'Создать');
$this->params['breadcrumbs'][] = ['label' => Yii::t('app', 'Меню сайта'), 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title;
echo $this->render('_form', [
'model' => $model,
]);
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,
]);
isNewRecord) {
$model->status = true;
}
?>
Не забываем, что в приложении должен быть указан язык по умолчанию (параметр language), а в параметрах UrlManager — массив со списком языков (languages), которые мы будем использовать. Язык по умолчанию должен быть первым в это массиве.
Заключение
В итоге мы должны получить следующее:
- Готовый модуль для мультиязычного древовидного меню сайта с удобным и настраиваемым интерфейсом;
- Базовый CRUD контроллер, который можно наследовать при создании других модулей, в которых используется дерево и мультиязычность;
- Два трейта (мультиязычность и дерево), которые можно подключать к моделям для имплементации соответствующих функций.
Я надеюсь, что данная статья станет полезной и поможет вам в разработке новых хороших проектов на Yii2.