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

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

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

Лирическое отступление

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

Начальная настройка

В первую очередь нам необходима модель, которая способна получить множество записей из одной или нескольких таблиц. В моем случае мне хватило отношения hasMany. Таким образом, массив моделей всех адресов мы можем получить следующим образом:

Ради примера можно представить что у нас есть view для пользователя, которая хочет отобразить список адресов с возможностью редактирования каждого, а также добавления нового адреса (сущности взяты с потолка). Подготавливаем саму форму (будем считать что контроллер отдает только $model как модель пользователя):

  • action — понятно, отсылает форму на определенный action контроллера addresses с параметром userId. (параметр нам пригодится позже)
  • массив options с единственным значением data-pjax, который активирует работу «пиджака» для конкретной формы (для ссылок активация не требуется, а вот формы надо указывать).
  • и id формы — если не задать id формы и при этом иметь на странице много виджетов или несколько ActiveForm, то после отработки сервером, pjax вернет нам форму с id w0, и идентификаторы могут пересечься с другими формами на странице, что нам совершенно не нужно.

После создания формы запускаем цикл по адресам, я использовал геттер напрямую, и не стоит боятся, что при каждой итерации будет происходить запрос к базе, Yii сохраняет все relation запросы в приватном массиве relations. Далее выводим name и value из таблицы (или любые другие поля и более сложную разметку.)

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

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

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

Контроллер
  1. У нас не одна модель а несколько
  2. Нам нужна пакетная загрузка
  3. Нужна пакетная валидация

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

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

Можем улучшить метод возвращая true или количество обновленных записей в случае удачи и false в случае отсутствия данных для обновления. Мне это было не нужно.

Добавляем два метода для поиска моделей. Первый для User, второй для Address (я только сейчас подумал, можно было бы обернуть эти два метода в один):

И, наконец, пишем наш action:

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

В методе выше я поспешил и сразу показал методы create и delete. Их еще нет, но заранее добавить в access control два метода лучше, чем потом ловить exception о запрещенном доступе.

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

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

Создание fake записи делаем через метод модели addOne()

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

Action в контроллере будет выглядеть так:

Кнопка добавления записи во view внутри pjax, но вне цикла:

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

Давайте сделаем тоже самое для кнопки удалить, в итоге наша view будет выглядеть внутри цикла вот так:

и action контроллера:

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

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

  • Добавить batchUpdate в action'ы create и delete прямо перед return $this->renderAjax(. )
  • Написать простенький скрипт, который меняет action формы в зависимости от нажатой кнопки, а потом сабмитит ее.

Простой сниппет кода, который занял у меня от силы 1 минуту. Ссылка или элемент с атрибутом data-toggle=reroute попадает в обработчик, и ближайшая к нему форма (среди родителей, естественно) меняет свой action на тот, что хранится в data-action, а после этого сабмитится. В случае неверной настройки обработчика со стороны html шаблона вылетает alert.

Осталось изменить наши кнопки в представлении следующим образом:

Что можно улучшить
  • Для начала можно оптимизировать пакетную загрузку (если она, конечно, не оптимизирована на уровне ядра, чему я не нашел подтверждение) таким образом, что не измененные записи не будут сохранятся в базу данных. Для этого достаточно сравнить oldAttributes и attributes конкретной модели в методе модели beforeSave(). В противном случае, если такая проверка не происходит на уровне фреймворка, sql сервер будет удивлен повторным записям с одними и теми же значениями.
  • Далее можно обернуть методы поиска модели в контроллере в один единственный метод findModel($classname, $params)
  • И, как я уже говорил, создать правило валидации на несоответствие полей модели ее константам с дефолтными значениями.

Буду рад, если кто-то подскажет улучшения или исправления данного рецепта. Всем добра!

📎📎📎📎📎📎📎📎📎📎