27 марта 2018

Формы конфигурации и Ajax API в Drupal 8

Алёна Парфирьева
Разработчик

Все прекрасно представляют, как работает обычная форма конфигурации, созданная при помощи Form API — пользователь нажимает кнопку Save configuration, страница перезагружается, форма обновляется. Но что если нам нужно некоторые значения формы изменять, не перезагружая страницу?

Нам на помощь придет Ajax API. Рассмотрим его использование на примере управления списком. Допустим, мы хотим создать форму, в которой можно ввести название организации и список дат праздничных дней, когда организация не работает.  Нам нужно добавлять элементы в список, удалять их и очищать весь список — все эти действия мы сделаем на основе Ajax API.

Создадим модуль organisation с простой формой, например, поле с названием организации и поле с нерабочими днями.

Для начала сделаем подготовительную работу: 

Начнем с шаблона. Определим в модуле шаблон:

function organisation_theme() {
  return [
    'organisation' => [
      'render element' => 'children',
    ],
    'holiday-list' => [
      'variables' => [
        'holidays' => NULL,
        'error' => NULL
      ]
    ]
  ];
}

Тут у нас есть две переменные: список нерабочих дней и ошибка. Создадим holiday-list.html.twig:

<div class="settings-holiday" id="holiday-list">
  {% if error is not empty %}
    <div role="contentinfo" aria-label="Error message" class="messages messages--error">
      <div role="alert">
        <h2 class="visually-hidden">Error message</h2>
        {{ error }}
      </div>
    </div>
  {% endif %}
{%  if holidays is not empty %}
  <h3 class="label">{% trans %}Holiday list{% endtrans %}</h3>
  <a href="{{ path(‘organisation.clear_holidays') }}" class="use-ajax settings-holiday__clear">{% trans %}Clear list{% endtrans %}</a>
  <ul class="settings-holiday__list">
    {% for holiday in holidays %}
      <li class="settings-holiday__item"><span class="settings-holiday__date">{{ holiday }}</span><a href="{{ path(‘organisation.remove_holiday', {'date': holiday}) }}" class="use-ajax settings-holiday__remove">{% trans %}Remove{% endtrans %}</a></li>
    {% endfor %}
  </ul>
{% else %}
  {% trans %}Holiday list is empty{% endtrans %}
{% endif %}
</div>

Таким образом у нас будет выводиться список дат со ссылкой «Remove», также будет ссылка «Clear list». Обратите внимание, что обе эти ссылки должны запускать ajax запрос, поэтому у каждой из них обязательно должен быть класс use-ajax. В урлах мы определили два роута: organisation.clear_holidays и organisation.remove_holiday, причем последний имеет параметр date — дата, которую необходимо удалить. Добавим эти роуты в organisation.routing.yml:

organisation.settings_form:
  path: '/admin/settings'
  defaults:
    _form: '\Drupal\organisation\Form\SettingsForm'
    _title: 'Organisation settings'
  requirements:
    _permission: 'administer site configuration'

organisation.remove_holiday:
  path: '/ajax/admin/organisation/holiday/remove/{date}'
  defaults:
    _controller: '\Drupal\organisation\Form\SettingsForm::removeHoliday'
    _title: 'Remove holiday'
  requirements:
    _permission: 'administer site configuration'

organisation.clear_holidays:
  path: '/ajax/admin/organisation/holiday/clear'
  defaults:
    _controller: '\Drupal\organisation\Form\SettingsForm::clearHolidays'
    _title: 'Clear holidays'
  requirements:
    _permission: 'administer site configuration'

Осталось определить саму форму и обработчики роутов. Для начала выведем форму. 

namespace Drupal\organisation\Form;

use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Datetime\DrupalDateTime;
use Symfony\Component\HttpFoundation\Request;

class SettingsForm extends ConfigFormBase {
  
  protected function getEditableConfigNames() {
    return [
      'organisation.settings',
    ];
  }

  // Helper function to get the html code of the list from the template
  protected static function renderHolidays($holidays, $error = null) {
    $theme = [
      '#theme' => 'holiday-list',
      '#holidays' => $holidays,
      '#error' => $error
    ];

    $renderer = \Drupal::service('renderer');
    return $renderer->render($theme);
  }

  public function buildForm(array $form, FormStateInterface $form_state) {
    $config = $this->config('organisation.settings');

    $form['name'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Organisation name'),
      '#default_value' => $config->get('name')
    ];

    $form['holidays'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('Holidays'),
    ];

    $form['holidays']['holiday_date'] = [
      '#type' => 'date',
      '#suffix' => SettingsForm::renderHolidays($config->get('holidays')),    
      '#ajax' => [
        'callback' => 'Drupal\organisation\Form\SettingsForm::addHoliday',    
        'wrapper' => 'holiday-list',                                          
        'progress' => [
          'type' => 'throbber',
          'message' => $this->t('Adding holiday...'),                        
        ],
      ],
    ];
  }
}

На этом этапе поле списка дат будет выглядеть так:

Ajax запрос будет срабатывать при выборе даты в поле holiday_date. Но если сейчас мы попробуем добавить дату, то запрос не выполнится. Нам необходимо добавить обработчики для описанных маршрутов.

// Add date

public static function addHoliday(array &$form, FormStateInterface $form_state) : AjaxResponse {
    
    $date = $form_state->getValue('holiday_date');
    $config = \Drupal::configFactory()->getEditable('organisation.settings');
    $holidays = $config->get('holidays');
    $error = null;

    try {     
      // Validate, since the date can not be entered completely. 
      // We are not going to focus on the date format let's assume that the format is used by default
      DrupalDateTime::createFromFormat('Y-m-d', $date);

      if (is_null($holidays)) {
        $holidays = [];
      }
      // Also we will not add duplicate dates
      if (!in_array($date, $holidays)) {
        $holidays[] = $date;
      } 
      else {
        $error = t('Holiday date %date already exists in this list', ['%date' => $date]);
      }
    } 

    catch (\Exception $e) {
      $error = t('Wrong date format. Enter full date.');
    }

    $config->set('holidays', $holidays)->save();
    $response = new AjaxResponse();
    $response->addCommand(new ReplaceCommand('#holiday-list', SettingsForm::renderHolidays($holidays, $error)));

    return $response;
  }

  // Delete the specified date. Remember that the route contains the date parameter

  public static function removeHoliday(string $date, Request $request) : AjaxResponse {
    $config = \Drupal::configFactory()->getEditable('organisation.settings');
    $holidays = $config->get('holidays');

    if (!is_null($holidays) && ($ind = array_search($date, $holidays)) !== false) {
      unset($holidays[$ind]);
      $config->set('holidays', $holidays)->save();
    }

    $response = new AjaxResponse();
    $response->addCommand(new ReplaceCommand('#holiday-list', SettingsForm::renderHolidays($holidays)));

    return $response;
  }

  // Delete all dates

  public static function clearHolidays(Request $request) : AjaxResponse {
    $config = \Drupal::configFactory()->getEditable('organisation.settings');
    $config->set('holidays', null)->save();
    $response = new AjaxResponse();
    $response->addCommand(new ReplaceCommand('#holiday-list', SettingsForm::renderHolidays(null)));
    return $response;
  }

Добавим немного CSS на ваше усмотрение:

.settings-holiday__list {
  padding: 0;
  margin: 12px 0;
  list-style: none;
}

.settings-holiday__item + .settings-holiday__item {
  margin-top: 8px;
}

.settings-holiday__remove, .settings-holiday__clear {
  margin-left: 70px;
}

Теперь список выглядит так (в данном примере еще показано, что будет, если ввести неверную дату):