Ми}{@лbI4

Блог хеллоуворлдщика

Динамические поля

08.05.2014 yii, динамические поля

Как всегда, каждая моя статья бывает чем-то сподвигнута, а именно работой. Выполняя тот или иной проект у меня появляются статьи для моего блога. На этот раз мы заденем такую тему, как "динамические поля".

Когда в проекте потребовалось реализовать связь между атрибутами товаров и их параметрами, вольно невольно возникает в голове только одно - динамические поля. В Yii, насколько всем известно, явно для этих целей ничего нет. Можно поискать в интернете и набрести, например, на эту статью http://habrahabr.ru/post/181642/ . Метод хорош, но не очень. Во-первых, нам везде нужно писать свою валидацию. Во-вторых, здесь не используется модель для которой весь этот концерт был организован, тем самым мы лишаемся много и приходится придумывать велосипеды, как например "запоминания прерыдущих значений". В моём методе этих недостатков нет. Оговорюсь, что я рассматриваю работу с двумя таблицами, в первой хранятся атрибуты товара, во второй хрянятся параметры для каждого из атрибута. Для кого-то, возможно, будет тяжело понять что я сейчас сказал, но не огорчайтесь. На самом деле всё просто. Чтоб понять о чём я, вам нужно это увидить. Предлагаю посетить демо-сайт платформы для интернет-магазина http://eximuscommerce.com/demo . В панели управления есть те самые атрибуты и параметры. И нет, я не слизал оттуда свою статью. Там есть динамическип поля, но реализация совсем не такая.

Реализация

У нас будут две модели, для аттрибутов ShopAttribute и для параметров атрибутов ShopAttributeParam. В модели ShopAttribute сделаем связь с таблицой ShopAttributeParam с типом связи "один-ко-многим", т.е. у одного аттрибута много параметров.

Если у нас в соответствующих таблицах что-то есть, то обратившись к модели

$model = ShopAttribute::model()->with('param')->findByPk($id);

Мы в $model->param получим массив объектов ShopAttributeParam.

Чтоб отображать динамические поля, нам нужно их получать. Создавть динамические поля будем используя jQuery, при этом никогда не удаляя поля для первого параметра, т.е. мы будем его копировать, а отображать уже существующие и добавленные поля со стороны сервера будем используя эту функцию в контроллере

/**
 * Creating field for parameter.
 *
 * @param object $form Class form of CActiveForm
 * @param object $model Class model of CActiveRecord
 * @param string $attribute Name of field
 *
 * @return string
 */
protected function field($form, $model, $attribute)
{
    $model = $model === null ?  new ShopAttributeParam() : $model;
     
    return $form->textFieldRow(
        $model,
        $attribute,
        array(
            'value' => $model->$attribute,
            'name' => CHtml::resolveName($model, $attribute) . "[{$model->id}]",
        ),
        array(
            'labelOptions' => array(
                'label' => ''
            ),
            'append' => $this->widget(
                'bootstrap.widgets.TbButton',
                array(
                    'buttonType' => 'button',
                    'type' => 'default',
                    'label' => Yii::t('ShopModule.core', 'Delete'),
                    'icon' => 'trash',
                    'htmlOptions' => array(
                        'onclick' => "jQuery(this).dynamicFields('delete', this);return false;",
                    ),
                ),
                true
            ),
            'appendOptions' => array(
                'isRaw' => true,
            ),
        )
    );
}

В аргумент $form передаётся объект формы. В аргумент $model передаётся объект модели. В аргумент $attribute передаётся имя динамического поля в модели ShopAttributeParam. У меня сейчас имя поля value.

В view пишем:

<div id="attributes-params-form">
    <div class="control-group">
        <div class="controls">
        <?php
            $this->widget(
                'bootstrap.widgets.TbButton',
                array(
                    'buttonType' => 'button',
                    'type' => 'success',
                    'label' => Yii::t('ShopModule.core', 'Add'),
                    'icon' => 'plus',
                    'htmlOptions' => array(
                        'onclick' => "jQuery(this).dynamicFields('add', 'attributes-params-form');return false;"
                    ),
                )
            );
        ?>
        </div>
    </div>
    <div class="fields">
        <?php
        if (!$model->param || !is_array($model->param))
        {
            echo $this->field($form, null, 'value');
        }
        else
        {
            for ($i = 0; $i != count($model->param); $i++)
            {
                echo $this->field($form, $model->param[$i], 'value');
            }
        }
        ?>
    </div>
</div>
<?php
$assets = Yii::app()->assetManager->publish(
    Yii::getPathOfAlias('application.modules.shop.assets.js'),
    false,
    -1,
    YII_DEBUG
);
$cs = Yii::app()->clientScript;
$cs
    ->registerCoreScript('jquery')
    ->registerScriptFile($assets . '/attributes.params.js', CClientScript::POS_END);

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

Код jQuery для динамического добавления и удаления полей attributes.params.js:

(function($){
     
    var clearField = function(field) {
        var regExp = /^([\w]+\[[\w]+\]\[)[\d]*(\])$/i;
         
        field.attr('name', field.attr('name').replace(regExp, '$1$2')).removeClass('error');
        field.closest('.controls').find('> .error').remove();
        field.closest('.control-group').removeClass('error');
    };
     
    var methods = {
         
        add: function(id) {
            var $form = $('#' + id).find('.fields'),
                field = $form.find('div:first').clone();
             
            $(field).find('input[type="text"]:first').val('');
             
            clearField($(field).find('input[type="text"]:first'));
             
            $form.append(field);
        },
         
        delete: function(th) {
            var $current = $(th).closest('.control-group');
             
            if ($current.parent().children().length == 1) {
                $current.find('input[type="text"]:first').val('');
                clearField($current.find('input[type="text"]:first'));
            } else {
                $current.remove();
            }
        }
         
    };
     
    $.fn.dynamicFields = function(method) {
        if (methods[method]) {
            return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
        } else {
            $.error('Method with name ' +  method + ' not exist.');
        }
    };
     
})(jQuery);

В функции clearField я произвожу очистку поля от лишнего для последующего его отображения. Напоминаю, что мы копируем существующее поле, а не генерируем новое.

Код контроллера:

/**
 * Change attribute
 *
 * @param int $id ID of attribute
 * @param boolean $new Create or update action.
 */
public function actionUpdate($id = null, $new = false)
{       
    if ($new)
    {
        $model = new ShopAttribute('create');
    }
    else
    {
        $model = $this->loadModel($id);
    }
     
    $params = Yii::app()->request->getPost('ShopAttributeParam');
    if (Yii::app()->request->getPost('ShopAttribute') && $params)
    {
        $transaction = Yii::app()->db->beginTransaction();
         
        try
        {
            $model->attributes = Yii::app()->request->getPost('ShopAttribute');
             
            if ($model->save())
            {
                //get value of fields for module ShopAttributeParam
                $errorValidate = 0;
                $model->param = array();
                $modelParams = array();
                foreach ($params['value'] as $key => $val)
                {
                    $param = ShopAttributeParam::model()->findByAttributes(
                        array(
                            'attr_id' => $model->id,
                            'id' => $key
                        )
                    );
 
                    if ($param === null)
                    {
                        $param = new ShopAttributeParam();
                        $param->attr_id = $model->id;
                    }
 
                    $param->value = $val;
 
                    if (!$param->validate())
                    {
                        ++$errorValidate;
                    }
 
                    $modelParams[] = $param;
                }
                $model->param = $modelParams;
 
                if (!$errorValidate)
                {
                    if ($this->saveParams($model))
                    {
                        $transaction->commit();
                        Yii::app()->user->setFlash(
                            'success',
                            Yii::t('ShopModule.attribute', $new ? 'Attribute has been successfully added!' : 'Attribute has been successfully changed!')
                        );
                        $this->redirect(array('index'));
                    }
                }
            }
             
            $transaction->rollback();
        }
        catch (CHttpException $e)
        {
            $transaction->rollback();
            throw $e;
        }
    }
     
    $this->render(
        ($new ? 'create' : 'update'),
        array(
            'model' => $model,
        )
    );
}
 
/**
 * Save parameters for attribute.
 * 
 * @param object $model Instance of the class CActiveRecord
 * @return boolean
 */
protected function saveParams($model)
{       
    $notInDelete = array(); //array parameters for delete
     
    //save new parameters
    foreach ($model->param as $param)
    {
        if (!$param->save(false))  
        {
            return false;
        }
 
        $notInDelete[] =  $param->id;
    }
     
    //Deleting parameters which has been deleted.
    if (!empty($notInDelete))
    {
        $criteria = new CDbCriteria();
        $criteria
            ->addNotInCondition('t.id', $notInDelete)
            ->compare('attr_id', $model->id);
        $paramsToDelete = ShopAttributeParam::model()->findAll($criteria);
    }
    else //delete all paramters for attribute
    {
        $paramsToDelete = ShopAttributeParam::model()->findAll(
            'attr_id=:id',
            array(':id' => $model->id)
        );
    }
     
    foreach ($paramsToDelete as $item)
    {
        if (!$item->delete())
        {
            return false;
        }
    }
     
    return true;
}

В методе actionUpdate я сначала сохраняю "родительскую" модель для того, чтобы получить ID атрибута и прописать его в параметрах. А так же я проверяю честность пользователей решивших тасовать данные. Но самое главное я делаю валидацию полей и если валидация для какого-то поля прошла неудачно, я увеличиваю счётчик $errorValidate на +1. Далее всё это дело помещаю в свойство param и если всё прошло гладко вызываю метод saveParam где уже окончательно сохраняю параметры и удаляю ненужные.

Валидацию для динамических полей в модели ShopAttributeParam делайте, как делали раньше. Единственный минус в том, что message для валидации придётся переписывать как вам удобно. У меня это выглядит так:

public function rules()
{
    return array(
        array('value', 'required', 'message' => Yii::t('ShopModule.core', 'Field can not be empty.')),
        array(
            'value',
            'length',
            'max' => 1000,
            'tooLong' => Yii::t(
                            'ShopModule.core', '{attribute} is too long (maximum is {max} characters).',
                            array(
                                '{attribute}' => Yii::t('ShopModule.core', 'Value of field'),
                                '{max}' => 1000
                            )
            ),
        ),
    );
}

Заключение

Обращаясь в action контроллера у нас есть два пути: получить атрибут по ID или создать новый. Если мы получаем по ID то в view у нас происходит отображение существующих полей. Всё остальное одинаково для обоих случаев. Логика работы кода совершенно проста. Не нужно пугаться этого кода, если кого-то он испугал. Самое главное что нужно уяснить, это то, что нам нужна валидация. Именно для неё мы и устроили этот концерт. Собственно, читая код, это видно и ясно. Если найдёте несостыковки - не пугайтесь. В моём проекте это немного не так. Там есть ёще много чего, что не относится к теме, поэтому пришлось перелапачивать код, чтобы не выйти за рамки статьи.

Возможно данное решение не само лучшее, но меня оно полностью устраивает. Надеюсь, что помог людям ищущим ответ на вопрос по работе с динамическими полями.