Mar 27, 2018

Configuration forms and Ajax API in Drupal 8

Everyone understands how the usual configuration form works, created using the Form API. The user clicks the Save button: the page reloads, the form is updated. But what if we need to change some form values without reloading the page?

Ajax API will help us. Consider the use of the example of list management. Let's say we want to create a form where you can enter the name of the organization and a list of holiday dates when the organization is not working.  We need to add items to the list, delete them and clear the whole list — all these actions we will do with the help of Ajax API.

Create a module organisation with a simple form: a field with the name of the organization and a field with non-working days.

To begin, let's do the preparatory work:

Let's start with the template. Define the template in our module:

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

Here we have two variables: a list of non-working days and an error. Create 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>

We will display a list of dates with the "Remove" link, there will be also a "Clear list" link. Note, that both of these links must run an ajax request, so each must have a class use-ajax. We defined two routes in the URLs: organisation.clear_holidays and organisation.remove_holiday. The latter having the date parameter — the date that you want to remove. Add these routes to 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'

It remains to determine the form itself and route handlers.  First, let's display the form.

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...'),                        
        ],
      ],
    ];
  }
}

At this stage, the date list field will look like this:

Ajax request will be triggered when selecting a date in the holiday_date field. But if now we try to add a date, the request will not be executed. We need to add handlers for the described routes.

// 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;
  }

Add some CSS, up to you:

.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;
}

Now the list looks like this (in this example it is shown what happens if you enter an incorrect date):