Композитные формы в Yii2

При разработке с отделением моделей форм от доменных сущностей (чему мы посвятили недавний цикл статей) поначалу возникает неудобство копирования повторяющихся полей из формы в форму. В одном из уроков мастер-класса по Yii2 мы познакомились c решением построения вложенных форм. Рассмотрим тот код и оформим его в самодостаточное публичное расширение.

Начнём с постановки задачи.

Начало проекта

Предположим, что мы сделали форму создания товара:

class ProductCreateForm extends Model
{
    public $code;
    public $name;
 
    public $price_new;
    public $price_old;
 
    public $meta_title;
    public $meta_description;
 
    public function rules()
    {
        return [
            [['code', 'name', 'price_new'], 'required'],
            [['code', 'name', 'meta_title'], 'string', 'max' => 255],
            [['code'], 'unique', 'targetClass' => Brand::class],
            [['meta_description'], 'string'],
            [['price_new', 'price_old'], 'integer'],
        ];
    }
}

и используем эту форму в своём контроллере:

class ProductController extends Controller
{
    private $service;
 
    public function __construct($id, $module, ProductManageService $service, $config = [])
    {
        $this->service = $service;
        parent::__construct($id, $module, $config);
    }
 
    ...
 
    public function actionCreate()
    {
        $form = new ProductCreateForm();
 
        if ($form->load(Yii::$app->request->post()) && $form->validate()) {
            $id = $this->service->create($form);
            return $this->redirect(['view', 'id' => $id]);
        }
 
        return $this->render('create', [
            'model' => $form,
        ]);
    }
}

передавая в прикладной сервис:

class ProductManageService
{
    private $products;
 
    public function __construct(ProductRepository $products)
    {
        $this->products = $products;
    }
 
    public function create(ProductCreateForm $form)
    {
        $product = Product::create(
            $form->code,
            $form->name,
            new Meta(
                $form->meta_title,
                $form->meta_description
            )
        );
 
        $product->changePrice($form->price_new, $form->price_old);
 
        $this->products->save($product);
 
        return $product->id;
    }
 
    ...
}

Всё бы ничего, но в какой-то момент это может оказаться неудобным. А именно, при добавлении на сайт сущностей блога потребуется те же SEO-поля добавить и в форму PostForm:

class PostForm extends Model
{
    public $title;
    public $content;
 
    public $meta_title;
    public $meta_description;
 
    public function rules()
    {
        return [
            [['title'], 'required'],
            [['title', 'meta_title'], 'string', 'max' => 255],
            [['content', 'meta_description'], 'string'],
        ];
    }
}

Аналогично проблемы могут возникать при добавлении форм для отдельных операций. Например, при вынесении PriceForm для изменения цены товара в отдельном действии нужно копировать эти же поля price_new и price_old с их правилами валидации и их attributeLabels в эту новую PriceForm:

class PriceForm extends Model
{
    public $new;
    public $old;
 
    public function __construct(Product $product = null, $config = [])
    {
        if ($product) {
            $this->new = $product->price_new;
            $this->old = $product->price_old;
        }
        parent::__construct($config);
    }
 
    public function rules()
    {
        return [
            [['new'], 'required'],
            [['new', 'old'], 'integer'],
        ];
    }
 
    public function attributeLabels()
    {
        return [
            'new' => 'Текущая цена',
            'old' => 'Прошлая цена',
        ];
    }
}

И использовать её в actionPrice:

class ProductController extends Controller
{
    ...
 
    public function actionCreate()
    {
        $form = new ProductCreateForm();
 
        if ($form->load(Yii::$app->request->post()) && $form->validate()) {
            $id = $this->service->create($form);
            return $this->redirect(['view', 'id' => $id]);
        }
 
        return $this->render('create', [
            'model' => $form,
        ]);
    }
 
    public function actionPrice($id)
    {
        $product = $this->findModel($id);
 
        $form = new PriceForm($product);
 
        if ($form->load(Yii::$app->request->post()) && $form->validate()) {
            $id = $this->service->changePrice($form);
            return $this->redirect(['view', 'id' => $id]);
        }
 
        return $this->render('price', [
            'model' => $form,
        ]);
    }
}

Возникает проблема повторения кода: нам приходится снова и снова копировать одни и те же группы полей с правилами валидации в разные модели форм.

Попробуем решить эту проблему, воспользовавшись уже привычными нам архитектурными методами.

Разделение ответственностей

Дабы не копировать эти вещи, можно вынести раздельные по смыслу группы полей в отдельные формы. Помимо уже имеющейся PriceForm сделаем ещё и MetaForm:

class MetaForm extends Model
{
    public $title;
    public $description;
 
    public function rules()
    {
        return [
            [['title'], 'required'],
            [['title', 'description'], 'string'],
        ];
    }
}

и освободим форму ProductCreateForm от их полей:

class ProductCreateForm extends Model
{
    public $code;
    public $name;
 
    public function rules()
    {
        return [
            [['code', 'name'], 'required'],
            [['code', 'name'], 'string', 'max' => 255],
            [['code'], 'unique', 'targetClass' => Brand::class],
        ];
    }
}

Дополнительно можно создать форму для значений динамических атрибутов товара:

class ValueForm extends Model
{
    public $value;
 
    private $_characteristic;
 
    public function __construct(Characteristic $characteristic, $config = [])
    {
        $this->_characteristic = $characteristic;
        parent::__construct($config);
    }
 
    public function rules(): array
    {
        return [
            ['value', 'safe'],
        ];
    }
 
    public function attributeLabels(): array
    {
        return [
            'value' => $this->_characteristic->name,
        ];
    }
 
    public function getCharacteristicId(): int
    {
        return $this->_characteristic->id;
    }
}

чтобы в цикле заполнять их динамическими характеристиками для последующего табличного ввода:

$valueForms = array_map(function (Characteristic $characteristic) {
    return new ValueForm($characteristic);
}, Characteristic::find()->orderBy('sort')->all());

Это избавит от копирования, но вместе с этим усложнит контроллер, так как он теперь должен оперировать четырьмя формами вместо одной:

class ProductController extends Controller
{
    ...
 
    public function actionCreate()
    {
        $productForm = new ProductCreateForm();
        $priceForm = new PriceForm();       
        $metaForm = new MetaForm();
        $valueForms = array_map(function (Characteristic $characteristic) {
            return new ValueForm($characteristic);
        }, Characteristic::find()->orderBy('sort')->all()); 
 
        $data = Yii::$app->request->post();
 
        $validProduct = $productForm->load($data) && $productForm->validate();
        $validPrice = $priceForm->load($data) && $priceForm->validate();
        $validMeta = $metaForm->load($data) && $metaForm->validate();
        $validValues = Model::loadMultiple($valueForms) && Model::validateMultiple($valueForms);
 
        if ($validProduct && $validPrice && $validMeta && $validValues) {
            $id = $this->service->create($productForm, $priceForm, $metaForm, $valueForms);
            return $this->redirect(['view', 'id' => $id]);
        }
 
        return $this->render('create', [
            'productForm' => $productForm,
            'priceForm' => $priceForm,
            'metaForm' => $metaForm,
            'valueForms' => $valueForms,
        ]);
    }
}

и аналогично рендерить поля ввода из разных моделей ввода в представлении:

<?php
<?php $form = ActiveForm::begin(); ?>
 
    <h2>Common</h2>
 
    <?= $form->field($productForm, 'code')->textInput() ?>
    <?= $form->field($productForm, 'name')->textInput() ?>
 
    <h2>Price</h2>
 
    <?= $form->field($priceForm, 'new')->textInput() ?>
    <?= $form->field($priceForm, 'old')->textInput() ?>
 
    <h2>Characteristics</h2>
 
    <?php foreach ($valueForms as $i => $valueForm): ?>
        <?= $form->field($valueForm, '[' . $i . ']value')->textInput() ?>
    <?php endforeach; ?>
 
    <h2>SEO</h2>
 
    <?= $form->field($metaForm, 'title')->textInput() ?>
    <?= $form->field($metaForm, 'description')->textarea(['rows' => 2]) ?>
 
    <div class="form-group">
        <?= Html::submitButton('Save', ['class' => 'btn btn-success']) ?>
    </div>
 
<?php ActiveForm::end(); ?>

Это усложнит и прикладной сервис приёмом нескольких форм:

class ProductManageService
{
    private $products;
 
    public function __construct(ProductRepository $products)
    {
        $this->products = $products;
    }
 
    public function create(
        ProductCreateForm $productForm,
        PriceForm $priceForm,
        MetaForm $metaForm,
        array $valueForms
    )
    {
        $product = Product::create(
            $productForm->code,
            $productForm->name,
            new Meta(
                $metaForm->title,
                $metaForm->description
            )
        );
 
        $product->changePrice($priceForm->new, $priceForm->old);
 
        foreach ($valueForms as $valueForm) {
            $product->changeValue($valueForm->getCharacteristicId(), $valueForm->value);
        }
 
        $this->products->save($product);
        return $product->id;
    }
 
    ...
}

С кучами форм не очень удобно работать в контроллерах. Приходится валидировать каждую по отдельности. Код станет ещё сложнее, если мы захотим добавить в контроллер обработчик Ajax-валидации. И этот же код нужно копировать в контроллер API.

Можно ли упростить манипуляции с этими формами? Можно, если их как-нибудь склеить в одну большую композитную форму. Этим и займёмся.

Композиция

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

Попробуем сделать что-то подобное в Yii2.

В простейшем случае мы можем построить каскадную модель формы примерно так:

/**
 * @property PriceForm $price;
 * @property MetaForm $meta;
 */
class ProductCreateForm extends Model
{
    public $code;
    public $name;
 
    private $_price;
    private $_meta;
    private $_values;
 
    public function __construct($config = [])
    {
        $this->_price = new PriceForm();
        $this->_meta = new MetaForm();
 
        $this->_values = array_map(function (Characteristic $characteristic) {
            return new ValueForm($characteristic);
        }, Characteristic::find()->orderBy('sort')->all()); 
 
        parent::__construct($config);
    }
 
    public function rules(): array
    {
        return [
            [['code', 'name'], 'required'],
            [['code', 'name'], 'string', 'max' => 255],
            [['code'], 'unique', 'targetClass' => Brand::class],
        ];
    }
 
    public function getPrice()
    {
        return $this->_price;
    }
 
    public function getMeta()
    {
        return $this->_meta;
    }
 
    public function getValues()
    {
        return $this->_values;
    }
}

Мы вложили PriceForm, MetaForm и массив из ValueForm в приватные поля и добавили геттеры для доступа к ним. Теперь можно работать с полями основной модели формы и вложенных:

class ProductManageService
{
    private $products;
 
    public function __construct(ProductRepository $products)
    {
        $this->products = $products;
    }
 
    public function create(ProductCreateForm $form)
    {
        $product = Product::create(
            $form->code,
            $form->name,
            new Meta(
                $form->meta->title,
                $form->meta->description
            )
        );
 
        $product->changePrice($form->price->new, $form->price->old);
 
        foreach ($form->values as $valueForm) {
            $product->changeValue($valueForm->getCharacteristicId(), $valueForm->value);
        }
 
        $this->products->save($product);
        return $product->id;
    }
 
    ...
}

Здесь уже используем вызов $form->meta->title для получения значения из MetaForm. Аналогично при рендере HTML-формы используем вложенные объекты вроде $model->meta:

<?php
<h2>Common</h2>
 
<?= $form->field($model, 'code')->textInput() ?>
<?= $form->field($model, 'name')->textInput() ?>
 
<h2>Price</h2>
 
<?= $form->field($model->price, 'new')->textInput() ?>
<?= $form->field($model->price, 'old')->textInput() ?>
 
<h2>Characteristics</h2>
 
<?php foreach ($model->values as $i => $valueForm): ?>
    <?= $form->field($valueForm, '[' . $i . ']value')->textInput() ?>
<?php endforeach; ?>
 
<h2>SEO</h2>
 
<?= $form->field($model->meta, 'title')->textInput() ?>
<?= $form->field($model->meta, 'description')->textarea(['rows' => 2]) ?>

Контроллер теперь будет создавать только экземпляр основной модели формы, но также будет производить валидацию вручную:

class ProductController extends Controller
{
    ...
 
    public function actionCreate()
    {
        $form = new ProductCreateForm();
 
        $data = Yii::$app->request->post();
 
        $validProduct = $form->load($data) && $form->validate();
        $validPrice = $form->price->load($data) && $form->price->validate();
        $validMeta = $form->meta->load($data) && $form->meta->validate();
        $validValues = Model::loadMultiple($form->values, $data) && Model::validateMultiple($form->values);
 
        if ($validProduct && $validPrice && $validMeta) {
            $id = $this->service->create($form);
            return $this->redirect(['view', 'id' => $id]);
        }
 
        return $this->render('create', [
            'model' => $form,
        ]);
    }
}

Это не очень удобно, так как придётся этот код копировать из контроллера в контроллер, если будем ещё и делать REST API.

Попробуем скрыть и автоматизировать этот процесс.

Инкапсуляция

Чтобы не загружать и валидировать все элементы снаружи в контроллере, переопределим методы load и validate самой ProductCreateForm и инкапсулируем загрузку и валидацию всех вложенных объектов прямо туда:

/**
 * @property PriceForm $price;
 * @property MetaForm $meta;
 */
class ProductCreateForm extends Model
{
    public $code;
    public $name;
 
    private $_price;
    private $_meta;
    private $_values;
 
    public function __construct($config = [])
    {
        $this->_price = new PriceForm();
        $this->_meta = new MetaForm();
        $this->_values = array_map(function (Characteristic $characteristic) {
            return new ValueForm($characteristic);
        }, Characteristic::find()->orderBy('sort')->all()); 
        parent::__construct($config);
    }
 
    public function load($data, $formName = null)
    {
        $loadSelf = parent::load($data, $formName);
        $loadPrice = $this->_price->load($data, $formName === null ? null : 'price');
        $loadMeta = $this->_meta->load($data, $formName === null ? null : 'meta');
        $loadValues = Model::loadMultiple($this->_values, $data, $formName === null ? null : 'values');
 
        return $loadSelf && $loadPrice && $loadMeta && $loadValues;
    }
 
    public function validate($attributeNames = null, $clearErrors = true)
    {
        $validateSelf = parent::validate($attributeNames, $clearErrors);
        $validatePrice = $this->_price->validate(null, $clearErrors);
        $validateMeta = $this->_meta->validate(null, $clearErrors);
        $validateValues = Model::validateMultiple($this->_values, $clearErrors);
 
        return $validateSelf && $validatePrice && $validateMeta && $validateValues;
    }
 
    public function rules()
    {
        return [
            [['code', 'name'], 'required'],
            [['code', 'name'], 'string', 'max' => 255],
            [['code'], 'unique', 'targetClass' => Brand::class],
        ];
    }
 
    ...
}

Теперь можно вернуть первоначальный код контроллера:

class ProductController extends Controller
{
    ...
 
    public function actionCreate()
    {
        $form = new ProductCreateForm();
 
        if ($form->load(Yii::$app->request->post()) && $form->validate()) {
            $id = $this->service->create($form);
            return $this->redirect(['view', 'id' => $id]);
        }
 
        return $this->render('create', [
            'model' => $form,
        ]);
    }
}

так как $form->load(...) и $form->validate() теперь заполняют и валидируют всю кучу вложенных объектов.

По такому принципу с переопределением load и validate можно строить и остальные формы.

Повторное использование

Модели форм получаются громоздкими. И чтобы не копировать один и тот же код из модели в модель, мы можем вынести обобщённый переопределённый код методов loadи validate в абстрактный класс. В простейшем случае он может быть таким:

use yii\base\Model;
use yii\helpers\ArrayHelper;
 
abstract class CompositeForm extends Model
{
    /**
     * @var Model[]
     */
    private $_forms = [];
 
    abstract protected function internalForms();
 
    public function load($data, $formName = null)
    {
        $success = parent::load($data, $formName);
        foreach ($this->_forms as $name => $form) {
            if (is_array($form)) {
                $success = Model::loadMultiple($form, $data, $formName === null ? null : $name) || $success;
            } else {
                $success = $form->load($data, $formName !== '' ? null : $name) || $success;
            }
        }
        return $success;
    }
 
    public function validate($attributeNames = null, $clearErrors = true)
    {
        if ($attributeNames !== null) {
            $parentNames = array_filter($attributeNames, 'is_string');
            $success = $parentNames ? parent::validate($parentNames, $clearErrors) : true;
        } else {
            $success = parent::validate(null, $clearErrors);
        }
        foreach ($this->_forms as $name => $form) {
            if ($attributeNames === null || array_key_exists($name, $attributeNames) || in_array($name, $attributeNames, true)) {
                $innerNames = ArrayHelper::getValue($attributeNames, $name);
                if (is_array($form)) {
                    $success = Model::validateMultiple($form, $innerNames) && $success;
                } else {
                    $success = $form->validate($innerNames, $clearErrors) && $success;
                }
            }
        }
        return $success;
    }
 
    public function __get($name)
    {
        if (isset($this->_forms[$name])) {
            return $this->_forms[$name];
        }
        return parent::__get($name);
    }
 
    public function __set($name, $value)
    {
        if (in_array($name, $this->internalForms(), true)) {
            $this->_forms[$name] = $value;
        } else {
            parent::__set($name, $value);
        }
    }
 
    public function __isset($name)
    {
        return isset($this->_forms[$name]) || parent::__isset($name);
    }
}

В него мы:

  • добавили массив $_forms для хранения произвольного числа вложенных объектов;
  • перенесли переопределённые методы load и validate;
  • переписали load и validate так, что они могут работать как с единичными объектами вроде форм meta и price, так и с массивами объектов вроде values;
  • в первой строке метода load добавили защиту, чтобы load не возвращал false, если у внешней формы нет своих атрибутов, а есть только вложенные модели;
  • методу validate добавили возможность указывать дерево атрибутов для проверки вроде validate(['code', 'price', 'meta' => ['title']]);
  • перенесли геттеры и сеттер посредством магических методов __get, __set и __isset;
  • добавили абстрактный метод internalForms, в котором будем указывать список вложенных объектов.

Теперь любую модель формы можно отнаследовать от CompositeForm, указав и заполнив внутренние виртуальные поля:

/**
 * @property PriceForm $price;
 * @property MetaForm $meta;
 */
class ProductCreateForm extends CompositeForm
{
    public $code;
    public $name;
 
    public function __construct($config = [])
    {
        $this->price = new PriceForm();
        $this->meta = new MetaForm();
        $this->values = array_map(function (Characteristic $characteristic) {
            return new ValueForm($characteristic);
        }, Characteristic::find()->orderBy('sort')->all()); 
        parent::__construct($config);
    }
 
    public function rules(): array
    {
        return [
            [['code', 'name'], 'required'],
            [['code', 'name'], 'string', 'max' => 255],
            [['code'], 'unique', 'targetClass' => Brand::class],
        ];
    }
 
    protected function internalForms()
    {
        return ['price', 'meta', 'values'];
    }
}

Если же мы захотим использовать эту модель ввода в стандартной реализации REST API вроде такой:

class ProductController extends \yii\rest\Controller
{
    ...
 
    public function actionCreate()
    {
        $form = new ProductCreateForm();
        $form->load(Yii::$app->request->getBodyParams());
 
        if ($form->validate()) {
            $id = $this->service->create($form);
            $response = Yii::$app->getResponse();
            $response->setStatusCode(201);
            $response->getHeaders()->set('Location', Url::to(['view', 'id' => $id], true));
            return [];
        }
 
        return $form;
    }
}

то нужно будет переопределить и методы hasErrors и getFirstErrors, чтобы yii\rest\Serializer в своём serializeModelErrors смог построить JSON-ответ с ошибками валидации всех вложенных объектов, которые есть в $form. Это мы скоро сделаем. А пока сделаем для расширения отдельный проект.

Подготовка расширения

Перед выкладыванием любого расширения в публичный доступ нужно удостовериться, что оно действительно работает.

Создадим заготовку проекта:

├── src
│   └── CompositeForm.php
├── tests
│   ├── bootstrap.php
│   └── TestCase.php
├── vendor
├── .gitignore
├── composer.json
├── phpunit.xml.dist
├── README.md
└── LICENCE.md

Опишем его зависимости и пространства имён в composer.json и подключим asset-packagist:

{
    "name": "elisdn/yii2-composite-form",
    "description": "Nested forms base class for Yii2 Framework.",
    "type": "yii2-extension",

    ...

    "require": {
        "yiisoft/yii2": "~2.0"
    },
    "require-dev": {
        "phpunit/phpunit": "4.*"
    },
    "autoload": {
        "psr-4": {
            "elisdn\\compositeForm\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "elisdn\\compositeForm\\tests\\": "tests/"
        }
    },
    "repositories": [
        {
            "type": "composer",
            "url": "https://asset-packagist.org"
        }
    ]
}

Проигнорируем ненужные для репозитория файлы в .gitgnore:

/vendor
/composer.lock
/phpunit.xml

Добавим стандартный phpunit.xml.dist:

<?xml version="1.0" encoding="utf-8"?>
<phpunit bootstrap="./tests/bootstrap.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Test Suite">
            <directory>./tests</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist>
            <directory suffix=".php">./src/</directory>
        </whitelist>
    </filter>
</phpunit>

и tests/bootstrap.php:

<?php
<?php
 
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'test');
 
require(__DIR__ . '/../vendor/autoload.php');
require(__DIR__ . '/../vendor/yiisoft/yii2/Yii.php');

Теперь выполним composer install, чтобы подгрузить фреймворк в vendor.

После этого создадим тестовые модели форм в директории tests/_forms, на которых мы будем проверять работу нашего класса.

Это основная форма:

namespace elisdn\compositeForm\tests\_forms;
 
use elisdn\compositeForm\CompositeForm;
 
/**
 * @property MetaForm $meta
 * @property ValueForm[] $values
 */
class ProductForm extends CompositeForm
{
    public $code;
    public $name;
 
    /**
     * @param integer $valuesCount
     * @param array $config
     */
    public function __construct($valuesCount, $config = [])
    {
        $this->meta = new MetaForm();
        $this->values = $valuesCount ? array_map(function () {
            return new ValueForm();
        }, range(1, $valuesCount)) : [];
        parent::__construct($config);
    }
 
    public function rules()
    {
        return [
            [['code'], 'required'],
            [['code', 'name'], 'string'],
        ];
    }
 
    protected function internalForms()
    {
        return ['meta', 'values'];
    }
}

В конструктор мы будем передавать число элементов values, которые нужно будет создать.

И рядом с ней добавим MetaForm:

class MetaForm extends Model
{
    public $title;
    public $description;
 
    public function rules()
    {
        return [
            [['title'], 'required'],
            [['title', 'description'], 'string'],
        ];
    }
}

и ValueForm:

class ValueForm extends Model
{
    public $value;
 
    public function rules()
    {
        return [
            ['value', 'required'],
        ];
    }
}

Также для проверки правильности работы load сделаем ещё одну внешнюю форму, у которой не будет своих полей, а будут только вложенные объекты:

/**
 * @property MetaForm $meta
 */
class OnlyNestedProductForm extends CompositeForm
{
    public function __construct($config = [])
    {
        $this->meta = new MetaForm();
        parent::__construct($config);
    }
 
    protected function internalForms()
    {
        return ['meta'];
    }
}

Когда формы готовы, создадим базовый класс для тестов:

namespace elisdn\compositeForm\tests;
 
use yii\console\Application;
 
abstract class TestCase extends \PHPUnit_Framework_TestCase
{
    protected function setUp()
    {
        parent::setUp();
        $this->mockApplication();
    }
 
    protected function tearDown()
    {
        $this->destroyApplication();
        parent::tearDown();
    }
 
    protected function mockApplication()
    {
        new Application([
            'id' => 'testapp',
            'basePath' => __DIR__,
            'vendorPath' => dirname(__DIR__) . '/vendor',
            'runtimePath' => __DIR__ . '/runtime',
        ]);
    }
 
    protected function destroyApplication()
    {
        \Yii::$app = null;
    }
}

И напишем эмуляцию заполнения наших моделей форм данными, приходящими из виджета ActiveForm, с проверкой на правильность заполнения всех полей:

namespace elisdn\compositeForm\tests;
 
use elisdn\compositeForm\tests\_forms\OnlyNestedProductForm;
use elisdn\compositeForm\tests\_forms\ProductForm;
 
class LoadActiveFormTest extends TestCase
{
    public function testWholeForm()
    {
        $data = [
            'ProductForm' => [
                'code' => 'P100',
                'name' => 'Product Name',
            ],
            'MetaForm' => [
                'title' => 'Meta Title',
                'description' => 'Meta Description',
            ],
            'ValueForm' => [
                ['value' => '101'],
                ['value' => '102'],
                ['value' => '103'],
            ],
        ];
 
        $form = new ProductForm(3);
 
        $this->assertTrue($form->load($data));
 
        $this->assertEquals($data['ProductForm']['code'], $form->code);
        $this->assertEquals($data['ProductForm']['name'], $form->name);
 
        $this->assertEquals($data['MetaForm']['title'], $form->meta->title);
        $this->assertEquals($data['MetaForm']['description'], $form->meta->description);
 
        $this->assertCount(3, $values = $form->values);
 
        $this->assertEquals($data['ValueForm'][0]['value'], $values[0]->value);
        $this->assertEquals($data['ValueForm'][1]['value'], $values[1]->value);
        $this->assertEquals($data['ValueForm'][2]['value'], $values[2]->value);
    }
 
    public function testOnlyInternalForms()
    {
        $data = [
            'MetaForm' => [
                'title' => 'Meta Title',
                'description' => 'Meta Description',
            ],
        ];
 
        $form = new OnlyNestedProductForm();
 
        $this->assertTrue($form->load($data));
 
        $this->assertEquals($data['MetaForm']['title'], $form->meta->title);
        $this->assertEquals($data['MetaForm']['description'], $form->meta->description);
    }
}

По аналогии нужно проверить, как это будет заполняться вложенными данными из JSON-запроса по API:

namespace elisdn\compositeForm\tests;
 
use elisdn\compositeForm\tests\_forms\OnlyNestedProductForm;
use elisdn\compositeForm\tests\_forms\ProductForm;
 
class LoadApiTest extends TestCase
{
    public function testWholeForm()
    {
        $data = [
            'code' => 'P100',
            'name' => 'Product Name',
            'meta' => [
                'title' => 'Meta Title',
                'description' => 'Meta Description',
            ],
            'values' => [
                ['value' => '101'],
                ['value' => '102'],
                ['value' => '103'],
            ],
        ];
 
        $form = new ProductForm(3);
 
        $this->assertTrue($form->load($data, ''));
 
        $this->assertEquals($data['code'], $form->code);
        $this->assertEquals($data['name'], $form->name);
 
        $this->assertEquals($data['meta']['title'], $form->meta->title);
        $this->assertEquals($data['meta']['description'], $form->meta->description);
 
        $this->assertCount(3, $values = $form->values);
 
        $this->assertEquals($data['values'][0]['value'], $values[0]->value);
        $this->assertEquals($data['values'][1]['value'], $values[1]->value);
        $this->assertEquals($data['values'][2]['value'], $values[2]->value);
    }
 
    public function testOnlyInternalForms()
    {
        $data = [
            'meta' => [
                'title' => 'Meta Title',
                'description' => 'Meta Description',
            ],
        ];
 
        $form = new OnlyNestedProductForm();
 
        $this->assertTrue($form->load($data, ''));
 
        $this->assertEquals($data['meta']['title'], $form->meta->title);
        $this->assertEquals($data['meta']['description'], $form->meta->description);
    }
}

И напишем ValidateTest, который будет эмулировать разные ситуации и проверять, как у нас будет производиться валидация внешней модели и внутренних, и как себя при этом ведут методы validate, hasErrors, getErrors и getFirstErrors с передачей им различных аргументов:

namespace elisdn\compositeForm\tests;
 
use elisdn\compositeForm\tests\_forms\OnlyNestedProductForm;
use elisdn\compositeForm\tests\_forms\ProductForm;
 
class ValidateTest extends TestCase
{
    public function testValidWholeForm()
    {
        $data = [
            'code' => 'P100',
            'name' => 'Product Name',
            'meta' => [
                'title' => 'Meta Title',
                'description' => 'Meta Description',
            ],
            'values' => [
                ['value' => '101'],
                ['value' => '102'],
                ['value' => '103'],
            ],
        ];
 
        $form = new ProductForm(3);
 
        $form->load($data, '');
 
        $this->assertTrue($form->validate());
        $this->assertFalse($form->hasErrors());
        $this->assertEmpty($form->getErrors());
    }
 
    public function testValidWithoutValues()
    {
        $data = [
            'code' => 'P100',
            'name' => 'Product Name',
            'meta' => [
                'title' => 'Meta Title',
                'description' => 'Meta Description',
            ],
            'values' => [],
        ];
 
        $form = new ProductForm(0);
 
        $form->load($data, '');
 
        $this->assertTrue($form->validate());
        $this->assertFalse($form->hasErrors());
        $this->assertEmpty($form->getErrors());
    }
 
    public function testNotValidWholeForm()
    {
        $data = [
            'code' => null,
            'name' => 'Product Name',
            'meta' => [
                'title' => null,
                'description' => 'Meta Description',
            ],
            'values' => [
                ['value' => '101'],
                ['value' => ''],
                ['value' => '103'],
            ],
        ];
 
        $form = new ProductForm(3);
 
        $form->load($data, '');
 
        $this->assertFalse($form->validate());
        $this->assertTrue($form->hasErrors());
 
        $this->assertEquals([
            'code' => ['Code cannot be blank.'],
            'meta.title' => ['Title cannot be blank.'],
            'values.1.value' => ['Value cannot be blank.'],
        ], $form->getErrors());
 
        $this->assertEquals(['Code cannot be blank.'], $form->getErrors('code'));
        $this->assertEquals(['Title cannot be blank.'], $form->getErrors('meta.title'));
        $this->assertEquals(['Value cannot be blank.'], $form->getErrors('values.1.value'));
 
        $this->assertEquals([], $form->getErrors('name'));
        $this->assertEquals([], $form->getErrors('meta.description'));
        $this->assertEquals([], $form->getErrors('values.2.value'));
 
        $this->assertTrue($form->hasErrors('code'));
        $this->assertFalse($form->hasErrors('name'));
        $this->assertTrue($form->hasErrors('meta.title'));
        $this->assertFalse($form->hasErrors('meta.description'));
        $this->assertTrue($form->hasErrors('values.1.value'));
        $this->assertFalse($form->hasErrors('values.2.value'));
 
        $this->assertEquals([
            'code' => 'Code cannot be blank.',
            'meta.title' => 'Title cannot be blank.',
            'values.1.value' => 'Value cannot be blank.',
        ], $form->getFirstErrors());
    }
 
    public function testNotValidInternalForms()
    {
        $data = [
            'code' => 'P100',
            'name' => 'Product Name',
            'meta' => [
                'title' => null,
                'description' => 'Meta Description',
            ],
            'values' => [
                ['value' => '101'],
                ['value' => ''],
                ['value' => '103'],
            ],
        ];
 
        $form = new ProductForm(3);
 
        $form->load($data, '');
 
        $this->assertFalse($form->validate());
        $this->assertTrue($form->hasErrors());
 
        $this->assertEquals([
            'meta.title' => ['Title cannot be blank.'],
            'values.1.value' => ['Value cannot be blank.'],
        ], $form->getErrors());
 
        $this->assertFalse($form->hasErrors('code'));
        $this->assertTrue($form->hasErrors('meta.title'));
        $this->assertTrue($form->hasErrors('values.1.value'));
 
        $this->assertEquals([
            'meta.title' => 'Title cannot be blank.',
            'values.1.value' => 'Value cannot be blank.',
        ], $form->getFirstErrors());
    }
 
    public function testValidAttributeNames()
    {
        $data = [
            'code' => 'P100',
            'name' => 'Product Name',
            'meta' => [
                'title' => 'Meta Title',
                'description' => 'Meta Description',
            ],
            'values' => [
                ['value' => '101'],
                ['value' => '103'],
            ],
        ];
 
        $form = new ProductForm(0);
 
        $form->load($data, '');
 
        $this->assertTrue($form->validate(['code']));
        $this->assertTrue($form->validate(['name']));
        $this->assertTrue($form->validate(['meta']));
        $this->assertTrue($form->validate(['meta' => ['title']]));
        $this->assertTrue($form->validate(['meta' => ['description']]));
        $this->assertTrue($form->validate(['meta' => ['title', 'description']]));
        $this->assertTrue($form->validate(['values']));
        $this->assertTrue($form->validate(['values' => ['value']]));
    }
 
    public function testNotValidAttributeNames()
    {
        $data = [
            'code' => null,
            'name' => 'Product Name',
            'meta' => [
                'title' => null,
                'description' => 'Meta Description',
            ],
            'values' => [
                ['value' => '101'],
                ['value' => ''],
            ],
        ];
 
        $form = new ProductForm(2);
 
        $form->load($data, '');
 
        $this->assertFalse($form->validate(['code']));
        $this->assertTrue($form->validate(['name']));
        $this->assertFalse($form->validate(['meta']));
        $this->assertFalse($form->validate(['meta' => ['title']]));
        $this->assertTrue($form->validate(['meta' => ['description']]));
        $this->assertFalse($form->validate(['meta' => ['title', 'description']]));
        $this->assertFalse($form->validate(['values']));
        $this->assertFalse($form->validate(['values' => ['value']]));
    }
 
    public function testValidOnlyNestedForms()
    {
        $data = [
            'meta' => [
                'title' => 'Meta Title',
                'description' => 'Meta Description',
            ],
        ];
 
        $form = new OnlyNestedProductForm();
 
        $form->load($data, '');
 
        $this->assertTrue($form->validate());
        $this->assertFalse($form->hasErrors());
        $this->assertEmpty($form->getErrors());
    }
 
    public function testNotValidOnlyNestedForms()
    {
        $data = [
            'meta' => [
                'title' => null,
                'description' => 'Meta Description',
            ],
        ];
 
        $form = new OnlyNestedProductForm();
 
        $form->load($data, '');
 
        $this->assertFalse($form->validate());
        $this->assertTrue($form->hasErrors());
 
        $this->assertEquals([
            'meta.title' => ['Title cannot be blank.'],
        ], $form->getErrors());
 
        $this->assertEquals([
            'meta.title' => 'Title cannot be blank.',
        ], $form->getFirstErrors());
    }
}

Ещё для надёжности мы проверили правильность валидации нашей формы OnlyNestedProductForm, не имеющей своих полей.

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

Получили такую структуру:

├── src
│   └── CompositeForm.php
├── tests
│   ├── bootstrap.php
│   ├── _forms
│   │   ├── MetaForm.php
│   │   ├── OnlyNestedProductForm.php
│   │   ├── ProductForm.php
│   │   └── ValueForm.php
│   ├── TestCase.php
│   ├── LoadActiveFormTest.php
│   ├── LoadApiTest.php
│   └── ValidateTest.php
├── vendor
├── .gitignore
├── composer.json
├── phpunit.xml.dist
├── README.md
└── LICENCE.md

Теперь запускаем тесты:

vendor/bin/phpunit

и дорабатываем код CompositeForm до тех пор, пока они все не пройдут:

PHPUnit 4.8.36 by Sebastian Bergmann and contributors.

............

Time: 242 ms, Memory: 6.00MB

OK (12 test, 76 assertion)

В итоге получаем готовый абстрактный класс:

namespace elisdn\compositeForm;
 
use yii\base\Model;
use yii\helpers\ArrayHelper;
 
abstract class CompositeForm extends Model
{
    /**
     * @var Model[]|array[]
     */
    private $_forms = [];
 
    /**
     * @return array of internal forms like ['meta', 'values']
     */
    abstract protected function internalForms();
 
    public function load($data, $formName = null)
    {
        $success = parent::load($data, $formName);
        foreach ($this->_forms as $name => $form) {
            if (is_array($form)) {
                $success = Model::loadMultiple($form, $data, $formName === null ? null : $name) && $success;
            } else {
                $success = $form->load($data, $formName !== '' ? null : $name) && $success;
            }
        }
        return $success;
    }
 
    public function validate($attributeNames = null, $clearErrors = true)
    {
        if ($attributeNames !== null) {
            $parentNames = array_filter($attributeNames, 'is_string');
            $success = $parentNames ? parent::validate($parentNames, $clearErrors) : true;
        } else {
            $success = parent::validate(null, $clearErrors);
        }
        foreach ($this->_forms as $name => $form) {
            if ($attributeNames === null || array_key_exists($name, $attributeNames) || in_array($name, $attributeNames, true)) {
                $innerNames = ArrayHelper::getValue($attributeNames, $name);
                if (is_array($form)) {
                    $success = Model::validateMultiple($form, $innerNames) && $success;
                } else {
                    $success = $form->validate($innerNames, $clearErrors) && $success;
                }
            }
        }
        return $success;
    }
 
    public function hasErrors($attribute = null)
    {
        if ($attribute !== null && mb_strpos($attribute, '.') === false) {
            return parent::hasErrors($attribute);
        }
        if (parent::hasErrors($attribute)) {
            return true;
        }
        foreach ($this->_forms as $name => $form) {
            if (is_array($form)) {
                foreach ($form as $i => $item) {
                    if ($attribute === null) {
                        if ($item->hasErrors()) {
                            return true;
                        }
                    } elseif (mb_strpos($attribute, $name . '.' . $i . '.') === 0) {
                        if ($item->hasErrors(mb_substr($attribute, mb_strlen($name . '.' . $i . '.')))) {
                            return true;
                        }
                    }
                }
            } else {
                if ($attribute === null) {
                    if ($form->hasErrors()) {
                        return true;
                    }
                } elseif (mb_strpos($attribute, $name . '.') === 0) {
                    if ($form->hasErrors(mb_substr($attribute, mb_strlen($name . '.')))) {
                        return true;
                    }
                }
            }
        }
        return false;
    }
 
    public function getErrors($attribute = null)
    {
        $result = parent::getErrors($attribute);
        foreach ($this->_forms as $name => $form) {
            if (is_array($form)) {
                /** @var array $form */
                foreach ($form as $i => $item) {
                    foreach ($item->getErrors() as $attr => $errors) {
                        /** @var array $errors */
                        $errorAttr = $name . '.' . $i . '.' . $attr;
                        if ($attribute === null) {
                            foreach ($errors as $error) {
                                $result[$errorAttr][] = $error;
                            }
                        } elseif ($errorAttr === $attribute) {
                            foreach ($errors as $error) {
                                $result[] = $error;
                            }
                        }
                    }
                }
            } else {
                foreach ($form->getErrors() as $attr => $errors) {
                    /** @var array $errors */
                    $errorAttr = $name . '.' . $attr;
                    if ($attribute === null) {
                        foreach ($errors as $error) {
                            $result[$errorAttr][] = $error;
                        }
                    } elseif ($errorAttr === $attribute) {
                        foreach ($errors as $error) {
                            $result[] = $error;
                        }
                    }
                }
            }
        }
        return $result;
    }
 
    public function getFirstErrors()
    {
        $result = parent::getFirstErrors();
        foreach ($this->_forms as $name => $form) {
            if (is_array($form)) {
                foreach ($form as $i => $item) {
                    foreach ($item->getFirstErrors() as $attr => $error) {
                        $result[$name . '.' . $i . '.' . $attr] = $error;
                    }
                }
            } else {
                foreach ($form->getFirstErrors() as $attr => $error) {
                    $result[$name . '.' . $attr] = $error;
                }
            }
        }
        return $result;
    }
 
    public function __get($name)
    {
        if (isset($this->_forms[$name])) {
            return $this->_forms[$name];
        }
        return parent::__get($name);
    }
 
    public function __set($name, $value)
    {
        if (in_array($name, $this->internalForms(), true)) {
            $this->_forms[$name] = $value;
        } else {
            parent::__set($name, $value);
        }
    }
 
    public function __isset($name)
    {
        return isset($this->_forms[$name]) || parent::__isset($name);
    }
}

С этим кодом уже можно работать.

Класс получился более сложным, чем он был в нашем первом черновом варианте, так как мы добавили переопределение и других методов для обработки всех ситуаций, рассмотренных в тестах. И фреймворк подтолкнул нас к написанию сложного кода с кучей if-ов из-за наличия необязательных параметров у каждого метода, меняющих логику работы.

Если ещё не инициализировали репозиторий, то пора это сделать и подключить новый, созданный на GitHub:

git init
git commit --allow-empty -m 'Initial commit'
git remote add origin git@github.com:ElisDN/yii2-composite-form.git
git push -u origin master

Далее дорабатываем код, коммитим и отправляем:

git add .
git commit -m 'Added extension code'
git push

Теперь исходники доступны в репозитории ElisDN/yii2-composite-form

Далее помечаем релиз меткой 1.0.0:

git tag 1.0.0
git push --tag

и, как делали это раньше с прошлыми расширениями, регистрируем на сайте packagist.org, чтобы его можно было подключать к любому проекту через Composer.

Вот и всё. Готовое расширение доступно для установки в любой проект командой:

composer require elisdn/yii2-composite-form

И с его помощью можно строить модели ввода любой сложности.

Продвинутый пример

Огромную модель для данных формы создания товара из десятков полей можно разбить на восемь мелких и собрать ProductCreateForm из них:

class ProductCreateForm extends CompositeForm
{
    public function __construct($config = [])
    {
        $this->specification = new SpecificationForm();
        $this->price = new PriceForm();
        $this->quantity = new QuantityForm();
        $this->meta = new MetaForm();
        $this->categories = new CategoriesForm();
        $this->photos = new PhotosForm();
        $this->tags = new TagsForm();
        $this->values = array_map(function (Characteristic $characteristic) {
            return new ValueForm($characteristic);
        }, Characteristic::find()->orderBy('sort')->all());
        parent::__construct($config);
    }
 
    protected function internalForms(): array
    {
        return ['specification', 'price', 'quantity', 'meta', 'photos', 'categories', 'tags', 'values'];
    }
}

И каждая часть будет полностью содержать свои поля и свою логику. Например, в модели ввода CategoriesForm будут поле $main для главной категории, поле $others для массива дополнительных категорий и метод categoriesList(), строящий для них дерево на основе Nested Sets:

class CategoriesForm extends Model
{
    public $main;
    public $others = [];
 
    public function __construct(Product $product = null, $config = [])
    {
        if ($product) {
            $this->main = $product->category_id;
            $this->others = ArrayHelper::getColumn($product->categoryAssignments, 'category_id');
        }
        parent::__construct($config);
    }
 
    public function categoriesList()
    {
        $categories = Category::find()->andWhere(['>', 'depth', 0])->orderBy('lft')->asArray()->all();
 
        return ArrayHelper::map($categories, 'id', function (array $category) {
            return ($category['depth'] > 1 ? str_repeat('-- ', $category['depth'] - 1) . ' ' : '') . $category['name'];
        });
    }
 
    public function rules()
    {
        return [
            ['main', 'required'],
            ['main', 'integer'],
            ['others', 'each', 'rule' => ['integer']],
            ['others', 'default', 'value' => []],
        ];
    }
}

для выбора главной в выпадающем списке и выбора дополнительных чекбоксами:

<?php
<?php $list = $model->categories->categoriesList() ?>
<?= $form->field($model->categories, 'main')->dropDownList($list, ['prompt' => '']) ?>
<?= $form->field($model->categories, 'others')->checkboxList($list) ?>

Если в форме редактирования товара нужны не все разделы, то просто собираем её только из нужных фрагментов:

class ProductEditForm extends CompositeForm
{
    public function __construct(Product $product, $config = [])
    {
        $this->specification = new SpecificationForm($product->specification);
        $this->meta = new MetaForm($product->meta);
        $this->categories = new CategoriesForm($product);
        $this->tags = new TagsForm($product);
        $this->values = array_map(function (Characteristic $characteristic) use ($product) {
            return new ValueForm($characteristic, $product->getValue($characteristic->id));
        }, Characteristic::find()->orderBy('sort')->all());
        parent::__construct($config);
    }
 
    protected function internalForms(): array
    {
        return ['specification', 'meta', 'categories', 'tags', 'values'];
    }
}

И аналогично рендерим нужные поля ввода в представлении:

<?php
<?php $form = ActiveForm::begin(); ?>
 
    <h2>Common</h2>
 
    <?= $this->render('_form_specification', ['form' => $form, 'model' => $model->specification]) ?>
 
    <h2>Price</h2>
 
    <?= $this->render('_form_price', ['form' => $form, 'model' => $model->price]) ?>
 
    <h2>Characteristics</h2>
 
    <?= $this->render('_form_values', ['form' => $form, 'models' => $model->values]) ?>
 
    <h2>SEO</h2>
 
    <?= $this->render('_form_meta', ['form' => $form, 'model' => $model->meta]) ?>
 
    <div class="form-group">
        <?= Html::submitButton('Save', ['class' => 'btn btn-success']) ?>
    </div>
 
<?php ActiveForm::end(); ?>

Контроллер оставляем простым:

class ProductController extends Controller
{
    ...
 
    public function actionCreate()
    {
        $form = new ProductCreateForm();
 
        if ($form->load(Yii::$app->request->post()) && $form->validate()) {
            try {
                $id = $this->service->create($form);
                return $this->redirect(['view', 'id' => $id]);
            } catch (\DomainException $e) {
                Yii::$app->errorHandler->logException($e);
                Yii::$app->session->setFlash('error', $e->getMessage());
            }
        }
 
        return $this->render('create', [
            'model' => $form,
        ]);
    }
}

И в прикладном сервисе на основе композитной формы ProductCreateForm создаём товар. А далее отдельно используем PriceForm для изменения цены (например, в модальном окне):

class ProductManageService
{
    private $products;
    private $categories;
    private $tags;
    private $transaction;
 
    public function __construct(
        ProductRepository $products,
        CategoryRepository $categories,
        TagRepository $tags,
        TransactionManager $transaction
    )
    {
        $this->products = $products;
        $this->brands = $brands;
        $this->categories = $categories;
        $this->tags = $tags;
        $this->transaction = $transaction;
    }
 
    public function create(ProductCreateForm $form)
    {
        $category = $this->categories->get($form->categories->main);
 
        $product = Product::create(
            $category->id,
            new Specification(
                $form->specification->code,
                $form->specification->name,
                $form->specification->description
            ),
            $form->quantity->quantity,
            new Meta(
                $form->meta->title,
                $form->meta->description,
                $form->meta->keywords
            )
        );
 
        $product->setPrice($form->price->new, $form->price->old);
 
        foreach ($form->categories->others as $otherId) {
            $category = $this->categories->get($otherId);
            $product->assignCategory($category->id);
        }
 
        foreach ($form->values as $valueForm) {
            $product->setValue($valueForm->getChanracteristicId(), $valueForm->value);
        }
 
        foreach ($form->photos->files as $file) {
            $product->addPhoto($file);
        }
 
        $this->transaction->wrap(function () use ($product, $form) {
            foreach ($form->tags->getNames() as $name) {
                if (!$tag = $this->tags->findByName($name)) {
                    $tag = Tag::create($name);
                    $this->tags->save($tag);
                }
                $product->assignTag($tag->id);
            }
            $this->products->save($product);
        });
 
        return $product->id;
    }
 
    ...
 
    public function changePrice($id, PriceForm $form): void
    {
        $product = $this->products->get($id);
        $product->setPrice($form->new, $form->old);
        $this->products->save($product);
    }
 
    ...
}

Вот и всё.

Такой композицией мы избавляемся от копипасты. И даже при отсутствии повторов такая группировка по смыслу избавляет от разбухания моделей при построении сложных вещей из десятков и сотен полей вроде таких форм. Просто разделяем форму на блоки «Площадь», «Удобства», «Собственник» и подобные и вкладываем соответствующие им модели ввода вроде SquareForm и OwnerForm внутрь CreateForm.

Расширение теперь доступно для подключения через Composer:

composer require elisdn/yii2-composite-form

Если есть вопросы, то пишите в комментариях. Если ещё не с нами, то подписывайтесь на блог. И до встречи в следующей статье!

Комментарии

 

Ковалев Роман

Спасибо за ценную информацию, Дмитрий!
А мне приходило в голову похожие проблемы решать через behavior. А именно - у многих сущностей приложения должно было быть привязано одно или несколько изображений. И, дабы не плодить одинаковый код, я сделал HasImagesBehavior где и производил всю нужную обработку, загрузку, валидацию и т.п.
Однако я не до конца уверен вот в каком своем решении. Может будет не очень в тему, но очень хотелось бы услышать Ваше мнение и сообщества.
Для хранения всех изображений всех сущностей системы я использовал таблицу в БД:

image
id INT
owner_id INT (foreign key на primary key соответствующей сущности)
owner_type INT (идентификатор типа сущности-владельца изображения)

Соответственно, также создал класс модели Image, в котором в виде констант хранятся возможные типы сущностей, к которым может быть привязано изображение. Например:
Image::OWNER_PRODUCT = 1,
Image::OWNER_CATEGORY = 2
и т.п.

Это позволило, создав где-нибудь в сервисном слое метод determineOwnerType($owner), который бы по классу объекта-владельца определял owner_type. Как-то так:

function determineOwnerType($owner)
{
  $classes = [
    Image::TYPE_PRODUCT => Product::className(),
    Image::TYPE_CATEGORY => Category::className(),
  ];
  foreach ($classes as $type => $class) {
    if ($owner instanceof $class) {
      return $type;
    }
  }
  throw new InvalidParamException("Given object is not an instance of valid image-owner class");


Тогда, зная owner_id и определив owner_type мы можем смело извлечь из БД записи всех картинок, которые относятся к данной сущности.

Все бы ничего, но меня смутило то, что объект Image "знает" обо всех его возможных владельцах. То есть, чтобы добавить новую сущность-владельца изображения, нужно добавлять новую константу в класс Image и новую запись в массив $classes, чтобы соотнести константу и класс новой добавляемой сущности. Но спинным мозгом чувствую, так быть не должно. Как я понимаю, объект Image должен быть ниже уровнем и ему должно быть все равно, сколько и каких у него возможных владельцев.

Прошу вашей помощи, может есть какие-то более изящные решения для хранения данных в БД для таких случаев? Может нужно вынести и константы и определение типа полностью в сервис? Но тогда
возникает какое-то чувство нарушения целостности класса Image (например та же валидация класса потребует все равно получить список возможных констант типов владельцев). Как считаете?

Ответить

 

Дмитрий Елисеев

Не понял, про какие говорите проблемы. А так можно сделать ProductImage extends Image и CategoryImage extends Image по STI со своими ProductQuery и CategoryQuery. И у них уже в beforeSave выставлять $this->type = static::class.

Ответить

 

Алексей Хромец

Спасибо!
Было бы круто, если бы в материале также присутствовали скриншоты результатов по ходу написания кода.

Ответить

 

Дмитрий Елисеев

Скриншоты формы из пяти полей? Или какие?

Ответить

 

Александр

Планируете ли развивать это расширение? Например, чтобы можно было влаживать одну композитную форму в другую. Сейчас вложенная форма может быть лишь обычной формой.

Ответить

 

Дмитрий Елисеев

> Сейчас вложенная форма может быть лишь обычной формой.

Почему?

Ответить

 

Александр

Вложенная композитная форма получает в свой метод load() не полный массив POST а только часть соответствующую родительской форме

Ответить

 

Дмитрий Елисеев

Всюду напрямую передаётся $data.

Ответить

 

Александр

В вашей форме, да, везде передается $data. Но она в конечном итоге вызывает методы фреймворка, который и портит всю малину. Я знаю о чем говорю, ведь использовал вашу форму "когда это еще не было мейнстримом". Можете сделать тест. Вложите одну композитную форму в другую

Ответить

 

Александр

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

Пример.

Есть форма брендов. У Бренда может быть много компаний, а у компаний может быть много контактов. Все это вводится в одной форме (пример формы https://wbraganca.com/yii2extensions/dynamicform-demo3/create).

BrandForm - Композитная форма, internalForms = ['companies']. CompanyForm в свою очередь имеет internalForms = ['contacts'].

После отравки формы $_POST выглядит следующим образом

$_POST['BrandForm']['name']
$_POST['CompanyForm'][0]['name']
$_POST['ContactForm'][0][0]['name']
$_POST['ContactForm'][0][1]['name']
...
$_POST['CompanyForm'][n]['name']
$_POST['ContactForm'][n][0]['name']
...
$_POST['ContactForm'][n][m]['name']

Было бы хорошо, если бы ваша CompositeForm могла заполнять все вложенные формы независимо от уровня вложенности, а также валидировать их, на подобие Symfony forms.

Ответить

 

Дмитрий Елисеев

Сейчас это можно решить переопределением метода formName, чтобы он примешивал передаваемый классу в конструктор уникальный индекс из CompanyForm:

class ContactForm extends Model
{
    public function formName()
    {
        return 'ContactForm_' . $this->index;
    }
}

чтобы он строил одноуровневый массив, совместимый с фреймворковским loadMultiple:

$_POST['CompanyForm'][3]['name']
$_POST['ContactForm_3][0]['name']
Ответить

 

Дмитрий

А меня больше интересует вопрос тестирования формы. Дмитрий, вы тут используете PHPUnit. Достаточно ли его для тестирования приложения на yii2? В каких случаях использовать Codeception?

Ответить

 

Дмитрий Елисеев

Когда делаете сайт используйте Codeception. Если пишете отдельную библиотеку, то тестируйте с PHPUnit.

Ответить

 

Иван

Недавно возникла проблема с тем, что у меня есть несколько моделей форм, код которых дублирует друг друга, а тут вы, Дмитрий, как раз выпустили статью, что надо. Просто круто! Огромное спасибо! К слово, если позволите вопрос.


У меня есть еще такая проблема (сильно не бить, прошу!):


Вот как у меня выглядит класс-хелпер для проверки доступа к тому или иному экшену контроллера. На вход идет модель + действие:


<?php

namespace app\helpers;

class checkAccessHelper
{


    /**
     * Основная функция для проверки прав.
     * Смотрим какой экшен к нам пришел, проверяем наличие через switch-case, обрабатываем в нужном методе.
     * Если все ок, то возвращаем true.
     *
     * Ах да $model -- абсолютно любая модель формы, где требуется проверка прав. Ну а action -- просто string, который задается из контролера.

     * checkAccessHelper constructor.
     * @param $action
     * @param $model
     *
     */


    function __construct($action, $model)
    {

        switch ($action)
        {
            case 'create':
                $currRole = $this->getIdentityRole();
                $this->checkCreateRight($model, $currRole);
                break;

            case 'update':
                $currRole = $this->getIdentityRole();
                $this->checkUpdateRight($model, $currRole);
                break;
            case 'delete':
                $currRole = $this->getIdentityRole();
                $this->checkDeleteRight($model, $currRole);
                break;
            case 'index':
                $currRole = $this->getIdentityRole();
                $this->checkUsersViewRight($model, $currRole);
                break;
        }

        return true;
    }


    /**
     *  Возвращаем роль пользователя, если он залогинен. Иначе выдаем ошибку "нет доступа".
     *
     * @return mixed
     * @throws \yii\web\ForbiddenHttpException
     */
    private function getIdentityRole()
    {
        if (!empty(\Yii::$app->user->identity))
         return  $currRole = \Yii::$app->user->identity->getRole();

        else
        {
            throw new \yii\web\ForbiddenHttpException ('Вам не разрешено производить данное действие.');
        }
    }



    /**
     * Проверяем права на создание пользователя.
     *
     * @param $model
     * @param $currRole
     * @throws \yii\web\ForbiddenHttpException
     */
    private function checkCreateRight($model, $currRole)
    {
        if ($model::ADMIN_ROLE !== $currRole )
            throw new \yii\web\ForbiddenHttpException('Вы не можете создать нового пользователя.');

    }

    /**
     * Проверяем права на обновление профиля пользователя.
     *
     * @param $model
     * @param $currRole
     * @throws \yii\web\ForbiddenHttpException
     *
     */
    private function checkUpdateRight($model, $currRole)
    {
        if (($model->id !== \Yii::$app->user->id) and ($model::ADMIN_ROLE !== $currRole ))
            throw new \yii\web\ForbiddenHttpException('Вы можете изменять только собственный профиль.');

    }

    /**
     * Проверяем права на удаление пользователя.
     *
     * @param $model
     * @param $currRole
     * @throws \yii\web\ForbiddenHttpException
     */
    private function checkDeleteRight($model, $currRole)
    {
        if ($model::ADMIN_ROLE !== $currRole)
            throw new \yii\web\ForbiddenHttpException('Вы не можете удалить профиль пользователя.');
    }

    /**
     * Проверяем права на просмотр список пользователей.
     *
     * @param $model
     * @param $currRole
     * @throws \yii\web\ForbiddenHttpException
     */
    private function checkUsersViewRight($model, $currRole)
    {
        if ($model::ADMIN_ROLE !== $currRole)
            throw new \yii\web\ForbiddenHttpException('Вы не можете видеть список пользователей.');
    }
}

Штука вся в том, что я хочу получать в этом хелпере константу ADMIN_ROLE через $model::ADMIN_ROLE, т.е. у меня так выйдет, что я буду пытаться ее получить из модели, которая мне пришла в хелпер. А так как там сейчас модель формы, то я ее попросту не получу, ее там нету. Уже спрашивал у коллег -- рекомендуют либо заморочиться с трейтами, либо писать свой интерфейс. Я понимаю, что можно попросту обратиться напрямую в UserModel, раз так хочется, но это бы увеличило связность, также можно было занести константу в сам checkAcceessHelper, но опять же я считаю, что ей место именно в UserModel, где также находятся те же статусы пользователей. Что могли бы вы мне посоветовать в этом случае, если я буду (а я обязательно буду) реализовывать композитную модель формы?


Простите, что вопрос не совсем по теме, а мой код, наверняка, приведет вас в дикий ужас.

Ответить

 

Дмитрий Елисеев

А чем обычный RBAC с

if (!\Yii::$app->user->can($action, ['model' => $model]) {
    throw new ForbiddenHttpException();
}

не угодил?

Ответить

 

Иван

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

Ответить

 

Александр

Очень странно что у вас конструктор возвращает значение. Зачем вам вообще конструктор и сплошные приватные методы. Этот хелпер представляет собой обычную чистую функцию - возрващает значение по полученным параметрам. Если вам нужен класс, скажем для автолоадинга - сделайте статичный метод

Ответить

 

Иван

Да действительно конструктор у меня возвращает значнеие, поскольку после проверки прав, он должен вернуть булевое значение true, или вывести exception, прервав выполнение кода. На самом деле раньше у меня там был не конструктор, а просто обычная паблик функция, но я бы лично хотел бы использовать именно конструктор, потому что тогда очень просто и легко его вызывать прямо из какого-нибудь if в контроллере просто, передавая через new необходимые аргументы для конструктора.
А можно поподробнее, как вы считаете необходимо правильно сделать этот класс?

Ответить

 

Александр

Можете вынести ADMIN_ROLE в отдельный класс

class Roles
{
    const ADMIN = 'admin';
    const MANAGER = 'manager';
    const USER = 'user';
   ...
    
}

и потом можете его использовать во всех своих классах которые используют константы ролей

Ответить

 

Иван

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

Ответить

 

slo_nik

Добрый вечер.
Извините, но что-то упустил. Подскажите, пожалуйста, откуда берётся это?

$this->service->create(
Ответить

 

Иван

class ProductManageService

Ответить

 

slo_nik

Публичный метод я вижу, не пойму откуда service берётся.

Ответить

 

Иван

Ваша правда. Тоже не понимаю.

Ответить

 

Дмитрий Елисеев

Дополнил статью.

Ответить

 

Евгений Шевченко

Дмитрий спасибо большое за ваши труды. У меня вопрос следующее характера - как понять какой эксепшн бросать в той или иной ситуации? Имею ввиду NotFoundException или ForbiddenHttpException И тд. Спасибо

Ответить

 

Дмитрий Елисеев

Not Found - не найдено.
Forbidden - нет доступа.

Ответить

 

Александр

Семантически -
404 Not found - ничего не найдено - NotFoundHttpException
403 Forbidden - запрещено - ForbiddenHttpException

Но, 403 в админке к примеру может вести к частичному information disclosure. т.е. если человеку прилетает 403, то он понимает что у него не хватает прав, а значит тут что-то интересное для злоумышленника. А вот если он получит 404 то подумает что такой страницы просто нет. Разумеется это применимо не всегда, но это стоит иметь в виду.

Ответить

 

Иван

Хочу сказать, что все это зависит от подготовленности атакующего и его знания устройства внутернней архитектуры. Как правило атакующего будет интересовать не сам код ошибки, а как он меняется в завимости от того или инога пейлоада, поэтому хоть это и правильный способ скрытия information leakage, но отнюдь не панацея, помните об этом.

Ответить

 

Дмитрий Елисеев

?

Ответить

 

bjatta – bjatta.ru

git commit --alllow-empty -m 'Initial commit'

Буква "l" у вас аж утроилась. Стоит поправить?..

Ответить

 

Дмитрий Елисеев

Спасибо! Исправил.

Ответить

 

Игорь

Думаю, можно сделать еще гибче, почти как в классическом паттерне "компоновщик" - конфигурировать композит формы из контроллера, например так:

$form = new ProductCreateForm([
    'forms' => [
        'price' => new PriceForm(),
        'meta' => new MetaForm(),
        'value' => new ValueForm(),
    ],
]);

тем самым избавившись от прямого указания конкретных форм внутри формы в методе internalForms(). И сам метод по сути не нужен будет.

Ответить

 

Дмитрий Елисеев

Метод internalForms не только для присваивания, но и остальных методов вроде load() и validate() нужен.

Ответить

 

Иван

Здравствуйте, Дмитрий. Мне очень понравилась ваша статья про композитные формы, но только лишь недавно дошли руки до попытки практической реализации. И у меня по ходу возникли проблемы, поэтому хотел бы у вас спросить совета, если позволите.

В общем, я хочу нечто подобное сделать только для формы регистрации, чтобы не плодить код для форм регистрации для пользователя, от имени админа и, допустим, для регистрации по апи, ну и по сути-то все.

Как все это вижу, что используется как и у вас одна композитная модель, которую я уже подставляю везде в контроллерах, дальше эта композитная модель создает объекты других моделей форм, все внутри валидирует и так далее. Проблема в том, что непонятно, как собственно для нужного контроллера подсовывать конечную нужную мне модель. Я подумал, что все это можно сделать через __call, то бишь пусть он вызывает из контроллера некую функцию signupByAdmin, которую нет, а дальше уже дергать нужную мне модель с существующей там функцией signup, но такое решение меня несколько настораживает.

Как бы вы порекомендовали решить такую проблему? (ох уж это нежелание все решать через наследование с базовым классом, как мне изначально советовали, но чего я не хочу)

Ответить

 

Дмитрий Елисеев

У меня две композитные модели ProductCreateForm и ProductEditForm, а не одна.

Ответить

 

Иван

Да, вижу. Только не совсем Вас понял, к чему Вы это?

Ответить

 

Сергей

Здравствуйте! Можете подсказать как называется, а, если есть, дать ссылку на модуль которым пользуетесь для подсветки синтаксиса блоков кода в Ваших примерах?

Ответить

 

Дмитрий Елисеев

Стандартный CMarkdownParser и highlight.css из Yii1.

Ответить

 

Сергей – predvoditelev.ru

Спасибо за композитные формы! Очень элегантное решение.
Использую сейчас в разрабатываемом проекте. Храню формы со всей их сложной логикой валидации в доменном слое, а в приложении уже собираю очень простые композитные формы. И практика показывает, что это очень удобно :)

Ответить

 

Данил

Дмитрий большое спасибо! Подскажите а как теперь прятать ненужные поля от выбора, к примеру как в яндекс недвижимости. Выбрал квартиру одни поля, выбрал дом другие поля, выбрал участок другие поля. Если возможно показать пример кода. Спасибо.

Ответить

 

Евгений

Дмитрий, насколько я помню yii вроде по умолчанию защищал от повторной отправки формы (двойной клик по сабмит), а сейчас заметил, что двойная отправка формы возможна. Это баг, или я где-то затупил сильно? Если я всё же не туплю, то как защититься?

Ответить

 

Евгений

уже сам себе ответил:) туплю)

Ответить

 

Алекс

Правильно ли я понимаю пример в статье демонстрирует форму создания товара, когда в БД уже есть характеристики и на каждую в конструкторе создаётся по инстансу формы характериситки:

$this->_values = array_map(function (Characteristic $characteristic) {
    return new ValueForm($characteristic);
}, Characteristic::find()->orderBy('sort')->all());

Это всё классно, но что делать в случае, когда поля динамически добавляются, допустим добавление авторов к книге (может быть много, а может и не быть), в таком случае load не пройдет

Сейчас переопределяю load след. образом:

if (!empty($data[$this->rateForm->formName()])) {
    // Создание RateForm's
    array_map(function() {
        $this->_rates[] = new RateForm();
    }, $data[$this->rateForm->formName()]);

    $loadRates = Model::loadMultiple($this->rates, $data);
} else {
    $loadRates = false;
}

Но этот момент делает ваш класс не используемым, подскажите что делать в таком случае

Ответить

 

Дмитрий Елисеев

Да, можно переопределить load, чтобы он на основе данных смотрел сколько там значений, собирал массив форм $this->authors и дёргал parent::load().

Ответить

 

Сергей

Тоже столкнулся с этой проблемой. Я переделал CompositeForm так:

public function load($data, $formName = null)
    : bool {
        $success = parent::load($data, $formName);
        foreach ($this->_forms as $name => &$form) {
            if (is_array($form)) {
                $form = $this->fillFormByPost($form, $data, $formName ?: $name);
                $success = Model::loadMultiple($form, $data, $formName === null ? null : $name) || $success;
            } else {
                $success = $form->load($data, $formName !== '' ? null : $name) || $success;
            }
        }
        return $success;
    }

private function fillFormByPost(array $form, $data, $formName) {
        if (empty($data) || $formName == '') {
            return $form;
        }
        try {
            $formClass = $this->getFormClassByName($formName);
            /** @var Model $blankForm */
            $blankForm = new $formClass();
            $postFormName = $blankForm->formName();
            if (!empty($data[$postFormName])) {
                $newForms = [];
                for($i=0,$max=sizeof($data[$postFormName]);$i<$max;$i++) {
                    $newForms[] = clone $blankForm;
                }
                return array_combine(array_keys($data[$postFormName]),$newForms);
            }
        } catch (\Exception $e) {
            throw new \RuntimeException($e->getMessage());
        }
    }

private function getFormClassByName($name)
    : string {
        $forms = $this->internalForms();
        if (!array_key_exists($name, $forms)) {
            throw new \RuntimeException("Form class name for \"$name\" form does not found in the Internal array.");
        }
        return $forms[$name];
    }

в модели формы internalForms() прописываю так:

protected function internalForms(): array
    {
        return [related'=>RelatedForm::class];
    }
Ответить

 

Рипа Юрий

А как вы при создании передаете параметры в форму

$blankForm = new $formClass(?);


У Дмитрия

... new ValueForm($characteristic)...
Ответить

 

Сергей ...

В классе ProductCreateForm видимо ошибка – уникальность поля 'code' проверяется у сущности Brand:

[['code'], 'unique', 'targetClass' => Brand::class],


а должно у Product.

Ответить

 

Максим

Здравствуйте Дмитрий! Спасибо за вебинары про интернет-магазин. Но у меня почему то не работает метод CompositeForm->load(). В контроллере имею такой код:

public function actionIndex()
    {
        if($this->cart->getItems()){
            $form = new OrderForm($this->cart->getWeight());
            if ($form->load(Yii::$app->request->post()) && $form->validate()) {
                
print_r(Yii::$app->request->post());; //первое эхо
print_r($form); //второе эхо
                
                try {                    
                    $order = $this->service->checkout(Yii::$app->user->id, $form);
                    return $this->redirect(['/cabinet/order/view', 'id' => $order->id]);
                } catch (\DomainException $e) {
                    Yii::$app->errorHandler->logException($e);
                    Yii::$app->session->setFlash('error', $e->getMessage());
                }
            }
}

В результате в первом эхо выводит:

Array
(
    [CustomerForm] => Array
        (
            [choice] => 0
            [email] => ivanov@mail.ru
            [phone] => 79121312231
            [imya] => Иванов Иван Иванович
            [country] => RU
            [index] => 
            [indexru] => 413864
            [street] => ул. Ивановская, д.1, кв. 2.
            [town] => 
            [recipientphone] => 
        )

    [DeliveryForm] => Array
        (
            [method] => 1
            [index] => 122214
            [address] => 124124
        )

    [OrderForm] => Array
        (
            [note] => 122312
        )

)

А во втором эхо обратите внимание эти поля пустые. Не могу понять в чем причина:

shop\forms\Shop\Order\OrderForm Object
(
    [note] => 122312
    [delivery] => shop\forms\Shop\Order\DeliveryForm Object
        (
            [method] => 
            [index] => 
            [address] => 
            [_weight:shop\forms\Shop\Order\DeliveryForm:private] => 1200
        )

    [customer] => shop\forms\Shop\Order\CustomerForm Object
        (
            [choice] => 
            [email] => 
            [phone] => 
            [imya] => 
            [country] => 
            [town] => 
            [street] => 
            [indexru] => 
            [index] => 
            [recipientphone] => 
        )

    [forms:shop\forms\CompositeForm:private] => Array
        (
        )

    [_errors:yii\base\Model:private] => Array
        (
        )

)

Когда же ухожу от использования класса CompositeForm, тогда все становится нормально, но тогда действительно приходится усложнять сервисные функции увеличивая количество их аргументов - форм. В общем, штука классная, но разобраться в чём причина потери значений полей не получается.

Ответить

 

Denys Duvanov

У вас опечатка: "price_new и proce_old"

Ответить

 

Дмитрий Елисеев

Исправил. Спасибо!

Ответить

 

Максим Ворожцов

Доброй ночи, Дмитрий. Никак не могу понять как можно в ваших формах реализовать табличный ввод? как заполнять формы данными?

Сейчас делаю так.

 public function __construct(array $config = [])
    {
        $this->mentors = [new MentorForm()];
        $this->participants = [new ParticipantForm()];
        parent::__construct($config);
    }

Как теперь заполнить формы данными. Это заявки у каждой заявки может быть несколько участников и несколько наставников.

Ответить

 

Дмитрий Елисеев

Как в документации про табличный ввод узнать число строк и сделать столько элементов.

Ответить

 

Ворожцов Максим

Проблема была в том, что я указал переменные у формы, в какие пытался добавить новую форму. Еле додумался, что они не нужны. Но разобрался ещё в мае) Спасибо!

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>