Mar 10, 2018

Custom form field in Drupal 8

It so happens that when creating forms, the developer lacks all the predefined field types that are in the Form API. In this case, you can write your custom form element based on the Drupal 8 FormElement class.

Consider the development of such field based on a time field. And then on the basis of the new field we will create a field that allows you to enter a time interval within one day.

HTML5 has a type of time field that allows you to enter hours, minutes and seconds:

<input type="time" step="900" />

The step parameter indicates the increment of minutes (in this case, 15), if it is less than 60, then it will be possible to enter also seconds.

Note: you can make this field with time input using the built-in Drupal type datetime (you need to check if the Datetime module is enabled).

$form['time'] = [
  '#type' => 'datetime', 
    '#date_date_element' => 'none', 
    '#date_time_element' => 'time' 
];

But we want more control over our field.

So, create a module that will display the form. Create Time.php in the src/Element folder. This file will contain a time field.

We need to specify the name of our type (time) — this is what will be written in '#type' when building the form, declare the getInfo method, which describes the parameters of the element and defines the methods for validation, rendering.

namespace Drupal\settings\Element;

use Drupal\Core\Render\Element;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Datetime\Entity\DateFormat;
use Drupal\Component\Utility\NestedArray;

/**
 * Provides a time element.
 *
 * @FormElement("time")
 */
class Time extends Element\FormElement {

  public function getInfo() {
    $time_format = '';
    if (!defined('MAINTENANCE_MODE')) {
      if ($time_format_entity = DateFormat::load('html_time')) {
        $time_format = $time_format_entity->getPattern();
      }
    }

    $class = get_class($this);
    return [
      '#input' => TRUE,
      '#element_validate' => [
        [$class, 'validateTime'],
      ],
      '#process' => [
        [$class, 'processTime'],
        [$class, 'processGroup'],
      ],
      '#pre_render' => [
        [$class, 'preRenderTime'],
        [$class, 'preRenderGroup'],
      ],
      '#theme' => 'input__textfield',
      '#theme_wrappers' => ['form_element'],
      '#time_format' => $time_format,
      '#time_callbacks' => [],
      '#step' => 60 * 15,
    ];
  }
}

In the '#process' section, we defined the processTime method. In this method, we can set default parameters for the field and process the parameters of the element, which we get from the field description when we create the form.

public static function processTime(&$element, FormStateInterface $form_state, &$complete_form) {
  $element['time'] = [
    '#name' => $element['#name'],
    '#title' => t('Time'),
    '#title_display' => 'invisible',
    '#default_value' => $element['#default_value'],
    '#attributes' => $element['#attributes'],
    '#required' => $element['#required'],
    '#size' => 12,
    '#error_no_message' => TRUE,
  ];

  return $element;
}

In getInfo, there are two options for theme:

'#theme' => 'input__textfield',
'#theme_wrappers' => ['form_element'],

Theme for the field output. We can create our own theme (we will do it for the second field — interval), but here we use a standard template for the text field and a standard wrapper for the form element. They are all defined in the core.

To set the input field attributes, we define the preRenderTime method:

public static function preRenderTime($element) {
  $element['#attributes']['type'] = 'time';
  Element::setAttributes($element, ['id', 'name', 'value', 'size', 'step']);
  // Sets the necessary attributes, such as the error class for validation.
  // Without this line the field will not be hightlighted, if an error occurred
  static::setAttributes($element, ['form-text']);
  return $element;
}

For an element, it is necessary to determine how the default value will be formed and how the value will generally be assigned to the element. For this you need a method valueCallback:

public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
  if ($input !== FALSE) {
    $format = isset($element['#time_format']) && $element['#time_format'] ? $element['#time_format'] : 'html_time';
    $time_format =  DateFormat::load($format)->getPattern();

    try {
      DrupalDateTime::createFromFormat($time_format, $input, NULL);
    }
    catch (\Exception $e) {
      $input = NULL;
    }
  }
  else {
    $input = $element['#default_value'];
  }
  return $input;
}

Here, by default, we assign the passed default value, and if the value is already in the field, then check it for compliance with the time format, clearing the field if the format is incorrect.

The last thing to do is to write the validation method specified in getInfo in the '#element_validate' section:

public static function validateTime(&$element, FormStateInterface $form_state, &$complete_form) {
  $format = isset($element['#time_format']) && $element['#time_format'] ? $element['#time_format'] : 'html_time';
  $time_format =  DateFormat::load($format)->getPattern();
  $title = !empty($element['#title']) ? $element['#title'] : '';
  $input_exists = FALSE;
  $input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists);

  if ($input_exists) {
    if (empty($input) && !$element['#required']) {
      $form_state->setValueForElement($element, NULL);
    }

    elseif (empty($input) && $element['#required']) {
      $form_state->setError($element, t('The %field is required. Please enter time in the format %format.', ['%field' => $title, '%format' => $time_format]));
    }
    else {
      try {
        DrupalDateTime::createFromFormat($time_format, $input, NULL);
        $form_state->setValueForElement($element, $input);
      }
      catch (\Exception $e) {
        $form_state->setError($element, t('The %field is required. Please enter time in the format %format.', ['%field' => $title, '%format' => $time_format]));
      }
    }
  }
}

Element is ready! Now we can set this field in the form:

$form['time'] = [
  '#type' => 'time',
  '#name' => 'time',
  '#title' => t('Time'),
  '#step' => 60 * 15,
  '#default_value' => '11:00'
];

Now, on the basis of the created element, create a field that allows you to enter a time interval. It's all the same here, in this example you can see how to make composite elements, as well as how to use your templates to display the field.

Create element Timerange:

/**
 * Provides a time range element.
 *
 * @FormElement("timerange")
 */
class Timerange extends Element\FormElement {

  public function getInfo() {
    $time_format = '';
    if (!defined('MAINTENANCE_MODE')) {
      if ($time_format_entity = DateFormat::load('html_time')) {
        $time_format = $time_format_entity->getPattern();
      }
    }

    $class = get_class($this);
    return [
      '#input' => TRUE,
      '#element_validate' => [
        [$class, 'validateTimerange'],
      ],
      '#process' => [
        [$class, 'processRange'],
        [$class, 'processGroup'],
      ],
      '#pre_render' => [
        [$class, 'preRenderGroup'],
      ],
      '#theme' => 'timerange_form',
      '#theme_wrappers' => ['timerange_wrapper'],
      '#time_format' => $time_format,
      '#time_callbacks' => [],
      '#step' => 60 * 15,
    ];
  }

  public static function processRange(&$element, FormStateInterface $form_state, &$complete_form) {
    $element['#tree'] = TRUE;

    $element['start'] = [
      '#type' => 'time',
      '#name' => $element['#name'].'[start]',
      '#time_format' => $element['#time_format'],
      '#step' => 60 * 15,
      '#default_value' => $element['#default_value']['start']
    ];

    $element['end'] = [
      '#type' => 'time',
      '#name' => $element['#name'].'[end]',
      '#time_format' => $element['#time_format'],
      '#step' => 60 * 15,
      '#default_value' => $element['#default_value']['end']
    ];

    return $element;
  }

  public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
    if ($input !== FALSE) {
      $format = isset($element['#time_format']) && $element['#time_format'] ? $element['#time_format'] : 'html_time';
      $time_format =  DateFormat::load($format)->getPattern();

      try {
        DrupalDateTime::createFromFormat($time_format, $input['start'], NULL);
      }
      catch (\Exception $e) {
        $input['start'] = NULL;
      }

      try {
        DrupalDateTime::createFromFormat($time_format, $input['end'], NULL);
      }
      catch (\Exception $e) {
        $input['end'] = NULL;
      }
    } 
    else {
      $input = [
        'start' => $element['#default_value']['start'],
        'end' => $element['#default_value']['end'],
      ];
    }

    return $input;
  }
}

As you can see in the processRange method, the element now contains two fields: start and end, both of the time type. When assigning a value to a field, we now have an associative array. It is easy to cope with validation, the main thing is not to forget that the value of the field is an array with the start and end keys.

As templates for the field output, we specified timerange_form and timerange_wrapper. Preprocess hooks are taken from the Drupal's core:

function settings_theme() {
  return [
     // ...
    'timerange_form' => [
      'render element' => 'element',
  ],
    'timerange_wrapper' => [
      'render element' => 'element',
  ]
  ];
}

// ******

function template_preprocess_timerange_form(&$variables) {
  $element = $variables['element'];

  $variables['attributes'] = [];
  if (isset($element['#id'])) {
    $variables['attributes']['id'] = $element['#id'];
  }
  if (!empty($element['#attributes']['class'])) {
    $variables['attributes']['class'] = (array) $element['#attributes']['class'];
  }

  $variables['content'] = $element;
}

function template_preprocess_timerange_wrapper(&$variables) {
  $element = $variables['element'];

  if (!empty($element['#title'])) {
    $variables['title'] = $element['#title'];
  }

  // Suppress error messages.
  $variables['errors'] = NULL;

  $variables['description'] = NULL;
  if (!empty($element['#description'])) {
    $description_attributes = [];
    if (!empty($element['#id'])) {
      $description_attributes['id'] = $element['#id'] . '--description';
    }
    $variables['description'] = $element['#description'];
    $variables['description_attributes'] = new Attribute($description_attributes);
  }

  $variables['required'] = FALSE;
  // For required datetime fields 'form-required' & 'js-form-required' classes
  // are appended to the label attributes.
  if (!empty($element['#required'])) {
    $variables['required'] = TRUE;
  }
  $variables['content'] = $element['#children'];
}

timerage-form.html.twig:

<div{{ attributes }} class="timerange-fields">
  {{ content }}
</div>

timerange-wrapper.html.twig:

<div class="timerange">
{%
  set title_classes = [
  required ? 'js-form-required',
  required ? 'form-required',
  'label'
]
%}
{% if title %}
  <h4{{ title_attributes.addClass(title_classes) }}>{{ title }}</h4>
{% endif %}
{{ content }}
{% if errors %}
  <div>
    {{ errors }}
  </div>
{% endif %}
{% if description %}
  <div{{ description_attributes }}>
    {{ description }}
  </div>
{% endif %}
</div>

And a bit of CSS so that these two fields stand side by side:

.timerange-fields {
  display: flex;
  justify-content: flex-start;
}

.timerange-fields > div {
  margin-left: 20px;
}

.timerange-fields > div:first-child {
  margin-left: 0;
}

.timerange .form-item {
  margin-top: 0;
}

Now you can set the field like this:

$form['working_hours'] = [
  '#tree' => TRUE,
  '#type' => 'timerange',
  '#title' => $this->t('Working hours'),
  '#time_format' => 'working_time',
  '#default_value' => ['start' => '11:00', 'end' => '17:00'],
];

Result:

You can view the sources of the core elements in core/lib/Drupal/Core/Render/Element.