📜 ⬆️ ⬇️

Yii 2.0: Dynamic addition of validated form fields through a “jacket” (pjax) for a multi-model form

Good day, Habr!
Not so long ago, I was faced with the task of developing a form with the ability to dynamically add fields, each field was a separate database entity, that is, a field = an entry in the database. Despite the fact that my task was not trivial, everyone may well be faced with something similar in one way or another. For example, adding a new element right inside the GridView and then editing and saving.

So, let's begin.


Lyrical digression


During the development of this solution, I rummaged through the entire Internet and did not find a single worthwhile recipe either on the English-speaking forums, nor on the SO or GitHub. Moreover, by that time, support for the validation of dynamic fields by Yii was not yet ready. More details here . And now, as it seems to me, it does not suit me.
The decision itself does not pretend to be super-elegant, so I’ll listen with pleasure to any constructive, criticism and advice.
')

Initial setup


First of all, we need a model that is able to get multiple records from one or several tables. In my case, I had the relationship hasMany. Thus, we can get an array of models of all addresses as follows:

$addresses = $model->addresses; 


For the sake of example, you can imagine that we have a view for the user who wants to display a list of addresses with the ability to edit each one, as well as add a new address (entities are taken from the ceiling).
Prepare the form itself (we will assume that the controller gives only $ model as a user model):
 <?php use yii\widgets\ActiveForm; use yii\helpers\Url; use yii\helpers\Html; ?> <?php $form = ActiveForm::begin([ 'action' => Url::toRoute(['addresses/update', 'userId' => $model->id]), 'options' => [ 'data-pjax' => '1' ], 'id' => 'adressesUpdateForm' ]); ?> <?php foreach ($model->adresses as $key => $address): ?> <?= $form->field($address, "[$key]name") ?> <?= $form->field($address, "[$key]value") ?> <?php endforeach ?> <?= Html::submitButton('', ['class' => 'btn btn-primary']) ?> <?php ActiveForm::end(); ?> 


The form is ready. In the code above, we first connect the necessary classes — the ActiveForm widget and two helpers.
Next, create an ActiveForm with the following parameters:


After creating the form, we start the cycle to the addresses, I used the getter directly, and you should not be afraid that at each iteration a request to the database will occur, Yii stores all relational requests in a private array of relations. Next, print the name and value from the table (or any other fields and more complex markup.)

The impatient reader will probably ask: “But what about the button for adding a new address?” - do not rush, everything is in order.

There are basic blanks, let's connect the view file as an internal view file to the complex view user.
Suppose we have a user profile page, and the addresses are displayed immediately below it, add view addresses and “dress in a jacket” at the same time:
 <?php use yii\widgets\Pjax; ?> <?= $this->render('_profile', ['model' => $model]) ?> <?php Pjax::begin(['enablePushState' => false]); ?> <?= $this->render('_addresses', ['model' => $model]) ?> <?php Pjax::end(); ?> 


I draw your attention to the parameter enablePushState, without it we will get the address change in the address bar of the browser. We do not need this, because all the work of the controller will go through renderAjax, and we will not have a full view with the layout in this part.

Controller


I specifically identified the controller as a separate chapter.
Let's first think about how it will work. It looks simple. We receive a request for action update with information about the user id, then we update the model and give renderAjax ('_ addresses', ['model' => $ user]); In turn, we get $ user through User :: findOne ($ userId), which we carefully passed along with the form.
However, the reality is a little more complicated:
  1. We have more than one model
  2. We need batch upload
  3. Need batch validation


So, let's go:
 <?php namespace backend\controllers; use Yii; use common\models\User; use common\models\Addresses; use yii\base\Model; use yii\filters\AccessControl; use yii\web\Controller; use yii\web\NotFoundHttpException; /** * Addresses controller */ class AddressesController extends Controller { } 


This is what the controller class will look like without methods.

Add batch loading as a controller method (you can do with the model method, but it seemed to me more correct, besides, in my example, I needed to save not only the models, but also the link to the user table via link ()):

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


We can improve the method by returning true or the number of updated records in case of success and false if there is no data to update. I did not need it.

Add two methods to search for models. The first is for User, the second is for Address (I just wondered if it would be possible to wrap these two methods in one):
  /** * Finds the Addresses model based on its primary key value. * If the model is not found, a 404 HTTP exception will be thrown. * @param integer $id * @return Addresses the loaded model * @throws NotFoundHttpException if the model cannot be found */ protected function findModel($id) { if (($model = Addresses::findOne($id)) !== null) { return $model; } else { throw new NotFoundHttpException('The requested page does not exist.'); } } /** * Finds the User model based on its primary key value. * If the model is not found, a 404 HTTP exception will be thrown. * @param integer $id * @return User the loaded model * @throws NotFoundHttpException if the model cannot be found */ protected function findUser($id) { if (($model = User::findOne($id)) !== null) { return $model; } else { throw new NotFoundHttpException('The requested page does not exist.'); } } 


And finally, we write our action:

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


Do not forget to add access control.

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

In the method above, I hurried and immediately showed the create and delete methods. They are not there yet, but it is better to add two methods to the access control in advance than to catch the exception about forbidden access.

Well, now we have a great form, which updates pjax data for all addresses. In the usual case, we could add a “add” and “delete” button to the form and send a request for a specific action, and in the case of “add” a separate view as well.

Dynamic fields with validation


So we got to the most important.
Simply adding a new entity comes down to the following actions:


Creating fake entries is done through the addOne () model method.
  public function addOne() { $this->name = self::DEFAULT_NAME; $this->value = self::DEFAULT_VALUE; } 

Remember to create constants in the model class.

The action in the controller will look like this:
  /** * action call by AJAX to create new fake address * @param integer $userId * @return mixed */ public function actionCreate($userId) { $user = $this->findUser($userId); $model = new Addresses; $model->addOne(); $user->link('addresses', $model); // link      ,   return $this->renderAjax('_addresses', ['model' => $user]); } 


Button to add an entry to the view inside pjax, but outside the loop:
  <?= Html::a(' ', Url::toRoute(['addresses/create', 'userId' => $model->id]), [ 'class' => 'btn btn-success', ]) ?> 


Actually, everything. Now, when you click on the “Add address” button, the database will create a fake entry with the initial data, and the view will be re-rendered along with the new validation rules.
It is possible to improve this part of the code by adding a validation rule stating that the values ​​should not be equivalent to the default. Since the link method saves without validation, this is completely realizable, and for the rest I can advise save (false) - false disables validation while saving the model.

Let's do the same for the delete button, in the end our view will look like this inside the loop:
  <?= $form->field($address, "[$key]name") ?> <?= $form->field($address, "[$key]value") ?> <?= Html::a('', Url::toRoute(['addresses/delete', 'id' => $address->id]), [ 'class' => 'btn btn-danger', ]) ?> 


and action controller:
  public function actionDelete($id) { $model = $this->findModel($id); $user = $model->user; $model->delete(); return $this->renderAjax('_addresses', ['model' => $user]); } 


But what about the changed values ​​and UX?


That's right. For the standard situation, the functionality described above is enough, but the user is used to the fact that when a field is dynamically added, he does not need to worry about saving the data before that. As a result, the user can fill in 5 fields, understand that he did not have enough, add the 6th ... and that’s all. Forgive 5 minutes of life for the user, forgive the user for our resource.

The only thing I could think of in this situation is to save the form every time the user presses the button (no matter which one).
What I needed for this:


First creaks:
 $(function(){ $(document).on('click', '[data-toggle=reroute]', function(e) { e.preventDefault(); var $this = $(this); var data = $this.data(); var action = data.action; var $form = $this.closest('form'); if ($form && action) { $form.attr('action', action).submit(); } else { alert('! ,  .'); } }); }); 

A simple code snippet that took me 1 minute or less. A link or element with the data-toggle = reroute attribute gets into the handler, and the form nearest to it (among parents, of course) changes its action to the one that is stored in the data-action, and then submitted. In the case of incorrect configuration of the handler from the html template, an alert crashes.

It remains to change our buttons in the view as follows:
  <?= Html::a(' ', null, [ 'class' => 'btn btn-success', 'data' => [ 'toggle' => 'reroute', 'action' => Url::toRoute(['addresses/create', 'userId' => $model->id]) ] ]) ?> <?= Html::a('', null, [ 'class' => 'btn btn-danger', 'data' => [ 'toggle' => 'reroute', 'action' => Url::toRoute(['addresses/delete', 'id' => $variable->id]) ] ]) ?> 


What can be improved


As always, there is something to strive for.


I would be glad if someone prompts improvements or corrections to this recipe. All good!

useful links
demos.krajee.com/builder-details/tabular-form is something similar, but very monster and made for the GridView. In addition, there is no saving of fields when deleting / adding new
www.yiiframework.com/wiki/666/handling-tabular-data-loading-and-validation-in-yii-2 is not a bad demo from the same author, which served as an example of batch loading.

Source: https://habr.com/ru/post/239147/


All Articles