Мультиязычные деревья в 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.


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


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


m180819_083502_menu_init.php
<?php

use yii\db\Schema;
use yii\db\Migration;

class m180819_083502_menu_init extends Migration
{

    public function init()
    {
        $this->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
<?php

namespace app\traits;

use Yii;
use yii\behaviors\SluggableBehavior;
use creocoder\translateable\TranslateableBehavior;

trait LangTrait
{
    public static function langClass()
    {
        return self::class . 'Lang';
    }

    public static function langTableName()
    {
        return self::tableName() . '_lang';
    }

    public function langBehaviors($translationAttributes)
    {
        return [
            'translateable' => [
                '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
<?php

namespace app\traits;

use Yii;
use yii\helpers\Html;
use paulzi\adjacencyList\AdjacencyListBehavior;

trait TreeTrait
{

    private static function getQueryClass()
    {
        return self::class . 'Query';
    }

    public function treeBehaviors()
    {
        return [
            'tree' => [
                '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(
                    '<i class="glyphicon glyphicon-arrow-down"></i>',
                    ['move-down', 'id' => $node['id']],
                    ['title' => Yii::t('app', 'Передвинуть вниз')]
                ),
                Html::a(
                    '<i class="glyphicon glyphicon-arrow-up"></i>',
                    ['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
<?php

namespace app\modules\menu\models;

use Yii;

class Menu extends \yii\db\ActiveRecord
{
    use \app\traits\TreeTrait;
    use \app\traits\LangTrait;

    const STATUS_ACTIVE = 1;
    const STATUS_INACTIVE = 0;

    public function behaviors()
    {
        $behaviors = [];
        return array_merge(
            $behaviors,
            $this->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'] ? '<i class="' . $node['icon_class'] . '"></i> ' . ($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
<?php

namespace app\modules\menu\models;

use Yii;

class MenuLang extends \yii\db\ActiveRecord
{
    public static function tableName()
    {
        return 'menu_lang';
    }

    public function rules()
    {
        return [
            [['name'], 'required'],
            [['name', 'title'], 'string', 'max' => 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
<?php

namespace app\modules\menu\models;

use paulzi\adjacencyList\AdjacencyListQueryTrait;

class MenuQuery extends \yii\db\ActiveQuery
{
    use AdjacencyListQueryTrait;
}

MenuSearch.php
<?php

namespace app\modules\menu\models;

use Yii;
use yii\base\Model;
use yii\data\ActiveDataProvider;
use app\modules\menu\models\Menu;

class MenuSearch extends Menu
{
    public $name;

    public function rules()
    {
        return [
            [['id', 'parent_id', 'sort', 'status'], 'integer'],
            [['link', 'link_attributes', 'icon_class'], 'safe'],
            [['name'], 'safe'],
        ];
    }

    public function scenarios()
    {
        return Model::scenarios();
    }

    public function search($params)
    {
        $query = parent::find()->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
<?php

namespace app\base\controllers;

use Yii;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\filters\VerbFilter;
use yii\helpers\Url;

class AdminLangTreeController extends Controller
{
    public $modelClass;
    public $modelClassSearch;
    public $modelName;
    public $modelNameLang;

    public function behaviors()
    {
        return [
            'verbs' => [
                '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
<?php

namespace app\modules\menu\controllers;

use app\base\controllers\AdminLangTreeController as BaseController;

class AdminController extends BaseController
{
    public $modelClass = \app\modules\menu\models\Menu::class;
    public $modelClassSearch = \app\modules\menu\models\MenuSearch::class;
    public $modelName = 'Menu';
    public $modelNameLang = 'MenuLang';
}

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


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


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


index.php
<?php

use yii\helpers\Html;
use yii\grid\GridView;
use yii\widgets\ActiveForm;
use execut\widget\TreeView;

$this->title = Yii::t('app', 'Меню сайта');
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="row">
    <div class="col-md-6">
        <div class="panel panel-primary">
            <div class="panel-heading">
                <?= Html::a(Yii::t('app', 'Создать'), ['create'], ['class' => 'btn btn-success btn-flat']) ?>
            </div>
            <div class="panel-body">
                <?= 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,
                    ],
                ]) ?>
            </div>
        </div>
    </div>
</div>

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


create.php
<?php

use yii\helpers\Html;

$this->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
<?php

use yii\helpers\Html;

$this->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
<?php

use yii\helpers\Html;
use yii\widgets\ActiveForm;

if ($model->isNewRecord) {
    $model->status = true;
}
?>
<div class="panel panel-primary">
    <?php $form = ActiveForm::begin(); ?>
    <div class="panel-body">
        <fieldset>
            <legend><?= Yii::t('app', 'Общие настройки') ?></legend>
            <div class="row">
                <div class="col-md-4">
                    <?php if (!$model->isRoot()) { ?>
                        <?= $form->field($model, 'parent_id')->dropDownList($model::listTree()) ?>                    
                    <?php } ?>
                    <?= $form->field($model, 'link')->textInput(['maxlength' => true]) ?>
                    <?= $form->field($model, 'link_attributes')->textInput(['maxlength' => true]) ?>
                    <?= $form->field($model, 'icon_class')->textInput(['maxlength' => true]) ?>
                    <?= $form->field($model, 'status')->checkbox() ?>
                </div>
            </div>
        </fieldset>
        <fieldset>
            <legend><?= Yii::t('app', 'Содержание') ?></legend>
                <!-- Nav tabs -->
            <ul class="nav nav-tabs" role="tablist">
                <?php foreach (Yii::$app->urlManager->languages as $key => $language) { ?>
                    <li role="presentation" <?= $key == 0 ? 'class="active"' : '' ?>>
                        <a href="#tab-content-<?= $language ?>" aria-controls="tab-content-<?= $language ?>" role="tab" data-toggle="tab"><?= $language ?></a>
                    </li>
                <?php } ?>
            </ul>
            <!-- Tab panes -->
            <div class="tab-content">
                <?php foreach (Yii::$app->urlManager->languages as $key => $language) { ?>
                    <div role="tabpanel" class="tab-pane <?= $key == 0 ? 'active' : '' ?>" id="tab-content-<?= $language ?>">
                        <?= $form->field($model->translate($language), "[$language]name")->textInput() ?>
                        <?= $form->field($model->translate($language), "[$language]title")->textInput() ?>
                    </div>
                <?php } ?>
            </div>
        </fieldset>        
    </div>
    <div class="box-footer">
        <?= Html::submitButton($model->isNewRecord ? '<i class="fa fa-plus"></i> ' . Yii::t('app', 'Создать') : '<i class="fa fa-refresh"></i> ' . Yii::t('app', 'Обновить'), ['class' => $model->isNewRecord ? 'btn btn-primary' : 'btn btn-success']) ?>
    <?= !$model->isNewRecord ? Html::submitButton('<i class="fa fa-save"></i> ' . Yii::t('app', 'Сохранить'), ['class' => 'btn btn-warning', 'name' => 'save']) : ''; ?>
    <?= !$model->isNewRecord ? Html::a('<i class="fa fa-trash"></i> ' . Yii::t('app', 'Удалить'), ['delete', 'id' => $model->id], ['class' => 'btn btn-danger', 'data' => ['confirm' => Yii::t('app', 'Вы уверены, что хотите удалить этот элемент?'), 'method' => 'post']]) : ''; ?>
    </div>
    <?php ActiveForm::end(); ?>
</div>

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


Заключение


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


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

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

Поделиться публикацией

Похожие публикации

Комментарии 4
    –2
    Я может не совсем понял, но что мешало просто использовать i18n и не городить это хрен-пойми что? Нет переводов по умолчанию? Ну так ловить непереведенные фразы в нужной локали и сохранять в БД, где можно и хранить переводы, а не в файлах…
    Какая то поделка из серии лишь бы было, точнее пример того, как делать ненадо. Кто плюсует? Объясните свою позицию…
      +1
      Вы видимо не совсем поняли суть данного решения. Это не переводы интерфейса, как предусматривает механизм i18n, и не переводы фреймворка. Это механизм хранения мультиязычного контента (это может быть и содержимое динамических страниц, мета-данные для SEO итд.). К тому же, тут показан рабочий вариант виджета по управлению деревом с поддержкой мультиязычности (я, к сожалению, не нашёл такого, когда столкнулся с подобной задачей). Если у Вас есть предложения или готовые решения по способу организации мультиязычного контента в Yii2 — можете поделиться. Как говориться, «критикуешь — предлагай».
      0
      Отличный рецепт) Только вот интересно, почему не Nested Sets предпочли?) Для хранения меню самое то)
        0
        По моим наблюдениям, модель Adjacency List для хранения деревьев в MySQL является более распространённой и понятной (гораздо проще понять связку id -> parent_id, чем вычислять поля left, right, level, особенно при ручном редактировании, в случае сбоя, потери узла, выгрузки в Excel итд.). Скажем так, это дело вкуса, везде есть свои плюсы и минусы Использование конкретной модели дерева не играет особой роли в примере данной статьи. Подобное мультиязычное меню можно также легко сделать и для Nested Sets. Кстати, есть хорошее дополнение для Yii2, которое позволяет менять или комбинировать модели хранения дерева в базе.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое