10 марта 2018

Собственное поле для форм в Drupal 8

Бывает так, что при создании форм разработчику не хватает всех предопределенных типов полей, которые есть в Form API. В таком случае можно написать свой элемент формы, основываясь на классе Drupal 8 FormElement.

Рассмотрим разработку такого поля на основе поля для ввода времени. А потом на основе нового поля создадим поле, которое позволяет вводить интервал времени в пределах одних суток.

В HTML5 есть тип поля ввода time, который позволяет вводить часы, минуты и секунды:

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

Параметр step указывает на инкремент минут (в данном случае 15), если он меньше 60, то будет возможность вводить также и секунды.

Примечание: сделать функционал поля с вводом времени можно с помощью встроенного в Drupal типа datetime (нужно проверить, включён ли модуль Datetime).

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

Но мы хотим больше контроля над нашим полем.

Итак, создаем модуль, который будет выводить форму. В папке src/Element создадим файл Time.php, который и будет содержать поле времени.

Нам необходимо указать название нашего типа (time) — это то, что будет писаться в '#type' при построении формы и объявить метод getInfo, который описывает параметры элемента, определяет методы для валидации, рендеренга.

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

В секции '#process' мы определили метод processTime. В этом методе мы можем задать полю какие-то параметры по умолчанию, провести обработку параметров элемента, которые получаем из описания поля, когда создаем форму.

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

Также нам надо определить внешний вид нашего поля. В getInfo есть два параметра для этого:

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

Тема для вывода самого поля и его обвеса (врапперы, сообщения об ошибках, примечания и т. д.). Мы можем создать свою тему (сделаем это для второго поля — интервала), тут же мы используем стандартный шаблон для текстового поля и стандартный враппер для элемента формы. Все они определены в ядре.

Чтобы задать атрибуты поля ввода (так необходимый нам тип поля time), определяем '#pre_render' метод preRenderTime:

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

Для элемента надо определить, как будет формироваться значение по умолчанию и как вообще значение будет присваиваться элементу. Для этого нужен метод 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;
}

Здесь мы по умолчанию присваиваем переданное дефолтное значение, а, если значение уже есть в поле, то проверяем его на соответствие формату времени, очищая поле, если формат неверный.

И последнее, что надо сделать, это написать метод для валидации, заданный в getInfo в секции  '#element_validate':

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

Элемент готов! Теперь мы можем задать этот поле в форме таким образом:

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

Теперь на основе созданного элемента создадим поле, позволяющее вводить интервал времени. Тут все аналогично, на этом примере видно, как делать составные элементы, а также, как использовать свои шаблоны для вывода поля.

Создадим элемент 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;
  }
}

Как видно в методе processRange, элемент теперь содержит два поля: start и end, оба типа time. При присвоении значения полю, мы теперь имеем ассоциативный массив. С валидацией уже справится несложно, главное не забывать, что значение поля является массивом с ключами start и end.

В качестве шаблонов для вывода поля мы указали timerange_form и timerange_wrapper. Объявим эти шаблоны в модуле таким образом (код хуков взят из ядра):

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>

И немного CSS, чтобы эти два поля стояли рядом:

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

Теперь можно задать поле таким образом:

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

Результат:

Посмотреть исходники элементов ядра можно в core/lib/Drupal/Core/Render/Element.