Datelist.php 13.2 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
<?php

namespace Drupal\Core\Datetime\Element;

use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Datetime\DateHelper;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Form\FormStateInterface;

/**
 * Provides a datelist element.
 *
 * @FormElement("datelist")
 */
class Datelist extends DateElementBase {

  /**
   * {@inheritdoc}
   */
  public function getInfo() {
    $class = get_class($this);
22
    return [
23
      '#input' => TRUE,
24 25 26 27 28 29
      '#element_validate' => [
        [$class, 'validateDatelist'],
      ],
      '#process' => [
        [$class, 'processDatelist'],
      ],
30
      '#theme' => 'datetime_form',
31 32
      '#theme_wrappers' => ['datetime_wrapper'],
      '#date_part_order' => ['year', 'month', 'day', 'hour', 'minute'],
33 34
      '#date_year_range' => '1900:2050',
      '#date_increment' => 1,
35
      '#date_date_callbacks' => [],
36
      '#date_timezone' => drupal_get_user_timezone(),
37
    ];
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
  }

  /**
   * {@inheritdoc}
   *
   * Validates the date type to adjust 12 hour time and prevent invalid dates.
   * If the date is valid, the date is set in the form.
   */
  public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
    $parts = $element['#date_part_order'];
    $increment = $element['#date_increment'];

    $date = NULL;
    if ($input !== FALSE) {
      $return = $input;
53 54 55 56 57 58 59 60 61
      if (empty(static::checkEmptyInputs($input, $parts))) {
        if (isset($input['ampm'])) {
          if ($input['ampm'] == 'pm' && $input['hour'] < 12) {
            $input['hour'] += 12;
          }
          elseif ($input['ampm'] == 'am' && $input['hour'] == 12) {
            $input['hour'] -= 12;
          }
          unset($input['ampm']);
62
        }
63
        try {
64
          $date = DrupalDateTime::createFromArray($input, $element['#date_timezone']);
65 66 67 68
        }
        catch (\Exception $e) {
          $form_state->setError($element, t('Selected combination of day and month is not valid.'));
        }
69
        if ($date instanceof DrupalDateTime && !$date->hasErrors()) {
70
          static::incrementRound($date, $increment);
71 72 73 74 75 76 77
        }
      }
    }
    else {
      $return = array_fill_keys($parts, '');
      if (!empty($element['#default_value'])) {
        $date = $element['#default_value'];
78
        if ($date instanceof DrupalDateTime && !$date->hasErrors()) {
79
          $date->setTimezone(new \DateTimeZone($element['#date_timezone']));
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
          static::incrementRound($date, $increment);
          foreach ($parts as $part) {
            switch ($part) {
              case 'day':
                $format = 'j';
                break;

              case 'month':
                $format = 'n';
                break;

              case 'year':
                $format = 'Y';
                break;

              case 'hour':
96
                $format = in_array('ampm', $element['#date_part_order']) ? 'g' : 'G';
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
                break;

              case 'minute':
                $format = 'i';
                break;

              case 'second':
                $format = 's';
                break;

              case 'ampm':
                $format = 'a';
                break;

              default:
                $format = '';

            }
            $return[$part] = $date->format($format);
          }
        }
      }
    }
    $return['object'] = $date;
    return $return;
  }

  /**
   * Expands a date element into an array of individual elements.
   *
   * Required settings:
   *   - #default_value: A DrupalDateTime object, adjusted to the proper local
   *     timezone. Converting a date stored in the database from UTC to the local
   *     zone and converting it back to UTC before storing it is not handled here.
   *     This element accepts a date as the default value, and then converts the
   *     user input strings back into a new date object on submission. No timezone
   *     adjustment is performed.
   * Optional properties include:
   *   - #date_part_order: Array of date parts indicating the parts and order
   *     that should be used in the selector, optionally including 'ampm' for
   *     12 hour time. Default is array('year', 'month', 'day', 'hour', 'minute').
   *   - #date_text_parts: Array of date parts that should be presented as
   *     text fields instead of drop-down selectors. Default is an empty array.
   *   - #date_date_callbacks: Array of optional callbacks for the date element.
   *   - #date_year_range: A description of the range of years to allow, like
   *     '1900:2050', '-3:+3' or '2000:+3', where the first value describes the
   *     earliest year and the second the latest year in the range. A year
   *     in either position means that specific year. A +/- value describes a
   *     dynamic value that is that many years earlier or later than the current
   *     year at the time the form is displayed. Defaults to '1900:2050'.
   *   - #date_increment: The increment to use for minutes and seconds, i.e.
   *     '15' would show only :00, :15, :30 and :45. Defaults to 1 to show every
   *     minute.
150 151 152
   *   - #date_timezone: The Time Zone Identifier (TZID) to use when displaying
   *     or interpreting dates, i.e: 'Asia/Kolkata'. Defaults to the value
   *     returned by drupal_get_user_timezone().
153 154 155 156 157 158 159 160 161 162
   *
   * Example usage:
   * @code
   *   $form = array(
   *     '#type' => 'datelist',
   *     '#default_value' => new DrupalDateTime('2000-01-01 00:00:00'),
   *     '#date_part_order' => array('month', 'day', 'year', 'hour', 'minute', 'ampm'),
   *     '#date_text_parts' => array('year'),
   *     '#date_year_range' => '2010:2020',
   *     '#date_increment' => 15,
163
   *     '#date_timezone' => 'Asia/Kolkata'
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185
   *   );
   * @endcode
   *
   * @param array $element
   *   The form element whose value is being processed.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current state of the form.
   * @param array $complete_form
   *   The complete form structure.
   *
   * @return array
   */
  public static function processDatelist(&$element, FormStateInterface $form_state, &$complete_form) {
    // Load translated date part labels from the appropriate calendar plugin.
    $date_helper = new DateHelper();

    // The value callback has populated the #value array.
    $date = !empty($element['#value']['object']) ? $element['#value']['object'] : NULL;

    $element['#tree'] = TRUE;

    // Determine the order of the date elements.
186 187
    $order = !empty($element['#date_part_order']) ? $element['#date_part_order'] : ['year', 'month', 'day'];
    $text_parts = !empty($element['#date_text_parts']) ? $element['#date_text_parts'] : [];
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211

    // Output multi-selector for date.
    foreach ($order as $part) {
      switch ($part) {
        case 'day':
          $options = $date_helper->days($element['#required']);
          $format = 'j';
          $title = t('Day');
          break;

        case 'month':
          $options = $date_helper->monthNamesAbbr($element['#required']);
          $format = 'n';
          $title = t('Month');
          break;

        case 'year':
          $range = static::datetimeRangeYears($element['#date_year_range'], $date);
          $options = $date_helper->years($range[0], $range[1], $element['#required']);
          $format = 'Y';
          $title = t('Year');
          break;

        case 'hour':
212
          $format = in_array('ampm', $element['#date_part_order']) ? 'g' : 'G';
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236
          $options = $date_helper->hours($format, $element['#required']);
          $title = t('Hour');
          break;

        case 'minute':
          $format = 'i';
          $options = $date_helper->minutes($format, $element['#required'], $element['#date_increment']);
          $title = t('Minute');
          break;

        case 'second':
          $format = 's';
          $options = $date_helper->seconds($format, $element['#required'], $element['#date_increment']);
          $title = t('Second');
          break;

        case 'ampm':
          $format = 'a';
          $options = $date_helper->ampm($element['#required']);
          $title = t('AM/PM');
          break;

        default:
          $format = '';
237
          $options = [];
238 239 240
          $title = '';
      }

241
      $default = isset($element['#value'][$part]) && trim($element['#value'][$part]) != '' ? $element['#value'][$part] : '';
242
      $value = $date instanceof DrupalDateTime && !$date->hasErrors() ? $date->format($format) : $default;
243 244 245 246 247
      if (!empty($value) && $part != 'ampm') {
        $value = intval($value);
      }

      $element['#attributes']['title'] = $title;
248
      $element[$part] = [
249 250 251 252 253 254 255
        '#type' => in_array($part, $text_parts) ? 'textfield' : 'select',
        '#title' => $title,
        '#title_display' => 'invisible',
        '#value' => $value,
        '#attributes' => $element['#attributes'],
        '#options' => $options,
        '#required' => $element['#required'],
256
        '#error_no_message' => FALSE,
257
        '#empty_option' => $title,
258
      ];
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
    }

    // Allows custom callbacks to alter the element.
    if (!empty($element['#date_date_callbacks'])) {
      foreach ($element['#date_date_callbacks'] as $callback) {
        if (function_exists($callback)) {
          $callback($element, $form_state, $date);
        }
      }
    }

    return $element;
  }

  /**
   * Validation callback for a datelist element.
   *
   * If the date is valid, the date object created from the user input is set in
   * the form for use by the caller. The work of compiling the user input back
   * into a date object is handled by the value callback, so we can use it here.
   * We also have the raw input available for validation testing.
   *
   * @param array $element
   *   The element being processed.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current state of the form.
   * @param array $complete_form
   *   The complete form structure.
   */
  public static function validateDatelist(&$element, FormStateInterface $form_state, &$complete_form) {
    $input_exists = FALSE;
    $input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists);
291 292
    $title = static::getElementTitle($element, $complete_form);

293
    if ($input_exists) {
294
      $all_empty = static::checkEmptyInputs($input, $element['#date_part_order']);
295 296 297 298 299 300 301

      // If there's empty input and the field is not required, set it to empty.
      if (empty($input['year']) && empty($input['month']) && empty($input['day']) && !$element['#required']) {
        $form_state->setValueForElement($element, NULL);
      }
      // If there's empty input and the field is required, set an error.
      elseif (empty($input['year']) && empty($input['month']) && empty($input['day']) && $element['#required']) {
302
        $form_state->setError($element, t('The %field date is required.', ['%field' => $title]));
303
      }
304
      elseif (!empty($all_empty)) {
305
        foreach ($all_empty as $value) {
306
          $form_state->setError($element, t('The %field date is incomplete.', ['%field' => $title]));
307
          $form_state->setError($element[$value], t('A value must be selected for %part.', ['%part' => $value]));
308 309
        }
      }
310 311 312
      else {
        // If the input is valid, set it.
        $date = $input['object'];
313
        if ($date instanceof DrupalDateTime && !$date->hasErrors()) {
314 315
          $form_state->setValueForElement($element, $date);
        }
316 317
        // If the input is invalid and an error doesn't exist, set one.
        elseif ($form_state->getError($element) === NULL) {
318
          $form_state->setError($element, t('The %field date is invalid.', ['%field' => $title]));
319 320 321 322 323
        }
      }
    }
  }

324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345
  /**
   * Checks the input array for empty values.
   *
   * Input array keys are checked against values in the parts array. Elements
   * not in the parts array are ignored. Returns an array representing elements
   * from the input array that have no value. If no empty values are found,
   * returned array is empty.
   *
   * @param array $input
   *   Array of individual inputs to check for value.
   * @param array $parts
   *   Array to check input against, ignoring elements not in this array.
   *
   * @return array
   *   Array of keys from the input array that have no value, may be empty.
   */
  protected static function checkEmptyInputs($input, $parts) {
    // Filters out empty array values, any valid value would have a string length.
    $filtered_input = array_filter($input, 'strlen');
    return array_diff($parts, array_keys($filtered_input));
  }

346 347 348 349 350 351 352 353 354 355
  /**
   * Rounds minutes and seconds to nearest requested value.
   *
   * @param $date
   * @param $increment
   *
   * @return
   */
  protected static function incrementRound(&$date, $increment) {
    // Round minutes and seconds, if necessary.
356
    if ($date instanceof DrupalDateTime && $increment > 1) {
357 358 359 360
      $day = intval($date->format('j'));
      $hour = intval($date->format('H'));
      $second = intval(round(intval($date->format('s')) / $increment) * $increment);
      $minute = intval($date->format('i'));
361 362 363 364 365 366 367 368 369
      if ($second == 60) {
        $minute += 1;
        $second = 0;
      }
      $minute = intval(round($minute / $increment) * $increment);
      if ($minute == 60) {
        $hour += 1;
        $minute = 0;
      }
370
      $date->setTime($hour, $minute, $second);
371 372
      if ($hour == 24) {
        $day += 1;
373 374 375
        $year = $date->format('Y');
        $month = $date->format('n');
        $date->setDate($year, $month, $day);
376 377 378 379 380 381
      }
    }
    return $date;
  }

}