Yii 2.0: Динамічне додавання валидируемых полів форми через «піджак»(pjax) для мульти-модельної форми

Доброго часу доби, Хабр!
Не так давно переді мною постало завдання розробки форми з можливістю динамічного додавання полів, кожне поле було окремою сутністю бази даних, тобто поле = запис у базі даних. Не дивлячись на те, що моя задача була не тривіальна, кожен цілком може зіткнутися з чимось подібним в тій чи іншій мірі. Наприклад, з додаванням нового елемента прямо всередині GridView з подальшим редагуванням і збереженням.

Отже, почнемо.


Ліричний відступ

Під час розробки даного рішення я перерив весь інтернет і не знайшов жодного вартісного рецепту ні на англомовних форумах, ні на SO ні на GitHub. Більше того, до того часу ще не була готова підтримка валідації динамічних полів з боку Yii. Більш докладно тут. Та й зараз, як мені здається, вона мені не підходить.
Саме рішення ніяк не претендує бути над-елегантним, з цього будь конструктивну критику, а також поради я з задоволенням вислухаю.

Початкова настройка

В першу чергу нам необхідна модель, яка здатна отримати безліч записів з однієї або декількох таблиць. У моєму випадку мені вистачило відносини hasMany. Таким чином, масив моделей всіх адрес, ми можемо отримати наступним чином:

$addresses = $model->addresses;


Заради прикладу можна уявити що у нас є view для користувача, яка хоче відобразити список адрес з можливістю редагування кожного, а також додавання нової адреси (сутності взяті зі стелі).
Готуємо саму форму (будемо вважати, що контролер віддає тільки $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(); ?>


Форма готова. У коді вище ми спершу підключаємо необхідні класи — віджет ActiveForm і два хелперу.
Далі створюємо ActiveForm з наступними параметрами:
  • action — зрозуміло, відсилає форму на певний action контролера addresses з параметром userId. (параметр нам стане в нагоді пізніше)
  • масив options з єдиним значенням data-pjax, який активує роботу «піджака» для конкретної форми (для посилань активація не потрібно, а от форми треба вказувати).
  • id форми — якщо не поставити id форми і при цьому мати на сторінці багато віджетів або кілька ActiveForm, то після відпрацювання сервером, pjax поверне нам форму з id w0, і ідентифікатори можуть перетнутися з іншими формами на сторінці, що нам абсолютно не потрібно.


Після створення форми запускаємо цикл за адресами, я використовував геттер безпосередньо, і не варто боятися, що при кожній ітерації буде відбуватися запит до бази, Yii зберігає всі relation запити в приватному масиві relations. Далі виводимо name і value з таблиці (або будь-які інші поля і більш складну розмітку.)

Нетерплячий читач, напевно, запитає: «А як же кнопка додавання нового адреси?» — не поспішайте, все по порядку.

Основні заготовки є, давайте підключимо view файл як внутрішній view файл до комплексного view користувача.
Припустимо, у нас є сторінка профілю користувача, а адреси відображаються відразу під ним, додамо view адрес і заодно «одягаємо в піджак»:
<?php

use yii\widgets\Pjax;
?>
<?= $this- > render('_profile', ['model' => $model]) ?>
<?php Pjax::begin(['enablePushState' => ' false']);? >
<?= $this- > render('_addresses', ['model' => $model]) ?>
<?php Pjax::end(); ?>


Звертаю Вашу увагу на параметр enablePushState, без нього ми отримаємо зміна адреси в адресному рядку браузера. Нам це не потрібно, тому що вся робота контролера буде проходити через renderAjax, і повноцінних view разом з layout в цій частині у нас не буде.

Контролер

Я спеціально виділив контролер окремою главою.
Давайте спершу подумаємо, як він буде працювати. З вигляду все просто. Отримуємо запит на action update з інформацією про id користувача, потім оновлюємо модель і віддаємо renderAjax('_addresses', ['model' => $user]); В свою чергу, $user ми отримуємо через User::findOne($userId), який дбайливо передали разом з формою.
Однак на ділі все трохи складніше:
  1. У нас не одна модель а кілька
  2. Нам потрібна пакетна завантаження
  3. Потрібна пакетна валідація


Отже, поїхали:
<?php
namespace backend\controllers;

use Yii;
common use\models\User;
common use\models\Addresses;
use yii\base\Model;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\NotFoundHttpException;

/**
* Addresses controller
*/
class AddressesController extends Controller
{

}


Так буде виглядати клас контролера без методів.

Додаємо пакетну завантаження як метод контролера (можна обійтися і методом моделі, але мені так здалося більш правильно, до того ж у моєму прикладі мені було необхідно зберігати не тільки моделі, але і зв'язок з таблицею user допомогою link()):

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


Можемо поліпшити метод повертаючи true або кількість оновлених записів в разі удачі і false в разі відсутності даних для оновлення. Мені це було не потрібно.

Додаємо два методу для пошуку моделей. Перший User, другий для Address (я тільки зараз подумав, що можна було б обернути ці два методи в один):
/**
* 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 model loaded
* @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 the User 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.');
}
}


І, нарешті, пишемо наш action:

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


Не забудьте додати access control.

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

У методі вище я поспішив і відразу показав методи create і delete. Їх ще немає, але заздалегідь додати в access control два методу краще, ніж потім ловити exception про заборонений доступ.

Ну що, тепер у нас є чудова форма, яка оновлює допомогою pjax дані по всіх адресах. У звичайному випадку ми могли б додати в форму кнопку «додати» і «видалити» і відсилати запит на певний action, а у випадку з «додати» — ще й окрему view.

Динамічні поля з валідацією

Ось і дісталися до самого головного.
Просте додавання нової суті зводиться до наступних дій:
  • Додаємо під view кнопку яка веде на action — addresses/create
  • Додаємо функцію створення fake запису в базі даних.
  • Додаємо action
  • Відображаємо view через ajax.


Створення fake запису робимо через метод моделі addOne()
public function addOne()
{
$this- > name = self::DEFAULT_NAME;
$this->value = self::DEFAULT_VALUE;
}

Не забудьте створити константи в класі моделі.

Action в контролері буде виглядати так:
/**
* 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]);
}


Кнопка додавання запису в view всередині pjax, але поза циклу:
<?= Html::a('Додати адресу', Url::toRoute(['addresses/create', 'userId' => $model->id]), [
'class' => 'btn btn-success',
]) ?>


Власне, все. Тепер при натисканні на кнопку «Додати адресу» в базі даних буде створюватися fake запис з початковими даними, а view буде рендеритись заново разом з новими правилами валідації.
Можна поліпшити цю частину коду додаванням правила валідації про те, що значення не повинні бути еквівалентні дефолтними. Так як метод link зберігає без валідації, це цілком здійсненне, а для решти можу порадити save(false — false відключає валідацію при збереженні моделі.

Давайте зробимо теж саме для кнопки видалити, в підсумку наша view буде виглядати всередині циклу ось так:
<?= $form->field($address, "[$key]name") ?>
<?= $form->field($address, "[$key]value") ?>
<?= Html::a('Видалити', Url::toRoute(['addresses/delete', 'id' => $address->id]), [
'class' => 'btn btn-danger',
]) ?>


і action контролера:
public function actionDelete($id)
{
$model = $this->findModel($id);
$user = $model->user;
$model->delete();
return $this->renderAjax('_addresses', ['model' => $user]);
}


А як же змінені значення і UX?

Все вірно. Для стандартної ситуації описаного вище функціоналу вистачить, проте користувач звик до того, що при динамічному додавання поля йому не потрібно піклуватися про збереження даних перед цим. В результаті користувач може заповнити 5 полів, зрозуміє що йому не вистачило, додасть 6-е… і все. Прощайте 5 хвилин життя для користувача, прощай користувач нашого ресурсу.

Єдине, що я зміг придумати в цій ситуації, це зберігати форму кожен раз, коли користувач натискає на кнопку (не важливо, на яку).
Що мені для цього знадобилося:
  • Додати butchUpdate в action'и create і delete прямо перед return $this->renderAjax(...)
  • Написати простенький скрипт, який змінює action форми в залежності від натиснутої кнопки, а потім сабмитит її.


Спершу скрипить:
$(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('Помилка! Будь ласка, повідомте адміністрації.');
}
});
});

Простий фрагмент коду, який зайняв у мене від сили 1 хвилину. Посилання або елемент з атрибутом data-toggle=reroute потрапляє в оброблювач, і найближча до нього форма (серед батьків, природно) змінює свій action на той, що зберігається в data-action, а після цього сабмитится. У разі невірної установки обробника з боку html шаблону вилітає alert.

Залишилось змінити наші кнопки в уявленні наступним чином:
<?= 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])
]
]) ?>


Що можна поліпшити

Як завжди є до чого прагнути.
  • Для початку можна оптимізувати пакетну завантаження (якщо вона, звичайно, не оптимізована на рівні ядра, чого я не знайшов підтвердження) таким чином, що не змінені записи не будуть збережуться в базу даних. Для цього достатньо порівняти oldAttributes і attributes конкретної моделі в методі моделі beforeSave(). В іншому випадку, якщо така перевірка не відбувається на рівні фреймворку, sql сервер буде здивований повторних записів з одними і тими ж значеннями.
  • Далі можна обернути методи пошуку моделі в контролері в один єдиний метод findModel($classname, $params)
  • І, як я вже говорив, створити правило валідації на невідповідність полів моделі її констант з дефолтними значеннями.


Буду радий, якщо хтось підкаже поліпшення або виправлення даного рецепту. Всім добра!

Корисні посиланняdemos.krajee.com/builder-details/tabular-form — щось схоже, але дуже монструозное і зроблено для GridView. До того ж, там немає збереження полів видалення / додавання нових
www.yiiframework.com/wiki/666/handling-tabular-data-loading-and-validation-in-yii-2/ — не погана демка від того ж автора, яка стала прикладом пакетної завантаження.


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.