Композитные формы в 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);
    }
}

По аналогии нужно провери