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:
- action - it is clear, it sends the form to a specific action controller of the addresses with the parameter userId. (this parameter will come in handy later)
- options array with a single data-pjax value, which activates the work of the “jacket” for a specific form (activation is not required for links, but the forms must be specified).
- and form id - if you do not set the form id and still have many widgets on the page or several ActiveForm, then after working by the server, pjax will return the form with id w0, and identifiers may intersect with other forms on the page, which we absolutely do not need.
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:
- We have more than one model
- We need batch upload
- 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; 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 ()):
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):
protected function findModel($id) { if (($model = Addresses::findOne($id)) !== null) { return $model; } else { throw new NotFoundHttpException('The requested page does not exist.'); } } 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.
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:
- Add a button in view that leads to action - addresses / create
- Add a function to create fake records in the database.
- Add action
- Display the view via ajax.
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:
public function actionCreate($userId) { $user = $this->findUser($userId); $model = new Addresses; $model->addOne(); $user->link('addresses', $model);
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:
- Add batchUpdate to create and delete actions just before return $ this-> renderAjax (...)
- Write a simple script that changes the action of the form depending on the pressed button, and then submit it.
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.
- For a start, you can optimize batch loading (if it is, of course, not optimized at the kernel level, which I did not find confirmation of) in such a way that unchanged records will not be saved to the database. To do this, it is enough to compare the oldAttributes and attributes of a specific model in the model method beforeSave (). Otherwise, if such a check does not occur at the framework level, the sql server will be surprised to repeat entries with the same values.
- Then you can wrap the model search methods in the controller into a single method, findModel ($ classname, $ params)
- And, as I have already said, create a validation rule for non-compliance of the model fields with its constants with default values.
I would be glad if someone prompts improvements or corrections to this recipe. All good!