В прошлой части мы написали систему по добавлению частей речи на сайт через админку. Самое время приступить к реализации основной задачи. Напомню,
Нужен словарь, куда администратор сайта будет добавлять, собственно, слова, а также их словоформы. Затем мы будем вытаскивать по нужной словоформе слово в базовой форме и давать его перевод, а также позволим пользователям добавлять его в свой собственный словарь для дальнейшего изучения.
Немного уточню: наш сервис подразумевает, что учиться будут носители русского и английского языка (с возможностью дальнейшего расширения количества языков), а учить они будут третий. Неважно, филиппинский язык, креольский или греческий.
Приступим к реализации словаря.
Учитываем, что переводиться на несколько языков будут только поля comment и translate.
public function safeUp()
{
$tableOptions = null;
if ($this->db->driverName === 'mysql') {
$tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB';
}
$this->createTable('dictionary', [
'id' => $this->primaryKey(),
'word' => $this->string(64), // слово, для которого будем делать переводы
'parent_id' => $this->integer(5), // если есть родитель, значит, что текущее слово - словоформа
'part_of_speech' => $this->integer(5), // к какой части речи относится данное слово. Связь с таблицей parts_of_speech
], $tableOptions);
$this->createTable('dictionary_translate', [
'id' => $this->primaryKey(),
'word_id' => $this->integer(), // для какого слова перевод? Связь с таблицей dictionary_translate
'language' => $this->string(), // тут будет лежать код языка перевода
'translate' => $this->string(), // перевод слова
'comment' => $this->string(), // если нужно объяснение значения слова, оно будет в этой строке
], $tableOptions);
// Создадим индексы нужных полей
$this->createIndex(
'idx-dictionary_translate-word_id',
'dictionary_translate',
'word_id'
);
$this->createIndex(
'idx-dictionary_translate-language',
'dictionary_translate',
'language'
);
/* Cоздадим внешний ключ для связи с таблицей parts_of_speech */
// См. документацию https://www.yiiframework.com/doc/guide/2.0/en/db-migrations, блок "add foreign key for table `user`"
$this->addForeignKey(
'fk-dictionary-part_of_speech',
'dictionary', // какую таблицу связываем
'part_of_speech', // по какому полю
'parts_of_speech', // с какой таблицей
'id', // по какому её полю
'CASCADE' // тип
);
/* Аналогично, создадим внешний ключ в таблице переводов, связав её с основной таблицей словаря */
$this->addForeignKey(
'fk-dictionary_translate-word_id',
'dictionary_translate', // какую таблицу связываем
'word_id', // по какому полю
'dictionary', // с какой таблицей
'id', // по какому её полю
'CASCADE' // тип
);
}
Забавная штука: ключ translation оказался зарезервированным, поэтому пришлось поле переименовать в translate.
В случае некорректной миграции будем удалять также обе созданные таблицы и внешние ключи. Индексы дропать не будем, ну их.
public function safeDown()
{
//Добавляем удаление внешних ключей
$this->dropForeignKey(
'fk-dictionary_translate-word_id', // имя ключа
'dictionary_translate' // к какой таблице он был добавлен
);
$this->dropForeignKey(
'fk-dictionary-part_of_speech', // имя ключа
'dictionary' // к какой таблице он был добавлен
);
$this->dropTable('dictionary');
$this->dropTable('dictionary_translate');
}
Создадим модель по тем же принципам, что и в прошлом посте.
Настроим внутри неё мультиязычное поведение и правила валидации:
public function behaviors()
{
return [
'ml' => [
'class' => MultilingualBehavior::className(),
'languages' => [
'ru' => 'Russian',
'en-US' => 'English',
],
'defaultLanguage' => 'ru',
'tableName' => "{{%dictionary_translate}}",
'langForeignKey' => 'word_id',
'attributes' => [
'comment', 'translation', // Передаем массив переводимых полей
]
],
];
}
public function rules()
{
return [
[['parent_id', 'part_of_speech'], 'integer'],
[['word'], 'string', 'max' => 64],
[['part_of_speech'], 'exist', 'skipOnError' => true, 'targetClass' => PartsOfSpeech::className(), 'targetAttribute' => ['part_of_speech' => 'id']],
[['comment', 'translation'], 'string']
];
}
Также изменим метод find() и добавим use.
Аналогично прошлому уроку, сгенерируем крудЪ и пропишем в контроллере правильный метод findModel($id).
Откорректируем форму в виде для заполнения нужных полей (и удалим лишнее поле parent_id, его в дальнейшем мы будем заполнять автоматически).
<?php $form = ActiveForm::begin(); ?>
<?= $form->field($model, 'word')->textInput(['maxlength' => true]) ?>
<?= $form->field($model, 'part_of_speech')->textInput() ?>
<div class="row">
<div class="col-sm-6">
<h3>Перевод на русский язык</h3>
<?= $form->field($model, 'translation') ?>
<?= $form->field($model, 'comment') ?>
</div>
<div class="col-sm-6">
<h3>English version</h3>
<?= $form->field($model, 'translation_en') ?>
<?= $form->field($model, 'comment_en') ?>
</div>
</div>
<div class="form-group">
<?= Html::submitButton('Сохранить', ['class' => 'btn btn-success']) ?>
</div>
<?php ActiveForm::end(); ?>
Также заполним лейблы для полей и получим такую форму:

Самое время воспользоваться написанным в прошлой части кодом и вывести выпадающим списком возможные варианты частей речи. Для этого подключим модель частей речи в наш контроллер, а также поключим ArrayHelper для формирования правильного списка:
#backend\controllers\DictionaryController use common\models\PartsOfSpeech; use yii\helpers\ArrayHelper;
И напишем метод контроллера, который будет возвращать обработанный массив, состоящий из всех частей речи, добавленных через админку.
public function getAllPartsOfSpeech(){
$parts = PartsOfSpeech::find()->asArray()->all();
$array = ArrayHelper::map($parts, 'id', 'name');
return $array;
}
Передадим части речи в вид внутри экшнов (на примере экшна create):
public function actionCreate()
{
$model = new Dictionary();
if ($model->load(Yii::$app->request->post()) && $model->save()) {
return $this->redirect(['view', 'id' => $model->id]);
}
return $this->render('create', [
'model' => $model,
'parts_of_speech' => $this->getAllPartsOfSpeech()
]);
}
Не забудьте передать эти данные в update, а также прокинуть их через вид в форму.
И, наконец, в виде делаем вот такую штуку:
# /backend/views/dictionary/_form.php $params = ['selection' => $model->part_of_speech ]; echo $form->field($model, 'part_of_speech')->dropDownList($parts_of_speech, $params);
И вот у нас готовая и работающая форма добавления нового слова в словарь.

Самое время реализовать возможность добавления словоформы к родительскому слову через тот же CRUD. По сути, механизм будет все тот же, за исключением передачи параметра с ID родителя.
Для начала, добавим кнопку добавления потомка в GridView:
# /backend/views/dictionary/index.php
<?= GridView::widget([
'dataProvider' => $dataProvider,
'columns' => [
['class' => 'yii\grid\SerialColumn'],
'id',
'word',
'parent_id',
'part_of_speech'
[
'class' => \yii\grid\ActionColumn::class,
/* Кусок кода с добавленной кнопкной */
'template' => '{view} {add} {update} {delete} ',
'buttons' => [
'add' => function ($url, $model, $key) {
$iconName = "duplicate";
$title = 'Добавить словоформу';
$id = 'add-'.$key;
$options = [
'title' => $title,
'id' => $id
];
$url = Url::current(['dictionary/add', 'id' => $key]);
$icon = Html::tag('span', '', ['class' => "glyphicon glyphicon-$iconName"]);
return Html::a($icon, $url, $options);
},
],
/* Конец куска */
],
],
]); ?>
Подробнее о том, как и почему это сделано, смотрите тут.
Теперь, когда у нас есть ссылка на экшн, опишем его:
# /backend/controllers/DictionaryController.php
public function actionAdd($id)
{
$model = new Dictionary();
if($this->parentExist($id)){
$model->parent_id = $id;
} else {
throw new NotFoundHttpException('Родительская форма слова не найдена или Вы пытаетесь добавить словоформу другой словоформе.');
}
if ($model->load(Yii::$app->request->post()) && $model->save()) {
return $this->redirect(['view', 'id' => $model->id]);
}
return $this->render('create', [
'model' => $model,
'parts_of_speech' => $this->getAllPartsOfSpeech()
]);
}
Фактически, это немного усовершенствованный код из стандартного экшна create, лишь с тем различием, что в нем мы проверяем, действительно ли в нашей базе существует запись с переданным id, и если это так, делаем присвоение модели:
$model->parent_id = $id;
Помимо этого, нужно написать в нашем контроллере метод для проверки существования родителя:
public function parentExist($id){
// Если запись существует
if (($model = Dictionary::find()->where(['id' => $id])->multilingual()->one()) !== null) {
// и у неё нет родителя
if( $model->parent_id == null ){
return true;
}
return false;
}
return false;
}
Что важно: для избежания круговых ссылок слов друг на друга мы запрещаем ссылаться на словоформы как на родителей. Благо, сам язык (под изучение которого разрабатывается сайт) нам это позволяет. Ну и в контроллере бросаем 404 исключение, если родителя с переданным id не существует или он сам является потомком.
Теоретически, можно написать рекурсивную функцию, которая будет определять родителя первого уровня и проставлять его id-шник, но в данном случае реализация не требует такого подхода, да и сегодня мне уже лень это делать.
В заключительном на сегодня блоке выведем часть речи строкой вместо её идентификатора.
В виджете
# /backend/views/dictionary/view.php
<?= DetailView::widget([
'model' => $model,
'attributes' => [
'id',
'word',
'parent_id',
/* Выведем поле с нужным лейблом и значением */
[
'label' => 'Часть речи',
'value' => $model->getPartOfSpeech($model->part_of_speech),
],
],
]) ?>
Также нужно написать функцию внутри модели, которая будет возвращать строкой значение по переданному id.
# /common/models/Dictionary.php
public function getPartOfSpeech($id)
{
$part_of_speech = PartsOfSpeech::find()->where(['id' => $id])->asArray()->one();
return $part_of_speech['name'];
}
Как мне кажется, получилось более чем наглядно.
И делаем аналогичную весчь в GridView общего списка.
[
'attribute' => 'part_of_speech',
'label' => 'Часть речи',
'format' => 'text',
'content' => function($data){
return $data->getPartOfSpeech($data->part_of_speech);
}
],
Время прерваться на сон, чем я, пожалуй, и займусь.
