Композитные формы в 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 $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> foreach ($valueForms as $i => $valueForm): $form->field($valueForm, '[' . $i . ']value')->textInput() 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> 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
:
<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> foreach ($model->values as $i => $valueForm): $form->field($valueForm, '[' . $i . ']value')->textInput() 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 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 $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 $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> 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, в котором в виде констант хранятся возможные типы сущностей, к которым может быть привязано изображение. Например:
Image::OWNER_PRODUCT = 1,
Image::OWNER_CATEGORY = 2
и т.п.
Это позволило, создав где-нибудь в сервисном слое метод determineOwnerType($owner), который бы по классу объекта-владельца определял owner_type. Как-то так:
Тогда, зная 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 выглядит следующим образом
Было бы хорошо, если бы ваша CompositeForm могла заполнять все вложенные формы независимо от уровня вложенности, а также валидировать их, на подобие Symfony forms.
Сейчас это можно решить переопределением метода formName, чтобы он примешивал передаваемый классу в конструктор уникальный индекс из CompanyForm:
чтобы он строил одноуровневый массив, совместимый с фреймворковским loadMultiple:
А меня больше интересует вопрос тестирования формы. Дмитрий, вы тут используете PHPUnit. Достаточно ли его для тестирования приложения на yii2? В каких случаях использовать Codeception?
Когда делаете сайт используйте Codeception. Если пишете отдельную библиотеку, то тестируйте с PHPUnit.
Недавно возникла проблема с тем, что у меня есть несколько моделей форм, код которых дублирует друг друга, а тут вы, Дмитрий, как раз выпустили статью, что надо. Просто круто! Огромное спасибо! К слово, если позволите вопрос.
У меня есть еще такая проблема (сильно не бить, прошу!):
Вот как у меня выглядит класс-хелпер для проверки доступа к тому или иному экшену контроллера. На вход идет модель + действие:
Штука вся в том, что я хочу получать в этом хелпере константу ADMIN_ROLE через $model::ADMIN_ROLE, т.е. у меня так выйдет, что я буду пытаться ее получить из модели, которая мне пришла в хелпер. А так как там сейчас модель формы, то я ее попросту не получу, ее там нету. Уже спрашивал у коллег -- рекомендуют либо заморочиться с трейтами, либо писать свой интерфейс. Я понимаю, что можно попросту обратиться напрямую в UserModel, раз так хочется, но это бы увеличило связность, также можно было занести константу в сам checkAcceessHelper, но опять же я считаю, что ей место именно в UserModel, где также находятся те же статусы пользователей. Что могли бы вы мне посоветовать в этом случае, если я буду (а я обязательно буду) реализовывать композитную модель формы?
Простите, что вопрос не совсем по теме, а мой код, наверняка, приведет вас в дикий ужас.
А чем обычный RBAC с
не угодил?
Не хочу слишком усложнять код и реализовывать у себя монструозный RBAC. Мне буквально нужно проверять просто один контроллер с крудами и все. Поэтому и написал свой собственный небольшой велосипед.
Очень странно что у вас конструктор возвращает значение. Зачем вам вообще конструктор и сплошные приватные методы. Этот хелпер представляет собой обычную чистую функцию - возрващает значение по полученным параметрам. Если вам нужен класс, скажем для автолоадинга - сделайте статичный метод
Да действительно конструктор у меня возвращает значнеие, поскольку после проверки прав, он должен вернуть булевое значение true, или вывести exception, прервав выполнение кода. На самом деле раньше у меня там был не конструктор, а просто обычная паблик функция, но я бы лично хотел бы использовать именно конструктор, потому что тогда очень просто и легко его вызывать прямо из какого-нибудь if в контроллере просто, передавая через new необходимые аргументы для конструктора.
А можно поподробнее, как вы считаете необходимо правильно сделать этот класс?
Можете вынести ADMIN_ROLE в отдельный класс
и потом можете его использовать во всех своих классах которые используют константы ролей
Ну как вариант. Мне лично нравится идея с композитными формами еще потому, что я в целом могу в абстрактный класс для форм уже передать эту константу и использовать ее во всех наследуемых от абстрактного класса моделей форм.
Добрый вечер.
Извините, но что-то упустил. Подскажите, пожалуйста, откуда берётся это?
class ProductManageService
Публичный метод я вижу, не пойму откуда service берётся.
Ваша правда. Тоже не понимаю.
Дополнил статью.
Дмитрий спасибо большое за ваши труды. У меня вопрос следующее характера - как понять какой эксепшн бросать в той или иной ситуации? Имею ввиду NotFoundException или ForbiddenHttpException И тд. Спасибо
Not Found - не найдено.
Forbidden - нет доступа.
Семантически -
404 Not found - ничего не найдено - NotFoundHttpException
403 Forbidden - запрещено - ForbiddenHttpException
Но, 403 в админке к примеру может вести к частичному information disclosure. т.е. если человеку прилетает 403, то он понимает что у него не хватает прав, а значит тут что-то интересное для злоумышленника. А вот если он получит 404 то подумает что такой страницы просто нет. Разумеется это применимо не всегда, но это стоит иметь в виду.
Хочу сказать, что все это зависит от подготовленности атакующего и его знания устройства внутернней архитектуры. Как правило атакующего будет интересовать не сам код ошибки, а как он меняется в завимости от того или инога пейлоада, поэтому хоть это и правильный способ скрытия information leakage, но отнюдь не панацея, помните об этом.
Json schema ?
?
Позволяет с помощью json описать правила валидации для форм любой сложности. Есть расширение для Yii2 https://github.com/dstotijn/yii2-json-schema-validator
git commit --alllow-empty -m 'Initial commit'
Буква "l" у вас аж утроилась. Стоит поправить?..
Спасибо! Исправил.
Думаю, можно сделать еще гибче, почти как в классическом паттерне "компоновщик" - конфигурировать композит формы из контроллера, например так:
тем самым избавившись от прямого указания конкретных форм внутри формы в методе internalForms(). И сам метод по сути не нужен будет.
Метод internalForms не только для присваивания, но и остальных методов вроде load() и validate() нужен.
Здравствуйте, Дмитрий. Мне очень понравилась ваша статья про композитные формы, но только лишь недавно дошли руки до попытки практической реализации. И у меня по ходу возникли проблемы, поэтому хотел бы у вас спросить совета, если позволите.
В общем, я хочу нечто подобное сделать только для формы регистрации, чтобы не плодить код для форм регистрации для пользователя, от имени админа и, допустим, для регистрации по апи, ну и по сути-то все.
Как все это вижу, что используется как и у вас одна композитная модель, которую я уже подставляю везде в контроллерах, дальше эта композитная модель создает объекты других моделей форм, все внутри валидирует и так далее. Проблема в том, что непонятно, как собственно для нужного контроллера подсовывать конечную нужную мне модель. Я подумал, что все это можно сделать через __call, то бишь пусть он вызывает из контроллера некую функцию signupByAdmin, которую нет, а дальше уже дергать нужную мне модель с существующей там функцией signup, но такое решение меня несколько настораживает.
Как бы вы порекомендовали решить такую проблему? (ох уж это нежелание все решать через наследование с базовым классом, как мне изначально советовали, но чего я не хочу)
У меня две композитные модели ProductCreateForm и ProductEditForm, а не одна.
Да, вижу. Только не совсем Вас понял, к чему Вы это?
Здравствуйте! Можете подсказать как называется, а, если есть, дать ссылку на модуль которым пользуетесь для подсветки синтаксиса блоков кода в Ваших примерах?
Стандартный CMarkdownParser и highlight.css из Yii1.
Спасибо за композитные формы! Очень элегантное решение.
Использую сейчас в разрабатываемом проекте. Храню формы со всей их сложной логикой валидации в доменном слое, а в приложении уже собираю очень простые композитные формы. И практика показывает, что это очень удобно :)
Дмитрий большое спасибо! Подскажите а как теперь прятать ненужные поля от выбора, к примеру как в яндекс недвижимости. Выбрал квартиру одни поля, выбрал дом другие поля, выбрал участок другие поля. Если возможно показать пример кода. Спасибо.
Дмитрий, насколько я помню yii вроде по умолчанию защищал от повторной отправки формы (двойной клик по сабмит), а сейчас заметил, что двойная отправка формы возможна. Это баг, или я где-то затупил сильно? Если я всё же не туплю, то как защититься?
уже сам себе ответил:) туплю)
Правильно ли я понимаю пример в статье демонстрирует форму создания товара, когда в БД уже есть характеристики и на каждую в конструкторе создаётся по инстансу формы характериситки:
Это всё классно, но что делать в случае, когда поля динамически добавляются, допустим добавление авторов к книге (может быть много, а может и не быть), в таком случае load не пройдет
Сейчас переопределяю load след. образом:
Но этот момент делает ваш класс не используемым, подскажите что делать в таком случае
Да, можно переопределить load, чтобы он на основе данных смотрел сколько там значений, собирал массив форм $this->authors и дёргал parent::load().
Тоже столкнулся с этой проблемой. Я переделал CompositeForm так:
в модели формы internalForms() прописываю так:
А как вы при создании передаете параметры в форму
У Дмитрия
В классе ProductCreateForm видимо ошибка – уникальность поля 'code' проверяется у сущности Brand:
а должно у Product.
Здравствуйте Дмитрий! Спасибо за вебинары про интернет-магазин. Но у меня почему то не работает метод CompositeForm->load(). В контроллере имею такой код:
В результате в первом эхо выводит:
А во втором эхо обратите внимание эти поля пустые. Не могу понять в чем причина:
Когда же ухожу от использования класса CompositeForm, тогда все становится нормально, но тогда действительно приходится усложнять сервисные функции увеличивая количество их аргументов - форм. В общем, штука классная, но разобраться в чём причина потери значений полей не получается.
У вас опечатка: "price_new и proce_old"
Исправил. Спасибо!
Доброй ночи, Дмитрий. Никак не могу понять как можно в ваших формах реализовать табличный ввод? как заполнять формы данными?
Сейчас делаю так.
Как теперь заполнить формы данными. Это заявки у каждой заявки может быть несколько участников и несколько наставников.
Как в документации про табличный ввод узнать число строк и сделать столько элементов.
Проблема была в том, что я указал переменные у формы, в какие пытался добавить новую форму. Еле додумался, что они не нужны. Но разобрался ещё в мае) Спасибо!