Pull to refresh

Yii 2.0: Динамическое добавление валидируемых полей формы через «пиджак»(pjax) для мульти-модельной формы

Reading time 8 min
Views 54K
Доброго времени суток, Хабр!
Не так давно передо мной встала задача разработки формы с возможностью динамического добавления полей, каждое поле являлось отдельной сущностью базы данных, то есть поле = запись в базе данных. Не смотря на то, что моя задача была не тривиальна, каждый вполне может столкнутся с чем-то подобным в той или иной мере. Например, с добавлением нового элемента прямо внутри GridView с последующим редактированием и сохранением.

Итак, начнем.


Лирическое отступление


Во время разработки данного решения я перерыл весь интернет и не нашел ни одного стоящего рецепта ни на англоязычных форумах, ни на SO ни на GitHub. Более того, к тому времени еще не была готова поддержка валидации динамических полей со стороны Yii. Более подробно тут. Да и сейчас, как мне кажется, она мне не подходит.
Само решение никак не претендует быть сверх-элегантным, по этому любой конструктив, критику, а также советы я с удовольствием выслушаю.

Начальная настройка


В первую очередь нам необходима модель, которая способна получить множество записей из одной или нескольких таблиц. В моем случае мне хватило отношения hasMany. Таким образом, массив моделей всех адресов мы можем получить следующим образом:

$addresses = $model->addresses;


Ради примера можно представить что у нас есть view для пользователя, которая хочет отобразить список адресов с возможностью редактирования каждого, а также добавления нового адреса (сущности взяты с потолка).
Подготавливаем саму форму (будем считать что контроллер отдает только $model как модель пользователя):
<?php

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

?>
<?php $form = ActiveForm::begin([
    'action' => Url::toRoute(['addresses/update', 'userId' => $model->id]),
    'options' => [
        'data-pjax' => '1'
    ],
    'id' => 'adressesUpdateForm'
]); ?>

    <?php foreach ($model->adresses as $key => $address): ?>
            <?= $form->field($address, "[$key]name") ?>
            <?= $form->field($address, "[$key]value") ?>
    <?php endforeach ?>

    <?= Html::submitButton('Сохранить', ['class' => 'btn btn-primary']) ?>
<?php ActiveForm::end(); ?>


Форма готова. В коде выше мы сперва подключаем необходимые классы — виджет ActiveForm и два хелпера.
Далее создаем ActiveForm со следующими параметрами:
  • action — понятно, отсылает форму на определенный action контроллера addresses с параметром userId. (параметр нам пригодится позже)
  • массив options с единственным значением data-pjax, который активирует работу «пиджака» для конкретной формы (для ссылок активация не требуется, а вот формы надо указывать).
  • и id формы — если не задать id формы и при этом иметь на странице много виджетов или несколько ActiveForm, то после отработки сервером, pjax вернет нам форму с id w0, и идентификаторы могут пересечься с другими формами на странице, что нам совершенно не нужно.


После создания формы запускаем цикл по адресам, я использовал геттер напрямую, и не стоит боятся, что при каждой итерации будет происходить запрос к базе, Yii сохраняет все relation запросы в приватном массиве relations. Далее выводим name и value из таблицы (или любые другие поля и более сложную разметку.)

Нетерпеливый читатель, наверное, спросит: «А как же кнопка добавления нового адреса?» — не спешите, все по порядку.

Основные заготовки есть, давайте подключим view файл как внутренний view файл к комплексному view пользователя.
Предположим, у нас есть страница профиля пользователя, а адреса отображаются сразу под ним, добавим view адресов и заодно «одеваем в пиджак»:
<?php

use yii\widgets\Pjax;
?>
<?= $this->render('_profile', ['model' => $model]) ?>
<?php Pjax::begin(['enablePushState' => false]); ?>
    <?= $this->render('_addresses', ['model' => $model]) ?>
<?php Pjax::end(); ?>


Обращаю Ваше внимание на параметр enablePushState, без него мы получим изменение адреса в адресной строке браузера. Нам это не нужно, потому что вся работа контроллера будет проходить через renderAjax, и полноценных view вместе с layout в этой части у нас не будет.

Контроллер


Я специально выделил контроллер отдельной главой.
Давайте сперва подумаем как он будет работать. С виду все просто. Получаем запрос на action update с информацией об id пользователя, затем обновляем модель и отдаем renderAjax('_addresses', ['model' => $user]); В свою очередь, $user мы получаем через User::findOne($userId), который заботливо передали вместе с формой.
Однако на деле все немного сложнее:
  1. У нас не одна модель а несколько
  2. Нам нужна пакетная загрузка
  3. Нужна пакетная валидация


Итак, поехали:
<?php
namespace backend\controllers;

use Yii;
use common\models\User;
use common\models\Addresses;
use yii\base\Model;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\NotFoundHttpException;

/**
 * Addresses controller
 */
class AddressesController extends Controller
{

}


Так будет выглядеть класс контроллера без методов.

Добавляем пакетную загрузку как метод контроллера (можно обойтись и методом модели, но мне показалось так более правильно, к тому же в моем примере мне было необходимо сохранять не только модели, но и связь с таблицей user посредством link()):

    /**
     * Update all addresses
     * @param Model $items
     * @return nothing
     */
    protected function batchUpdate($items)
    {
        if (Model::loadMultiple($items, Yii::$app->request->post()) &&
            Model::validateMultiple($items)) {
            foreach ($items as $key => $item) {
                $item->save();
            }
        }
    }


Можем улучшить метод возвращая true или количество обновленных записей в случае удачи и false в случае отсутствия данных для обновления. Мне это было не нужно.

Добавляем два метода для поиска моделей. Первый для User, второй для Address (я только сейчас подумал, можно было бы обернуть эти два метода в один):
    /**
     * Finds the Addresses model based on its primary key value.
     * If the model is not found, a 404 HTTP exception will be thrown.
     * @param integer $id
     * @return Addresses the loaded model
     * @throws NotFoundHttpException if the model cannot be found
     */
    protected function findModel($id)
    {
        if (($model = Addresses::findOne($id)) !== null) {
            return $model;
        } else {
            throw new NotFoundHttpException('The requested page does not exist.');
        }
    }

    /**
     * Finds the User model based on its primary key value.
     * If the model is not found, a 404 HTTP exception will be thrown.
     * @param integer $id
     * @return User the loaded model
     * @throws NotFoundHttpException if the model cannot be found
     */
    protected function findUser($id)
    {
        if (($model = User::findOne($id)) !== null) {
            return $model;
        } else {
            throw new NotFoundHttpException('The requested page does not exist.');
        }
    }


И, наконец, пишем наш action:

    public function actionUpdate($userId)
    {
        $user = $this->findUser($userId);
        $this->batchUpdate($user->addresses);
        return $this->renderAjax('_addresses', ['model' => $user]);
    }


Не забудьте добавить access control.

    /**
     * @inheritdoc
     */
    public function behaviors()
    {
        return [
            'access' => [
                'class' => AccessControl::className(),
                'rules' => [
                    [
                        'actions' => ['create', 'update', 'delete'],
                        'allow' => true,
                        'roles' => ['@'],
                    ],
                ],
            ]
        ];
    }

В методе выше я поспешил и сразу показал методы create и delete. Их еще нет, но заранее добавить в access control два метода лучше, чем потом ловить exception о запрещенном доступе.

Ну что, теперь у нас есть отличная форма, которая обновляет посредством pjax данные по всем адресам. В обычном случае мы бы могли добавить в форму кнопку «добавить» и «удалить» и отсылать запрос на определенный action, а в случае с «добавить» — еще и отдельную view.

Динамические поля с валидацией


Вот и добрались до самого главного.
Простое добавление новой сущности сводится к следующим действиям:
  • Добавляем во view кнопку которая ведет на action — addresses/create
  • Добавляем функцию создания fake записи в базе данных.
  • Добавляем action
  • Отображаем view через ajax.


Создание fake записи делаем через метод модели addOne()
    public function addOne()
    {
        $this->name = self::DEFAULT_NAME;
        $this->value = self::DEFAULT_VALUE;
    }

Не забудьте создать константы в классе модели.

Action в контроллере будет выглядеть так:
    /**
     * action call by AJAX to create new fake address
     * @param  integer $userId
     * @return mixed
     */
    public function actionCreate($userId)
    {
        $user = $this->findUser($userId);
        $model = new Addresses;
        $model->addOne();
        $user->link('addresses', $model); // link сохраняет в базу данных без валидации, будьте осторожны
        return $this->renderAjax('_addresses', ['model' => $user]);
    }


Кнопка добавления записи во view внутри pjax, но вне цикла:
    <?= Html::a('Добавить адрес', Url::toRoute(['addresses/create', 'userId' => $model->id]), [
        'class' => 'btn btn-success',
    ]) ?>


Собственно, все. Теперь при нажатии на кнопку «Добавить адрес» в базе данных будет создаваться fake запись с начальными данными, а view будет рендериться заново вместе с новыми правилами валидации.
Можно улучшить эту часть кода добавлением правила валидации о том, что значения не должны быть эквивалентны дефолтным. Так как метод link сохраняет без валидации, это вполне реализуемо, а для остальных могу посоветовать save(false) — false отключает валидацию при сохранении модели.

Давайте сделаем тоже самое для кнопки удалить, в итоге наша view будет выглядеть внутри цикла вот так:
            <?= $form->field($address, "[$key]name") ?>
            <?= $form->field($address, "[$key]value") ?>
            <?= Html::a('Удалить', Url::toRoute(['addresses/delete', 'id' => $address->id]), [
                'class' => 'btn btn-danger',
            ]) ?>


и action контроллера:
    public function actionDelete($id)
    {
        $model = $this->findModel($id);
        $user = $model->user;
        $model->delete();
        return $this->renderAjax('_addresses', ['model' => $user]);
    }


А как же измененные значения и UX?


Все верно. Для стандартной ситуации описанного выше функционала хватит, однако пользователь привык к тому, что при динамическом добавлении поля ему не нужно заботиться о сохранении данных перед этим. В итоге пользователь может заполнить 5 полей, поймет что ему не хватило, добавит 6-ое… и все. Прощайте 5 минут жизни для пользователя, прощай пользователь для нашего ресурса.

Единственное, что я смог придумать в этой ситуации, это сохранять форму каждый раз, когда пользователь жмет на кнопку (не важно, на какую).
Что мне для этого понадобилось:
  • Добавить batchUpdate в action'ы create и delete прямо перед return $this->renderAjax(...)
  • Написать простенький скрипт, который меняет action формы в зависимости от нажатой кнопки, а потом сабмитит ее.


Сперва скрипит:
$(function(){
    $(document).on('click', '[data-toggle=reroute]', function(e) {
        e.preventDefault();
        var $this = $(this);
        var data = $this.data();
        var action = data.action;
        var $form = $this.closest('form');
        if ($form && action) {
            $form.attr('action', action).submit();
        } else {
            alert('Ошибка! Пожалуйста, сообщите администрации.');
        }
    });
});

Простой сниппет кода, который занял у меня от силы 1 минуту. Ссылка или элемент с атрибутом data-toggle=reroute попадает в обработчик, и ближайшая к нему форма (среди родителей, естественно) меняет свой action на тот, что хранится в data-action, а после этого сабмитится. В случае неверной настройки обработчика со стороны html шаблона вылетает alert.

Осталось изменить наши кнопки в представлении следующим образом:
    <?= Html::a('Добавить адрес', null, [
        'class' => 'btn btn-success',
        'data' => [
            'toggle' => 'reroute',
            'action' => Url::toRoute(['addresses/create', 'userId' => $model->id])
        ]
    ]) ?>

    <?= Html::a('Удалить', null, [
        'class' => 'btn btn-danger',
        'data' => [
            'toggle' => 'reroute',
            'action' => Url::toRoute(['addresses/delete', 'id' => $variable->id])
        ]
    ]) ?>


Что можно улучшить


Как всегда есть к чему стремиться.
  • Для начала можно оптимизировать пакетную загрузку (если она, конечно, не оптимизирована на уровне ядра, чему я не нашел подтверждение) таким образом, что не измененные записи не будут сохранятся в базу данных. Для этого достаточно сравнить oldAttributes и attributes конкретной модели в методе модели beforeSave(). В противном случае, если такая проверка не происходит на уровне фреймворка, sql сервер будет удивлен повторным записям с одними и теми же значениями.
  • Далее можно обернуть методы поиска модели в контроллере в один единственный метод findModel($classname, $params)
  • И, как я уже говорил, создать правило валидации на несоответствие полей модели ее константам с дефолтными значениями.


Буду рад, если кто-то подскажет улучшения или исправления данного рецепта. Всем добра!

Полезные ссылки
demos.krajee.com/builder-details/tabular-form — нечто похожее, но очень монструозное и сделано для GridView. К тому же, там нет сохранения полей при удалении / добавлении новых
www.yiiframework.com/wiki/666/handling-tabular-data-loading-and-validation-in-yii-2 — не плохая демка от того же автора, которая послужила примером пакетной загрузки.
Tags:
Hubs:
+14
Comments 9
Comments Comments 9

Articles