diff --git a/core/modules/datetime/datetime.module b/core/modules/datetime/datetime.module index 2ad07a2..271681b 100644 --- a/core/modules/datetime/datetime.module +++ b/core/modules/datetime/datetime.module @@ -5,7 +5,13 @@ * Field hooks to implement a simple datetime field. */ +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Datetime\DrupalDateTime; +use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Template\Attribute; +use Drupal\datetime\DateHelper; +use Drupal\node\NodeInterface; /** * Defines the timezone that dates should be stored in. @@ -30,19 +36,162 @@ function datetime_help($route_name, RouteMatchInterface $route_match) { case 'help.page.datetime': $output = ''; $output .= '

' . t('About') . '

'; - $output .= '

' . t('The Datetime module provides a Date field that stores dates and times. It also provides the Form API elements datetime and datelist for use in programming modules. See the Field module help and the Field UI module help pages for general information on fields and how to create and manage them. For more information, see the online documentation for the Datetime module.', array(':field' => \Drupal::url('help.page', array('name' => 'field')), ':field_ui' => (\Drupal::moduleHandler()->moduleExists('field_ui')) ? \Drupal::url('help.page', array('name' => 'field_ui')) : '#', ':datetime_do' => 'https://www.drupal.org/documentation/modules/datetime')) . '

'; + $output .= '

' . t('The Datetime module provides a Date field that stores dates and times. It also provides the Form API elements datetime and datelist for use in programming modules. See the Field module help and the Field UI module help pages for general information on fields and how to create and manage them. For more information, see the online documentation for the Datetime module.', array('!field' => \Drupal::url('help.page', array('name' => 'field')), '!field_ui' => \Drupal::url('help.page', array('name' => 'field_ui')), '!datetime_do' => 'https://drupal.org/documentation/modules/datetime')) . '

'; $output .= '

' . t('Uses') . '

'; $output .= '
'; $output .= '
' . t('Managing and displaying date fields') . '
'; - $output .= '
' . t('The settings and the display of the Date field can be configured separately. See the Field UI help for more information on how to manage fields and their display.', array(':field_ui' => (\Drupal::moduleHandler()->moduleExists('field_ui')) ? \Drupal::url('help.page', array('name' => 'field_ui')) : '#')) . '
'; + $output .= '
' . t('The settings and the display of the Date field can be configured separately. See the Field UI help for more information on how to manage fields and their display.', array('!field_ui' => \Drupal::url('help.page', array('name' => 'field_ui')))) . '
'; $output .= '
' . t('Displaying dates') . '
'; - $output .= '
' . t('Dates can be displayed using the Plain or the Default formatter. The Plain formatter displays the date in the ISO 8601 format. If you choose the Default formatter, you can choose a format from a predefined list that can be managed on the Date and time formats page.', array(':date_format_list'=> \Drupal::url('entity.date_format.collection'))) . '
'; + $output .= '
' . t('Dates can be displayed using the Plain or the Default formatter. The Plain formatter displays the date in the ISO 8601 format. If you choose the Default formatter, you can choose a format from a predefined list that can be managed on the Date and time formats page.', array('!date_format_list'=> \Drupal::url('system.date_format_list'))) . '
'; $output .= '
'; return $output; } } /** + * Implements hook_element_info(). + */ +function datetime_element_info() { + $date_format = ''; + $time_format = ''; + // Date formats cannot be loaded during install or update. + if (!defined('MAINTENANCE_MODE')) { + if ($date_format_entity = entity_load('date_format', 'html_date')) { + $date_format = $date_format_entity->getPattern(); + } + if ($time_format_entity = entity_load('date_format', 'html_time')) { + $time_format = $time_format_entity->getPattern(); + } + } + $types['datetime'] = array( + '#input' => TRUE, + '#element_validate' => array('datetime_datetime_validate'), + '#process' => array('datetime_datetime_form_process', 'form_process_group'), + '#pre_render' => array('form_pre_render_group'), + '#theme' => 'datetime_form', + '#theme_wrappers' => array('datetime_wrapper'), + '#date_date_format' => $date_format, + '#date_date_element' => 'date', + '#date_date_callbacks' => array(), + '#date_time_format' => $time_format, + '#date_time_element' => 'time', + '#date_time_callbacks' => array(), + '#date_year_range' => '1900:2050', + '#date_increment' => 1, + '#date_timezone' => '', + ); + $types['datelist'] = array( + '#input' => TRUE, + '#element_validate' => array('datetime_datelist_validate'), + '#process' => array('datetime_datelist_form_process'), + '#theme' => 'datetime_form', + '#theme_wrappers' => array('datetime_wrapper'), + '#date_part_order' => array('year', 'month', 'day', 'hour', 'minute'), + '#date_year_range' => '1900:2050', + '#date_increment' => 1, + '#date_date_callbacks' => array(), + '#date_timezone' => '', + ); + return $types; +} + +/** + * Implements hook_theme(). + */ +function datetime_theme() { + return array( + 'datetime_form' => array( + 'template' => 'datetime-form', + 'render element' => 'element', + ), + 'datetime_wrapper' => array( + 'template' => 'datetime-wrapper', + 'render element' => 'element', + ), + 'datetime_fieldset_wrapper' => array( + 'template' => 'datetime-fieldset-wrapper', + 'render element' => 'element', + ), + ); +} + +/** + * Validation callback for the datetime widget element. + * + * The date has already been validated by the datetime form type validator and + * transformed to an date object. We just need to convert the date back to a the + * storage timezone and format. + * + * @param array $element + * The form element whose value is being validated. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ +function datetime_datetime_widget_validate(&$element, FormStateInterface $form_state) { + if (!form_get_errors($form_state)) { + $input_exists = FALSE; + $input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists); + if ($input_exists) { + // The date should have been returned to a date object at this point by + // datetime_validate(), which runs before this. + if (!empty($input['value'])) { + $date = $input['value']; + if ($date instanceOf DrupalDateTime && !$date->hasErrors()) { + + // If this is a date-only field, set it to the default time so the + // timezone conversion can be reversed. + if ($element['value']['#date_time_element'] == 'none') { + datetime_date_default_time($date); + } + // Adjust the date for storage. + $date->setTimezone(new \DateTimezone(DATETIME_STORAGE_TIMEZONE)); + $value = $date->format($element['value']['#date_storage_format']); + form_set_value($element['value'], $value, $form_state); + } + } + } + } +} + +/** + * Validation callback for the datelist widget element. + * + * The date has already been validated by the datetime form type validator and + * transformed to an date object. We just need to convert the date back to a the + * storage timezone and format. + * + * @param array $element + * The form element whose value is being validated. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ +function datetime_datelist_widget_validate(&$element, FormStateInterface $form_state) { + if (!form_get_errors($form_state)) { + $input_exists = FALSE; + $input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists); + if ($input_exists) { + // The date should have been returned to a date object at this point by + // datetime_validate(), which runs before this. + if (!empty($input['value'])) { + $date = $input['value']; + if ($date instanceOf DrupalDateTime && !$date->hasErrors()) { + + // If this is a date-only field, set it to the default time so the + // timezone conversion can be reversed. + if (!in_array('hour', $element['value']['#date_part_order'])) { + datetime_date_default_time($date); + } + // Adjust the date for storage. + $date->setTimezone(new \DateTimezone(DATETIME_STORAGE_TIMEZONE)); + $value = $date->format($element['value']['#date_storage_format']); + form_set_value($element['value'], $value, $form_state); + } + } + } + } +} + +/** * Sets a consistent time on a date without time. * * The default time for a date without time can be anything, so long as it is @@ -55,3 +204,865 @@ function datetime_help($route_name, RouteMatchInterface $route_match) { function datetime_date_default_time($date) { $date->setTime(12, 0, 0); } + +/** + * Prepares variables for datetime form element templates. + * + * The datetime form element serves as a wrapper around the date element type, + * which creates a date and a time component for a date. + * + * Default template: datetime-form.html.twig. + * + * @param array $variables + * An associative array containing: + * - element: An associative array containing the properties of the element. + * Properties used: #title, #value, #options, #description, #required, + * #attributes. + * + * @see form_process_datetime() + */ +function template_preprocess_datetime_form(&$variables) { + $element = $variables['element']; + + $variables['attributes'] = array(); + if (isset($element['#id'])) { + $variables['attributes']['id'] = $element['#id']; + } + if (!empty($element['#attributes']['class'])) { + $variables['attributes']['class'] = (array) $element['#attributes']['class']; + } + $variables['attributes']['class'][] = 'container-inline'; + + $variables['content'] = $element; +} + +/** + * Prepares variables for datetime form wrapper templates. + * + * Default template: datetime-wrapper.html.twig. + * + * @param array $variables + * An associative array containing: + * - element: An associative array containing the properties of the element. + * Properties used: #title, #children, #required, #attributes. + */ +function template_preprocess_datetime_wrapper(&$variables) { + $element = $variables['element']; + + if (!empty($element['#title'])) { + $variables['title'] = $element['#title']; + } + + if (!empty($element['#description'])) { + // Add aria-describedby attribute to title tag for screen readers. + $variables['description'] = $element['#description']; + $variables['attributes']['class'][] = 'description'; + // Associate the description with the aria-describedby attribute. + $variables['attributes']['id'] = $variables['attributes']['aria-describedby']; + // Remove aria-describedby attribute as it shouldn't be visible here. + unset($variables['attributes']['aria-describedby']); + } + + $title_attributes = array('class' => array('label')); + // For required datetime fields a 'form-required' class is appended to the + // label attributes. + if (!empty($element['#required'])) { + $title_attributes['class'][] = 'form-required'; + } + $variables['title_attributes'] = new Attribute($title_attributes); + $variables['content'] = $element['#children']; +} + +/** + * Prepares variables for datetime fieldset form wrapper templates. + * + * Default template: datetime-fieldset-wrapper.html.twig. + * + * @param array $variables + * An associative array containing: + * - element: An associative array containing the properties of the element. + * Properties used: #attributes, ,#content, #description, #id, #title, + * #required. + */ +function template_preprocess_datetime_fieldset_wrapper(&$variables) { + $element = $variables['element']; + $variables['content'] = $element['#children']; + $variables['attributes'] = $element['#attributes']; + $variables['attributes']['class'][] = 'form-item'; + $legend_attributes = array(); + if (isset($element['#title_display']) && $element['#title_display'] == 'invisible') { + $legend_attributes['class'][] = 'visually-hidden'; + } + $variables['legend']['attributes'] = new Attribute($legend_attributes); + if (!empty($element['#title'])) { + $variables['legend']['title'] = $element['#title']; + } +// $variables['legend']['title'] = (isset($element['#title']) && $element['#title'] !== '') ? Xss::filterAdmin($element['#title']) : ''; + $legend_span_attributes = array('class' => array('fieldset-legend')); + if (!empty($element['#required'])) { + $legend_span_attributes['class'][] = 'form-required'; + $variables['legend_span']['attributes'] = new Attribute($legend_span_attributes); + } + if (!empty($element['#description'])) { + $description_attributes = array( + 'class' => 'description', + 'id' => $element['#attributes']['aria-describedby'], + ); + $variables['description']['attributes'] = new Attribute($description_attributes); + $variables['description']['content'] = $element['#description']; + } +} + +/** + * Expands a datetime element type into date and/or time elements. + * + * All form elements are designed to have sane defaults so any or all can be + * omitted. Both the date and time components are configurable so they can be + * output as HTML5 datetime elements or not, as desired. + * + * Examples of possible configurations include: + * HTML5 date and time: + * #date_date_element = 'date'; + * #date_time_element = 'time'; + * HTML5 datetime: + * #date_date_element = 'datetime'; + * #date_time_element = 'none'; + * HTML5 time only: + * #date_date_element = 'none'; + * #date_time_element = 'time' + * Non-HTML5: + * #date_date_element = 'text'; + * #date_time_element = 'text'; + * + * 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_date_format: A date format string that describes the format that + * should be displayed to the end user for the date. When using HTML5 + * elements the format MUST use the appropriate HTML5 format for that + * element, no other format will work. See the format_date() function for a + * list of the possible formats and HTML5 standards for the HTML5 + * requirements. Defaults to the right HTML5 format for the chosen element + * if a HTML5 element is used, otherwise defaults to + * entity_load('date_format', 'html_date')->getPattern(). + * - #date_date_element: The date element. Options are: + * - datetime: Use the HTML5 datetime element type. + * - datetime-local: Use the HTML5 datetime-local element type. + * - date: Use the HTML5 date element type. + * - text: No HTML5 element, use a normal text field. + * - none: Do not display a date element. + * - #date_date_callbacks: Array of optional callbacks for the date element. + * Can be used to add a jQuery datepicker. + * - #date_time_element: The time element. Options are: + * - time: Use a HTML5 time element type. + * - text: No HTML5 element, use a normal text field. + * - none: Do not display a time element. + * - #date_time_format: A date format string that describes the format that + * should be displayed to the end user for the time. When using HTML5 + * elements the format MUST use the appropriate HTML5 format for that + * element, no other format will work. See the format_date() function for + * a list of the possible formats and HTML5 standards for the HTML5 + * requirements. Defaults to the right HTML5 format for the chosen element + * if a HTML5 element is used, otherwise defaults to + * entity_load('date_format', 'html_time')->getPattern(). + * - #date_time_callbacks: An array of optional callbacks for the time + * element. Can be used to add a jQuery timepicker or an 'All day' checkbox. + * - #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. Used in jQueryUI datepicker year + * range and HTML5 min/max date settings. 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. Used for HTML5 step values and + * jQueryUI datepicker settings. Defaults to 1 to show every minute. + * - #date_timezone: The local timezone to use when creating dates. Generally + * this should be left empty and it will be set correctly for the user using + * the form. Useful if the default value is empty to designate a desired + * timezone for dates created in form processing. If a default date is + * provided, this value will be ignored, the timezone in the default date + * takes precedence. Defaults to the value returned by + * drupal_get_user_timezone(). + * + * Example usage: + * @code + * $form = array( + * '#type' => 'datetime', + * '#default_value' => new DrupalDateTime('2000-01-01 00:00:00'), + * '#date_date_element' => 'date', + * '#date_time_element' => 'none', + * '#date_year_range' => '2010:+3', + * ); + * @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. + * + * @return array + * The form element whose value has been processed. + */ +function datetime_datetime_form_process($element, FormStateInterface $form_state) { + $format_settings = array(); + // The value callback has populated the #value array. + $date = !empty($element['#value']['object']) ? $element['#value']['object'] : NULL; + + // Set a fallback timezone. + if ($date instanceOf DrupalDateTime) { + $element['#date_timezone'] = $date->getTimezone()->getName(); + } + elseif (!empty($element['#timezone'])) { + $element['#date_timezone'] = $element['#date_timezone']; + } + else { + $element['#date_timezone'] = drupal_get_user_timezone(); + } + + $element['#tree'] = TRUE; + + if ($element['#date_date_element'] != 'none') { + + $date_format = $element['#date_date_element'] != 'none' ? datetime_html5_format('date', $element) : ''; + $date_value = !empty($date) ? $date->format($date_format, $format_settings) : $element['#value']['date']; + + // Creating format examples on every individual date item is messy, and + // placeholders are invalid for HTML5 date and datetime, so an example + // format is appended to the title to appear in tooltips. + $extra_attributes = array( + 'title' => t('Date (i.e. !format)', array('!format' => datetime_format_example($date_format))), + 'type' => $element['#date_date_element'], + ); + + // Adds the HTML5 date attributes. + if ($date instanceOf DrupalDateTime && !$date->hasErrors()) { + $html5_min = clone($date); + $range = datetime_range_years($element['#date_year_range'], $date); + $html5_min->setDate($range[0], 1, 1)->setTime(0, 0, 0); + $html5_max = clone($date); + $html5_max->setDate($range[1], 12, 31)->setTime(23, 59, 59); + + $extra_attributes += array( + 'min' => $html5_min->format($date_format, $format_settings), + 'max' => $html5_max->format($date_format, $format_settings), + ); + } + + $element['date'] = array( + '#type' => 'date', + '#title' => t('Date'), + '#title_display' => 'invisible', + '#value' => $date_value, + '#attributes' => $element['#attributes'] + $extra_attributes, + '#required' => $element['#required'], + '#size' => max(12, strlen($element['#value']['date'])), + ); + + // 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); + } + } + } + } + + if ($element['#date_time_element'] != 'none') { + + $time_format = $element['#date_time_element'] != 'none' ? datetime_html5_format('time', $element) : ''; + $time_value = !empty($date) ? $date->format($time_format, $format_settings) : $element['#value']['time']; + + // Adds the HTML5 attributes. + $extra_attributes = array( + 'title' =>t('Time (i.e. !format)', array('!format' => datetime_format_example($time_format))), + 'type' => $element['#date_time_element'], + 'step' => $element['#date_increment'], + ); + $element['time'] = array( + '#type' => 'date', + '#title' => t('Time'), + '#title_display' => 'invisible', + '#value' => $time_value, + '#attributes' => $element['#attributes'] + $extra_attributes, + '#required' => $element['#required'], + '#size' => 12, + ); + + // Allows custom callbacks to alter the element. + if (!empty($element['#date_time_callbacks'])) { + foreach ($element['#date_time_callbacks'] as $callback) { + if (function_exists($callback)) { + $callback($element, $form_state, $date); + } + } + } + } + + return $element; +} + +/** + * Value callback for a datetime element. + * + * @param array $element + * The form element whose value is being populated. + * @param array $input + * (optional) The incoming input to populate the form element. If this is + * FALSE, the element's default value should be returned. Defaults to FALSE. + * + * @return array + * The data that will appear in the $element_state['values'] collection for + * this element. Return nothing to use the default. + */ +function form_type_datetime_value($element, $input = FALSE) { + if ($input !== FALSE) { + $date_input = $element['#date_date_element'] != 'none' && !empty($input['date']) ? $input['date'] : ''; + $time_input = $element['#date_time_element'] != 'none' && !empty($input['time']) ? $input['time'] : ''; + $date_format = $element['#date_date_element'] != 'none' ? datetime_html5_format('date', $element) : ''; + $time_format = $element['#date_time_element'] != 'none' ? datetime_html5_format('time', $element) : ''; + $timezone = !empty($element['#date_timezone']) ? $element['#date_timezone'] : NULL; + + // Seconds will be omitted in a post in case there's no entry. + if (!empty($time_input) && strlen($time_input) == 5) { + $time_input .= ':00'; + } + + try { + $date_time_format = trim($date_format . ' ' . $time_format); + $date_time_input = trim($date_input . ' ' . $time_input); + $date = DrupalDateTime::createFromFormat($date_time_format, $date_time_input, $timezone); + } + catch (\Exception $e) { + $date = NULL; + } + $input = array( + 'date' => $date_input, + 'time' => $time_input, + 'object' => $date, + ); + } + else { + $date = $element['#default_value']; + if ($date instanceOf DrupalDateTime && !$date->hasErrors()) { + $input = array( + 'date' => $date->format($element['#date_date_format']), + 'time' => $date->format($element['#date_time_format']), + 'object' => $date, + ); + } + else { + $input = array( + 'date' => '', + 'time' => '', + 'object' => NULL, + ); + } + } + return $input; +} + +/** + * Validation callback for a datetime 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 form element whose value is being validated. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ +function datetime_datetime_validate($element, FormStateInterface $form_state) { + + $input_exists = FALSE; + $input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists); + if ($input_exists) { + + $title = !empty($element['#title']) ? $element['#title'] : ''; + $date_format = $element['#date_date_element'] != 'none' ? datetime_html5_format('date', $element) : ''; + $time_format = $element['#date_time_element'] != 'none' ? datetime_html5_format('time', $element) : ''; + $format = trim($date_format . ' ' . $time_format); + + // If there's empty input and the field is not required, set it to empty. + if (empty($input['date']) && empty($input['time']) && !$element['#required']) { + form_set_value($element, NULL, $form_state); + } + // If there's empty input and the field is required, set an error. A + // reminder of the required format in the message provides a good UX. + elseif (empty($input['date']) && empty($input['time']) && $element['#required']) { + form_error($element, $form_state, t('The %field date is required. Please enter a date in the format %format.', array('%field' => $title, '%format' => datetime_format_example($format)))); + } + else { + // If the date is valid, set it. + $date = $input['object']; + if ($date instanceOf DrupalDateTime && !$date->hasErrors()) { + form_set_value($element, $date, $form_state); + } + // If the date is invalid, set an error. A reminder of the required + // format in the message provides a good UX. + else { + form_error($element, $form_state, t('The %field date is invalid. Please enter a date in the format %format.', array('%field' => $title, '%format' => datetime_format_example($format)))); + } + } + } +} + +/** + * Retrieves the right format for a HTML5 date element. + * + * The format is important because these elements will not work with any other + * format. + * + * @param string $part + * The type of element format to retrieve. + * @param string $element + * The $element to assess. + * + * @return string + * Returns the right format for the type of element, or the original format + * if this is not a HTML5 element. + */ +function datetime_html5_format($part, $element) { + switch ($part) { + case 'date': + switch ($element['#date_date_element']) { + case 'date': + return entity_load('date_format', 'html_date')->getPattern(); + + case 'datetime': + case 'datetime-local': + return entity_load('date_format', 'html_datetime')->getPattern(); + + default: + return $element['#date_date_format']; + } + break; + + case 'time': + switch ($element['#date_time_element']) { + case 'time': + return entity_load('date_format', 'html_time')->getPattern(); + + default: + return $element['#date_time_format']; + } + break; + } +} + +/** + * Creates an example for a date format. + * + * This is centralized for a consistent method of creating these examples. + * + * @param string $format + * + * + * @return string + * + */ +function datetime_format_example($format) { + $date = &drupal_static(__FUNCTION__); + if (empty($date)) { + $date = new DrupalDateTime(); + } + return $date->format($format); +} + +/** + * 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. + * - #date_timezone: The local timezone to use when creating dates. Generally + * this should be left empty and it will be set correctly for the user using + * the form. Useful if the default value is empty to designate a desired + * timezone for dates created in form processing. If a default date is + * provided, this value will be ignored, the timezone in the default date + * takes precedence. Defaults to the value returned by + * drupal_get_user_timezone(). + * + * 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, + * ); + * @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. + */ +function datetime_datelist_form_process($element, FormStateInterface $form_state) { + + // 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; + + // Set a fallback timezone. + if ($date instanceOf DrupalDateTime) { + $element['#date_timezone'] = $date->getTimezone()->getName(); + } + elseif (!empty($element['#timezone'])) { + $element['#date_timezone'] = $element['#date_timezone']; + } + else { + $element['#date_timezone'] = drupal_get_user_timezone(); + } + + $element['#tree'] = TRUE; + + // Determine the order of the date elements. + $order = !empty($element['#date_part_order']) ? $element['#date_part_order'] : array('year', 'month', 'day'); + $text_parts = !empty($element['#date_text_parts']) ? $element['#date_text_parts'] : array(); + + // 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 = datetime_range_years($element['#date_year_range'], $date); + $options = $date_helper->years($range[0], $range[1], $element['#required']); + $format = 'Y'; + $title = t('Year'); + break; + + case 'hour': + $format = in_array('ampm', $element['#date_part_order']) ? 'g': 'G'; + $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'); + } + + $default = !empty($element['#value'][$part]) ? $element['#value'][$part] : ''; + $value = $date instanceOf DrupalDateTime && !$date->hasErrors() ? $date->format($format) : $default; + if (!empty($value) && $part != 'ampm') { + $value = intval($value); + } + + $element['#attributes']['title'] = $title; + $element[$part] = array( + '#type' => in_array($part, $text_parts) ? 'textfield' : 'select', + '#title' => $title, + '#title_display' => 'invisible', + '#value' => $value, + '#attributes' => $element['#attributes'], + '#options' => $options, + '#required' => $element['#required'], + ); + } + + // 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; +} + +/** + * Element value callback for datelist element. + * + * 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. + * + * @param array $element + * The element being processed. + * @param array|false $input + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + */ +function form_type_datelist_value($element, $input = FALSE, FormStateInterface $form_state) { + $parts = $element['#date_part_order']; + $increment = $element['#date_increment']; + + $date = NULL; + if ($input !== FALSE) { + $return = $input; + 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']); + } + $timezone = !empty($element['#date_timezone']) ? $element['#date_timezone'] : NULL; + $date = DrupalDateTime::createFromArray($input, $timezone); + if ($date instanceOf DrupalDateTime && !$date->hasErrors()) { + date_increment_round($date, $increment); + } + } + else { + $return = array_fill_keys($parts, ''); + if (!empty($element['#default_value'])) { + $date = $element['#default_value']; + if ($date instanceOf DrupalDateTime && !$date->hasErrors()) { + date_increment_round($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': + $format = in_array('ampm', $element['#date_part_order']) ? 'g': 'G'; + break; + + case 'minute': + $format = 'i'; + break; + + case 'second': + $format = 's'; + break; + + case 'ampm': + $format = 'a'; + } + $return[$part] = $date->format($format); + } + } + } + } + $return['object'] = $date; + return $return; +} + +/** + * 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. + */ +function datetime_datelist_validate($element, FormStateInterface $form_state) { + $input_exists = FALSE; + $input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists); + if ($input_exists) { + + // 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_set_value($element, NULL, $form_state); + } + // 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']) { + form_error($element, $form_state, t('The %field date is required.')); + } + else { + // If the input is valid, set it. + $date = $input['object']; + if ($date instanceOf DrupalDateTime && !$date->hasErrors()) { + form_set_value($element, $date, $form_state); + } + // If the input is invalid, set an error. + else { + form_error($element, $form_state, t('The %field date is invalid.')); + } + } + } +} + +/** + * Rounds minutes and seconds to nearest requested value. + * + * @param $date + * + * @param $increment + * + * + * @return + * + */ +function date_increment_round(&$date, $increment) { + // Round minutes and seconds, if necessary. + if ($date instanceOf DrupalDateTime && $increment > 1) { + $day = intval(date_format($date, 'j')); + $hour = intval(date_format($date, 'H')); + $second = intval(round(intval(date_format($date, 's')) / $increment) * $increment); + $minute = intval(date_format($date, 'i')); + if ($second == 60) { + $minute += 1; + $second = 0; + } + $minute = intval(round($minute / $increment) * $increment); + if ($minute == 60) { + $hour += 1; + $minute = 0; + } + date_time_set($date, $hour, $minute, $second); + if ($hour == 24) { + $day += 1; + $year = date_format($date, 'Y'); + $month = date_format($date, 'n'); + date_date_set($date, $year, $month, $day); + } + } + return $date; +} + +/** + * Specifies the start and end year to use as a date range. + * + * Handles a string like -3:+3 or 2001:2010 to describe a dynamic range of + * minimum and maximum years to use in a date selector. + * + * Centers the range around the current year, if any, but expands it far enough + * so it will pick up the year value in the field in case the value in the field + * is outside the initial range. + * + * @param string $string + * A min and max year string like '-3:+1' or '2000:2010' or '2000:+3'. + * @param object $date + * (optional) A date object to test as a default value. Defaults to NULL. + * + * @return array + * A numerically indexed array, containing the minimum and maximum year + * described by this pattern. + */ +function datetime_range_years($string, $date = NULL) { + + $this_year = date_format(new DrupalDateTime(), 'Y'); + list($min_year, $max_year) = explode(':', $string); + + // Valid patterns would be -5:+5, 0:+1, 2008:2010. + $plus_pattern = '@[\+|\-][0-9]{1,4}@'; + $year_pattern = '@^[0-9]{4}@'; + if (!preg_match($year_pattern, $min_year, $matches)) { + if (preg_match($plus_pattern, $min_year, $matches)) { + $min_year = $this_year + $matches[0]; + } + else { + $min_year = $this_year; + } + } + if (!preg_match($year_pattern, $max_year, $matches)) { + if (preg_match($plus_pattern, $max_year, $matches)) { + $max_year = $this_year + $matches[0]; + } + else { + $max_year = $this_year; + } + } + // We expect the $min year to be less than the $max year. Some custom values + // for -99:+99 might not obey that. + if ($min_year > $max_year) { + $temp = $max_year; + $max_year = $min_year; + $min_year = $temp; + } + // If there is a current value, stretch the range to include it. + $value_year = $date instanceOf DrupalDateTime ? $date->format('Y') : ''; + if (!empty($value_year)) { + $min_year = min($value_year, $min_year); + $max_year = max($value_year, $max_year); + } + return array($min_year, $max_year); +} + +/** + * Implements hook_form_BASE_FORM_ID_alter() for node forms. + */ +function datetime_form_node_form_alter(&$form, FormStateInterface $form_state, $form_id) { + // Alter the 'Authored on' date to use datetime. + $form['created']['#type'] = 'datetime'; + $date_format = entity_load('date_format', 'html_date')->getPattern(); + $time_format = entity_load('date_format', 'html_time')->getPattern(); + $form['created']['#description'] = t('Format: %format. Leave blank to use the time of form submission.', array('%format' => datetime_format_example($date_format . ' ' . $time_format))); + unset($form['created']['#maxlength']); +} + +/** + * Implements hook_node_prepare_form(). + */ +function datetime_node_prepare_form(NodeInterface $node, $operation, FormStateInterface $form_state) { + // Prepare the 'Authored on' date to use datetime. + $node->date = DrupalDateTime::createFromTimestamp($node->getCreatedTime()); +} diff --git a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php index 8d436a0..9ead895 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php +++ b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php @@ -8,6 +8,7 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Field\WidgetBase; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; @@ -25,7 +26,7 @@ * } * ) */ -class DateTimeDefaultWidget extends DateTimeWidgetBase implements ContainerFactoryPluginInterface { +class DateTimeDefaultWidget extends WidgetBase implements ContainerFactoryPluginInterface { /** * The date format storage. @@ -61,34 +62,69 @@ public static function create(ContainerInterface $container, array $configuratio * {@inheritdoc} */ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { - $element = parent::formElement($items, $delta, $element, $form, $form_state); + // We are nesting some sub-elements inside the parent, so we need a wrapper. + // We also need to add another #title attribute at the top level for ease in + // identifying this item in error messages. We do not want to display this + // title because the actual title display is handled at a higher level by + // the Field module. + + $element['#attributes']['class'][] = 'container-inline'; + $element['#element_validate'][] = 'datetime_datetime_widget_validate'; // Identify the type of date and time elements to use. switch ($this->getFieldSetting('datetime_type')) { case DateTimeItem::DATETIME_TYPE_DATE: $date_type = 'date'; $time_type = 'none'; + $element['#theme_wrappers'][] = 'datetime_wrapper'; $date_format = $this->dateStorage->load('html_date')->getPattern(); $time_format = ''; + $element_format = $date_format; + $storage_format = DATETIME_DATE_STORAGE_FORMAT; break; default: $date_type = 'date'; $time_type = 'time'; + $element['#theme_wrappers'][] = 'datetime_fieldset_wrapper'; $date_format = $this->dateStorage->load('html_date')->getPattern(); $time_format = $this->dateStorage->load('html_time')->getPattern(); + $element_format = $date_format . ' ' . $time_format; + $storage_format = DATETIME_DATETIME_STORAGE_FORMAT; break; } - $element['value'] += array( + $element['value'] = array( + '#type' => 'datetime', + '#default_value' => NULL, + '#date_increment' => 1, '#date_date_format'=> $date_format, '#date_date_element' => $date_type, '#date_date_callbacks' => array(), '#date_time_format' => $time_format, '#date_time_element' => $time_type, '#date_time_callbacks' => array(), + '#date_timezone' => drupal_get_user_timezone(), + '#required' => $element['#required'], ); + // Set the storage and widget options so the validation can use them. The + // validator will not have access to the field definition. + $element['value']['#date_element_format'] = $element_format; + $element['value']['#date_storage_format'] = $storage_format; + if ($items[$delta]->date) { + $date = $items[$delta]->date; + // The date was created and verified during field_load(), so it is safe to + // use without further inspection. + $date->setTimezone(new \DateTimeZone($element['value']['#date_timezone'])); + if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) { + // A date without time will pick up the current time, use the default + // time. + datetime_date_default_time($date); + } + $element['value']['#default_value'] = $date; + } + return $element; } diff --git a/core/modules/datetime/src/Tests/DateTimeFieldTest.php b/core/modules/datetime/src/Tests/DateTimeFieldTest.php index 0aa39fc..5a04fbe 100644 --- a/core/modules/datetime/src/Tests/DateTimeFieldTest.php +++ b/core/modules/datetime/src/Tests/DateTimeFieldTest.php @@ -254,6 +254,7 @@ function testDatetimeField() { $this->drupalGet('entity_test/add'); $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Date element found.'); $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Time element found.'); + $this->assertFieldByXPath('//*[@id="edit-' . $field_name . '-wrapper"]/fieldset/legend/span[contains(@class, "form-required")]', TRUE, 'Required markup found'); // Build up a date in the UTC timezone. $value = '2012-12-31 00:00:00'; diff --git a/core/modules/datetime/templates/datetime-fieldset-wrapper.html.twig b/core/modules/datetime/templates/datetime-fieldset-wrapper.html.twig new file mode 100644 index 0000000..4855700 --- /dev/null +++ b/core/modules/datetime/templates/datetime-fieldset-wrapper.html.twig @@ -0,0 +1,34 @@ +{# +/** + * @file + * Default theme implementation of a datetime fieldset form wrapper. + * + * Available variables: + * - attributes: HTML attributes for the fieldset element. + * - required: The required marker or empty if the associated fieldset is + * not required. + * - legend: The legend element containing the following properties: + * - title: Title of the fieldset, intended for use as the text of the legend. + * - attributes: HTML attributes to apply to the legend. + * - description: The description element containing the following properties: + * - content: The description content of the fieldset. + * - attributes: HTML attributes to apply to the description container. + * - content: The form element to be output. + * + * @see template_preprocess_datetime_fieldset_wrapper() + * + * @ingroup themeable + */ +#} + + {% if legend.title is not empty or required -%} + {# Always wrap fieldset legends in a SPAN for CSS positioning. #} + {{ legend.title }}{{ required }} + {%- endif %} +
+ {{ content }} + {% if description.content %} + {{ description.content }}
+ {% endif %} + + \ No newline at end of file diff --git a/core/modules/system/templates/fieldset.html.twig b/core/modules/system/templates/fieldset.html.twig index b67ec85..1fff1ef 100644 --- a/core/modules/system/templates/fieldset.html.twig +++ b/core/modules/system/templates/fieldset.html.twig @@ -16,6 +16,7 @@ * - children: The rendered child elements of the fieldset. * - prefix: The content to add before the fieldset children. * - suffix: The content to add after the fieldset children. + * - content: The form element to be output. * * @see template_preprocess_fieldset() * @@ -52,6 +53,7 @@ {{ prefix }} {% endif %} {{ children }} + {{ content }} {% if suffix %} {{ suffix }} {% endif %}