diff --git a/core/lib/Drupal/Core/Datetime/Element/Datetime.php b/core/lib/Drupal/Core/Datetime/Element/Datetime.php index 6d39f8d..ff67d02 100644 --- a/core/lib/Drupal/Core/Datetime/Element/Datetime.php +++ b/core/lib/Drupal/Core/Datetime/Element/Datetime.php @@ -62,6 +62,7 @@ public function getInfo() { '#date_year_range' => '1900:2050', '#date_increment' => 1, '#date_timezone' => '', + '#expose_timezone' => FALSE, ]; } @@ -74,6 +75,11 @@ public static function valueCallback(&$element, $input, FormStateInterface $form $time_input = $element['#date_time_element'] != 'none' && !empty($input['time']) ? $input['time'] : ''; $date_format = $element['#date_date_element'] != 'none' ? static::getHtml5DateFormat($element) : ''; $time_format = $element['#date_time_element'] != 'none' ? static::getHtml5TimeFormat($element) : ''; + + // Timezone. + if ($element['#expose_timezone'] && $input['timezone']) { + $element['#date_timezone'] = $input['timezone']; + } $timezone = !empty($element['#date_timezone']) ? $element['#date_timezone'] : NULL; // Seconds will be omitted in a post in case there's no entry. @@ -92,6 +98,7 @@ public static function valueCallback(&$element, $input, FormStateInterface $form $input = [ 'date' => $date_input, 'time' => $time_input, + 'timezone' => $timezone, 'object' => $date, ]; } @@ -101,6 +108,7 @@ public static function valueCallback(&$element, $input, FormStateInterface $form $input = [ 'date' => $date->format($element['#date_date_format']), 'time' => $date->format($element['#date_time_format']), + 'timezone' => $date->getTimezone()->getName(), 'object' => $date, ]; } @@ -108,6 +116,7 @@ public static function valueCallback(&$element, $input, FormStateInterface $form $input = [ 'date' => '', 'time' => '', + 'timezone' => '', 'object' => NULL, ]; } @@ -195,6 +204,8 @@ public static function valueCallback(&$element, $input, FormStateInterface $form * provided, this value will be ignored, the timezone in the default date * takes precedence. Defaults to the value returned by * drupal_get_user_timezone(). + * - #expose_timezone: a boolean that if set to TRUE, will expose a timezone + * select list. Defaults to FALSE. * * Example usage: * @code @@ -227,7 +238,9 @@ public static function processDatetime(&$element, FormStateInterface $form_state $element['#date_timezone'] = $date->getTimezone()->getName(); } elseif (empty($element['#timezone'])) { - $element['#date_timezone'] = drupal_get_user_timezone(); + // todo This is a temporary hack to get new formatter and widget tests to + // pass while working on #2632040. It will not be needed once #2799987 is fixed. + // $element['#date_timezone'] = drupal_get_user_timezone(); } $element['#tree'] = TRUE; @@ -332,6 +345,17 @@ public static function processAjaxForm(&$element, FormStateInterface $form_state } } + // Expose a timezone selector. + if (!empty($element['#expose_timezone']) && $element['#expose_timezone']) { + $element['timezone'] = array( + '#type' => 'select', + '#options' => array_combine(\DateTimeZone::listIdentifiers(), \DateTimeZone::listIdentifiers()), + // Default to user's timezone. + '#default_value' => $element['#date_timezone'], + '#required' => $element['#required'], + ); + } + return $element; } diff --git a/core/modules/datetime/config/schema/datetime.schema.yml b/core/modules/datetime/config/schema/datetime.schema.yml index a009011..28d143c 100644 --- a/core/modules/datetime/config/schema/datetime.schema.yml +++ b/core/modules/datetime/config/schema/datetime.schema.yml @@ -7,6 +7,9 @@ field.storage_settings.datetime: datetime_type: type: string label: 'Date type' + timezone_storage: + type: boolean + label: 'Time zone storage' field.field_settings.datetime: type: mapping @@ -26,9 +29,15 @@ field.value.datetime: field.formatter.settings.datetime_base: type: mapping mapping: + timezone_default: + type: string + label: 'Time zone default' timezone_override: type: string - label: 'Time zone override' + label: 'Timezone override' + timezone_per_date: + type: boolean + label: 'Timezone per date' field.formatter.settings.datetime_default: type: field.formatter.settings.datetime_base @@ -64,8 +73,21 @@ field.formatter.settings.datetime_time_ago: type: integer label: 'Granularity' -field.widget.settings.datetime_datelist: +field.widget.settings.datetime_base: type: mapping + mapping: + timezone_default: + type: string + label: 'Time zone default' + timezone_override: + type: string + label: 'Timezone override' + timezone_per_date: + type: boolean + label: 'Timezone per date' + +field.widget.settings.datetime_datelist: + type: field.widget.settings.datetime_base label: 'Datetime select list display format settings' mapping: increment: @@ -79,5 +101,5 @@ field.widget.settings.datetime_datelist: label: 'Time type' field.widget.settings.datetime_default: - type: mapping + type: field.widget.settings.datetime_base label: 'Datetime default display format settings' diff --git a/core/modules/datetime/datetime.install b/core/modules/datetime/datetime.install new file mode 100644 index 0000000..5f20a22 --- /dev/null +++ b/core/modules/datetime/datetime.install @@ -0,0 +1,100 @@ +getDefinitions() as $entity_type_id => $entity_type) { + $storage = \Drupal::entityTypeManager()->getStorage($entity_type_id); + if ($storage instanceof DynamicallyFieldableEntityStorageSchemaInterface) { + $field_changes = []; + $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions($entity_type_id); + $original_storage_definitions = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledFieldStorageDefinitions($entity_type_id); + foreach (array_intersect_key($field_storage_definitions, $original_storage_definitions) as $field_name => $storage_definition) { + if ($storage_definition->getType() === 'datetime' && $storage->requiresFieldStorageSchemaChanges($storage_definition, $original_storage_definitions[$field_name])) { + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $table_mapping = $storage->getTableMapping(); + $field_changes[$field_name] = $table_mapping->getFieldTableName($field_name); + } + } + + if (!empty($field_changes)) { + + $change_list[$entity_type_id] = [ + 'field_storage_definitions' => $field_changes, + 'revision_table' => $entity_type->getRevisionTable(), + ]; + } + } + } + + $sandbox['change_list'] = $change_list; + $sandbox['max'] = count($change_list); + } + + $remaining = array_diff(array_keys($sandbox['change_list']), $sandbox['processed']); + if (!empty($remaining)) { + $entity_type_id = array_pop($remaining); + $field_changes = $sandbox['change_list'][$entity_type_id]; + + $field_spec = [ + 'description' => 'The date timezone.', + 'type' => 'varchar', + 'length' => 50, + ]; + $schema = Database::getConnection()->schema(); + + foreach ($field_changes['field_storage_definitions'] as $field_name => $field_table) { + $timezone_field_name = $field_name . '_timezone'; + $field_schema = [ + 'fields' => [ + // Include the 'value' field in order to properly create the index. + $field_name . '_value' => [ + 'description' => 'The date value.', + 'type' => 'varchar', + 'length' => 20, + ], + $timezone_field_name => $field_spec, + ], + 'indexes' => [ + 'value_timezone' => [$field_name . '_value', $timezone_field_name], + ], + ]; + $schema->addField($field_table, $timezone_field_name, $field_spec); + $schema->addIndex($field_table, 'value_timezone', $field_schema['indexes']['value_timezone'], $field_schema); + + if ($field_changes['revision_table']) { + $revision_table = $field_changes['revision_table'] . '__' . $field_name; + $schema->addField($revision_table, $timezone_field_name, $field_spec); + $schema->addIndex($revision_table, 'value_timezone', $field_schema['indexes']['value_timezone'], $field_schema); + } + + // Set timezone storage to FALSE, which is the behavior prior to the + // introduction of configurable per-date time zone storage. + $field_storage = FieldStorageConfig::load($entity_type_id . '.' . $field_name); + $field_storage->setSetting('timezone_storage', FALSE); + $field_storage->save(); + \Drupal::entityDefinitionUpdateManager()->updateFieldStorageDefinition($field_storage); + } + $sandbox['processed'][] = $entity_type_id; + } + + $sandbox['#finished'] = empty($remaining); +} diff --git a/core/modules/datetime/src/Plugin/Field/ConfigurableTimezoneInterface.php b/core/modules/datetime/src/Plugin/Field/ConfigurableTimezoneInterface.php new file mode 100644 index 0000000..47450c7 --- /dev/null +++ b/core/modules/datetime/src/Plugin/Field/ConfigurableTimezoneInterface.php @@ -0,0 +1,42 @@ +fieldDefinition->getFieldStorageDefinition() + ->getSetting('datetime_type'); + if ($datetime_type === DateTimeItem::DATETIME_TYPE_DATETIME) { + $form['timezone_default'] = [ + '#type' => 'select', + '#title' => $this->t('Default time zone'), + '#description' => $this->t('The time zone to use by default when displaying this date.'), + '#options' => [ + ConfigurableTimezoneInterface::TIMEZONE_USER => $this->t("The user's account time zone"), + ConfigurableTimezoneInterface::TIMEZONE_SITE => $this->t("The site's default time zone"), + ConfigurableTimezoneInterface::TIMEZONE_FIXED => $this->t("A fixed time zone"), + ], + '#default_value' => $this->getSetting('timezone_default'), + ]; + + $form['timezone_override'] = [ + '#type' => 'select', + '#title' => $this->t('Fixed time zone'), + '#options' => system_time_zones(TRUE), + '#default_value' => $this->getSetting('timezone_override'), + '#states' => ['visible' => [':input[name="fields[' . $this->fieldDefinition->getName() . '][settings_edit_form][settings][timezone_default]"]' => ['value' => ConfigurableTimezoneInterface::TIMEZONE_FIXED]]], + ]; + + // If this field is using per-date time zone storage, give the option of + // allowing that to override the default. + $timezone_storage = $this->fieldDefinition->getFieldStorageDefinition() + ->getSetting('timezone_storage'); + if ($timezone_storage === TRUE) { + $form['timezone_per_date'] = [ + '#type' => 'checkbox', + '#title' => 'Preferred time zone for each date', + '#default_value' => $this->getSetting('timezone_per_date'), + ]; + } + } + + return $form; + } + + /** + * A time zone settings summary for a widget or formatter. + * + * @param array $per_date_summary + * An array with 2 keys ('use' and 'default') giving the summary text to use + * when per-date time zones are used by the widget or formatter. + * + * @return array + * A short summary of the time zone settings. + */ + protected function timezoneSettingsSummary($per_date_summary) { + $summary = []; + $datetime_type = $this->fieldDefinition->getFieldStorageDefinition() + ->getSetting('datetime_type'); + if ($datetime_type === DateTimeItem::DATETIME_TYPE_DATETIME) { + // Determine the default time zone summary text. + $timezone_default = $this->getSetting('timezone_default'); + $timezone_override = $this->getSetting('timezone_override'); + if ($timezone_override && $timezone_default === ConfigurableTimezoneInterface::TIMEZONE_FIXED) { + $default_timezone_text = $timezone_override; + } + elseif ($timezone_default === ConfigurableTimezoneInterface::TIMEZONE_SITE) { + $default_timezone_text = $this->t("the site's default time zone"); + } + else { + $default_timezone_text = $this->t("the user's account time zone"); + } + + // Prepare the final summary. + if ($this->getSetting('timezone_per_date') === TRUE) { + $summary[] = $this->t($per_date_summary['use']); + $summary[] = $this->t($per_date_summary['default'], ['@timezone' => $default_timezone_text]); + } + else { + $summary[] = $this->t('Time zone: @timezone', ['@timezone' => $default_timezone_text]); + } + } + return $summary; + } + + /** + * {@inheritdoc} + */ + public function getDefaultTimezone($itemTimezone = NULL) { + if ($this->getFieldSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATE) { + // A date without time has no time zone conversion. + $timezone = DateTimeItemInterface::STORAGE_TIMEZONE; + } + else { + $timezone_default = $this->getSetting('timezone_default'); + $timezone_override = $this->getSetting('timezone_override'); + if ($this->getSetting('timezone_per_date') && !empty($itemTimezone)) { + $timezone = $itemTimezone; + } + elseif ($timezone_override && $timezone_default === ConfigurableTimezoneInterface::TIMEZONE_FIXED) { + $timezone = $timezone_override; + } + elseif ($timezone_default === ConfigurableTimezoneInterface::TIMEZONE_SITE) { + $timezone = $this->config->get('system.date')->get('timezone.default'); + } + else { + $timezone = drupal_get_user_timezone(); + } + } + return $timezone; + } + +} diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php index e3b9daf..c4dbda2 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php @@ -43,7 +43,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) { /** @var \Drupal\Core\Datetime\DrupalDateTime $date */ $date = $item->date; - $elements[$delta] = $this->buildDate($date); + $elements[$delta] = $this->buildDate($date, $item->timezone); } } @@ -55,8 +55,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) { */ protected function formatDate($date) { $format = $this->getSetting('date_format'); - $timezone = $this->getSetting('timezone_override'); - return $this->dateFormatter->format($date->getTimestamp(), 'custom', $format, $timezone != '' ? $timezone : NULL); + return $this->dateFormatter->format($date->getTimestamp(), 'custom', $format, $date->getTimezone()->getName()); } /** @@ -83,7 +82,7 @@ public function settingsSummary() { $date = new DrupalDateTime(); $this->setTimeZone($date); - $summary[] = $date->format($this->getSetting('date_format'), $this->getFormatSettings()); + $summary[] = $date->format($this->getSetting('date_format')); return $summary; } diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php index fa65496..cb6839c 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php @@ -32,8 +32,7 @@ public static function defaultSettings() { */ protected function formatDate($date) { $format_type = $this->getSetting('format_type'); - $timezone = $this->getSetting('timezone_override') ?: $date->getTimezone()->getName(); - return $this->dateFormatter->format($date->getTimestamp(), $format_type, '', $timezone != '' ? $timezone : NULL); + return $this->dateFormatter->format($date->getTimestamp(), $format_type, '', $date->getTimezone()->getName()); } /** @@ -68,6 +67,8 @@ public function settingsSummary() { $summary = parent::settingsSummary(); $date = new DrupalDateTime(); + $this->setTimeZone($date); + $summary[] = t('Format: @display', ['@display' => $this->formatDate($date)]); return $summary; diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php index a6680f6..0579264 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php @@ -2,6 +2,7 @@ namespace Drupal\datetime\Plugin\Field\FieldFormatter; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Entity\EntityStorageInterface; @@ -10,14 +11,16 @@ use Drupal\Core\Field\FormatterBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; -use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\datetime\Plugin\Field\ConfigurableTimezoneTrait; +use Drupal\datetime\Plugin\Field\ConfigurableTimezoneInterface; /** * Base class for 'DateTime Field formatter' plugin implementations. */ -abstract class DateTimeFormatterBase extends FormatterBase implements ContainerFactoryPluginInterface { +abstract class DateTimeFormatterBase extends FormatterBase implements ConfigurableTimezoneInterface, ContainerFactoryPluginInterface { + + use ConfigurableTimezoneTrait; /** * The date formatter service. @@ -34,6 +37,13 @@ protected $dateFormatStorage; /** + * A config factory for retrieving required config settings. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $config; + + /** * Constructs a new DateTimeDefaultFormatter. * * @param string $plugin_id @@ -54,12 +64,15 @@ * The date formatter service. * @param \Drupal\Core\Entity\EntityStorageInterface $date_format_storage * The date format entity storage. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The factory for configuration objects. */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, DateFormatterInterface $date_formatter, EntityStorageInterface $date_format_storage) { + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, DateFormatterInterface $date_formatter, EntityStorageInterface $date_format_storage, ConfigFactoryInterface $config_factory) { parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings); $this->dateFormatter = $date_formatter; $this->dateFormatStorage = $date_format_storage; + $this->config = $config_factory; } /** @@ -75,7 +88,8 @@ public static function create(ContainerInterface $container, array $configuratio $configuration['view_mode'], $configuration['third_party_settings'], $container->get('date.formatter'), - $container->get('entity.manager')->getStorage('date_format') + $container->get('entity.manager')->getStorage('date_format'), + $container->get('config.factory') ); } @@ -84,7 +98,9 @@ public static function create(ContainerInterface $container, array $configuratio */ public static function defaultSettings() { return [ + 'timezone_default' => ConfigurableTimezoneInterface::TIMEZONE_USER, 'timezone_override' => '', + 'timezone_per_date' => FALSE, ] + parent::defaultSettings(); } @@ -92,7 +108,9 @@ public static function defaultSettings() { * {@inheritdoc} */ public function settingsForm(array $form, FormStateInterface $form_state) { - $form = parent::settingsForm($form, $form_state); + $form = $this->timezoneSettingsForm($form, $form_state) + + parent::settingsForm($form, $form_state); + $form['timezone_per_date']['#description'] = "Where a time zone has been specified for a particular date value, use that instead of the default selected above."; $form['timezone_override'] = [ '#type' => 'select', @@ -109,12 +127,12 @@ public function settingsForm(array $form, FormStateInterface $form_state) { * {@inheritdoc} */ public function settingsSummary() { - $summary = parent::settingsSummary(); - - if ($override = $this->getSetting('timezone_override')) { - $summary[] = $this->t('Time zone: @timezone', ['@timezone' => $override]); - } - + $per_date_summary = [ + 'use' => 'Use timezones from individual dates', + 'default' => 'Use @timezone if no individual timezone specified', + ]; + $summary = $this->timezoneSettingsSummary($per_date_summary) + + parent::settingsSummary(); return $summary; } @@ -128,7 +146,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) { if ($item->date) { /** @var \Drupal\Core\Datetime\DrupalDateTime $date */ $date = $item->date; - $elements[$delta] = $this->buildDateWithIsoAttribute($date); + $elements[$delta] = $this->buildDateWithIsoAttribute($date, $item->timezone); if (!empty($item->_attributes)) { $elements[$delta]['#attributes'] += $item->_attributes; @@ -154,55 +172,37 @@ public function viewElements(FieldItemListInterface $items, $langcode) { abstract protected function formatDate($date); /** - * Sets the proper time zone on a DrupalDateTime object for the current user. + * Sets the proper time zone on a DrupalDateTime object. * * A DrupalDateTime object loaded from the database will have the UTC time - * zone applied to it. This method will apply the time zone for the current - * user, based on system and user settings. + * zone applied to it. This method applies the proper timezone based on + * the formatter configuration. * * @see drupal_get_user_timezone() * * @param \Drupal\Core\Datetime\DrupalDateTime $date * A DrupalDateTime object. + * @param string $date_instance_timezone + * (optional) The timezone associated with the specific date field instance. */ - protected function setTimeZone(DrupalDateTime $date) { - if ($this->getFieldSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATE) { - // A date without time has no timezone conversion. - $timezone = DateTimeItemInterface::STORAGE_TIMEZONE; - } - else { - $timezone = drupal_get_user_timezone(); - } + protected function setTimeZone(DrupalDateTime $date, $date_instance_timezone = NULL) { + $timezone = $this->getDefaultTimezone($date_instance_timezone); $date->setTimeZone(timezone_open($timezone)); } /** - * Gets a settings array suitable for DrupalDateTime::format(). - * - * @return array - * The settings array that can be passed to DrupalDateTime::format(). - */ - protected function getFormatSettings() { - $settings = []; - - if ($this->getSetting('timezone_override') != '') { - $settings['timezone'] = $this->getSetting('timezone_override'); - } - - return $settings; - } - - /** * Creates a render array from a date object. * * @param \Drupal\Core\Datetime\DrupalDateTime $date * A date object. + * @param string $timezone + * (optional) A timezone to explicitly set the date to. * * @return array * A render array. */ - protected function buildDate(DrupalDateTime $date) { - $this->setTimeZone($date); + protected function buildDate(DrupalDateTime $date, $timezone = NULL) { + $this->setTimeZone($date, $timezone); $build = [ '#markup' => $this->formatDate($date), @@ -221,15 +221,17 @@ protected function buildDate(DrupalDateTime $date) { * * @param \Drupal\Core\Datetime\DrupalDateTime $date * A date object. + * @param string $timezone + * (optional) A timezone to explicitly set the date to. * * @return array * A render array. */ - protected function buildDateWithIsoAttribute(DrupalDateTime $date) { + protected function buildDateWithIsoAttribute(DrupalDateTime $date, $timezone = NULL) { // Create the ISO date in Universal Time. $iso_date = $date->format("Y-m-d\TH:i:s") . 'Z'; - $this->setTimeZone($date); + $this->setTimeZone($date, $timezone); $build = [ '#theme' => 'time', diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php index 8ddfdda..5b673c8 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php @@ -30,7 +30,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) { /** @var \Drupal\Core\Datetime\DrupalDateTime $date */ $date = $item->date; - $elements[$delta] = $this->buildDate($date); + $elements[$delta] = $this->buildDate($date, $item->timezone); } } @@ -42,8 +42,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) { */ protected function formatDate($date) { $format = $this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE ? DateTimeItemInterface::DATE_STORAGE_FORMAT : DateTimeItemInterface::DATETIME_STORAGE_FORMAT; - $timezone = $this->getSetting('timezone_override'); - return $this->dateFormatter->format($date->getTimestamp(), 'custom', $format, $timezone != '' ? $timezone : NULL); + return $this->dateFormatter->format($date->getTimestamp(), 'custom', $format, $date->getTimezone()->getName()); } } diff --git a/core/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php b/core/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php index 3264069..12559ce 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php +++ b/core/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php @@ -29,6 +29,7 @@ class DateTimeItem extends FieldItemBase implements DateTimeItemInterface { public static function defaultStorageSettings() { return [ 'datetime_type' => 'datetime', + 'timezone_storage' => FALSE, ] + parent::defaultStorageSettings(); } @@ -57,6 +58,9 @@ public static function propertyDefinitions(FieldStorageDefinitionInterface $fiel ->setClass('\Drupal\datetime\DateTimeComputed') ->setSetting('date source', 'value'); + $properties['timezone'] = DataDefinition::create('string') + ->setLabel(t('Timezone')); + return $properties; } @@ -64,18 +68,26 @@ public static function propertyDefinitions(FieldStorageDefinitionInterface $fiel * {@inheritdoc} */ public static function schema(FieldStorageDefinitionInterface $field_definition) { - return [ + $schema = [ 'columns' => [ 'value' => [ 'description' => 'The date value.', 'type' => 'varchar', 'length' => 20, ], + 'timezone' => [ + 'description' => 'The date timezone', + 'type' => 'varchar', + 'length' => 50, + ], ], 'indexes' => [ 'value' => ['value'], + 'value_timezone' => ['value', 'timezone'], ], ]; + + return $schema; } /** @@ -96,6 +108,23 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state '#disabled' => $has_data, ]; + $element['timezone_storage'] = [ + '#type' => 'checkbox', + '#title' => t('Store a time zone'), + '#description' => 'Allow storing a preferred time zone with each date and time', + '#default_value' => $this->getSetting('timezone_storage'), + '#states' => [ + // Hide the field if this is a date-only field. + 'visible' => [ + ':input[name="settings[datetime_type]"]' => ['value' => static::DATETIME_TYPE_DATETIME], + ], + 'disabled' => [ + ':input[name="settings[datetime_type]"]' => ['value' => static::DATETIME_TYPE_DATE], + ], + ], + '#disabled' => $has_data, + ]; + return $element; } diff --git a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php index 2a4ffcd..044a3e2 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php +++ b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php @@ -9,6 +9,7 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Core\Config\ConfigFactoryInterface; /** * Plugin implementation of the 'datetime_default' widget. @@ -33,8 +34,8 @@ class DateTimeDefaultWidget extends DateTimeWidgetBase implements ContainerFacto /** * {@inheritdoc} */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityStorageInterface $date_storage) { - parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ConfigFactoryInterface $config_factory, EntityStorageInterface $date_storage) { + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $config_factory); $this->dateStorage = $date_storage; } @@ -49,6 +50,7 @@ public static function create(ContainerInterface $container, array $configuratio $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], + $container->get('config.factory'), $container->get('entity.manager')->getStorage('date_format') ); } diff --git a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeWidgetBase.php b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeWidgetBase.php index ce86e04..9c3d4e9 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeWidgetBase.php +++ b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeWidgetBase.php @@ -2,17 +2,101 @@ namespace Drupal\datetime\Plugin\Field\FieldWidget; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Datetime\DrupalDateTime; +use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\WidgetBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\datetime\Plugin\Field\ConfigurableTimezoneInterface; +use Drupal\datetime\Plugin\Field\ConfigurableTimezoneTrait; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Base class for the 'datetime_*' widgets. */ -class DateTimeWidgetBase extends WidgetBase { +class DateTimeWidgetBase extends WidgetBase implements ConfigurableTimezoneInterface, ContainerFactoryPluginInterface { + + use ConfigurableTimezoneTrait; + + /** + * A config factory for retrieving required config settings. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $config; + + /** + * Constructs a new DateTimeDefaultFormatter. + * + * @param string $plugin_id + * The plugin_id for the formatter. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The definition of the field to which the formatter is associated. + * @param array $settings + * The formatter settings. + * @param array $third_party_settings + * Third party settings. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The factory for configuration objects. + */ + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ConfigFactoryInterface $config_factory) { + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + $this->config = $config_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['third_party_settings'], + $container->get('config.factory') + ); + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'timezone_default' => ConfigurableTimezoneInterface::TIMEZONE_USER, + 'timezone_override' => '', + 'timezone_per_date' => FALSE, + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $form = $this->timezoneSettingsForm($form, $form_state) + + parent::settingsForm($form, $form_state); + $form['timezone_per_date']['#description'] = "Allow users to specify a time zone when entering a date, and store this as the preferred time zone for that date."; + return $form; + } + + /** + * {@inheritdoc} + */ + public function settingsSummary() { + $per_date_summary = [ + 'use' => 'Allow users to choose a time zone', + 'default' => 'Default to @timezone', + ]; + $summary = $this->timezoneSettingsSummary($per_date_summary) + + parent::settingsSummary(); + return $summary; + } /** * {@inheritdoc} @@ -22,20 +106,20 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#type' => 'datetime', '#default_value' => NULL, '#date_increment' => 1, - '#date_timezone' => drupal_get_user_timezone(), + '#date_timezone' => $this->getDefaultTimezone($items[$delta]->timezone), '#required' => $element['#required'], ]; - if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) { - // A date-only field should have no timezone conversion performed, so - // use the same timezone as for storage. - $element['value']['#date_timezone'] = DateTimeItemInterface::STORAGE_TIMEZONE; + if ($this->getSetting('timezone_per_date') && $this->getFieldSetting('timezone_storage') === TRUE) { + $element['value']['#expose_timezone'] = TRUE; } if ($items[$delta]->date) { + /** @var \Drupal\Core\Datetime\DrupalDateTime $date */ $date = $items[$delta]->date; // The date was created and verified during field_load(), so it is safe to // use without further inspection. + // @todo Remove after #2799987, as then the element will handle this. $date->setTimezone(new \DateTimeZone($element['value']['#date_timezone'])); $element['value']['#default_value'] = $this->createDefaultValue($date, $element['value']['#date_timezone']); } @@ -47,9 +131,10 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen * {@inheritdoc} */ public function massageFormValues(array $values, array $form, FormStateInterface $form_state) { - // The widget form element type has transformed the value to a - // DrupalDateTime object at this point. We need to convert it back to the - // storage timezone and format. + // The widget form element type has transformed the date value to a + // DrupalDateTime object at this point. We need to extract the time zone + // and store it separately, and then convert the date to Drupal's storage + // time zone and format. foreach ($values as &$item) { if (!empty($item['value']) && $item['value'] instanceof DrupalDateTime) { $date = $item['value']; @@ -62,15 +147,38 @@ public function massageFormValues(array $values, array $form, FormStateInterface $format = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; break; } - // Adjust the date for storage. - $date->setTimezone(new \DateTimezone(DateTimeItemInterface::STORAGE_TIMEZONE)); - $item['value'] = $date->format($format); + + // Store the time zone if appropriate. + $item['timezone'] = ''; + if ($this->shouldStoreTimezone($date, $form, $form_state) && $this->getFieldSetting('timezone_storage') === TRUE) { + $item['timezone'] = $date->getTimezone()->getName(); + } + + $item['value'] = $date->format($format, ['timezone' => DateTimeItemInterface::STORAGE_TIMEZONE]); } } return $values; } /** + * Determines whether the time zone should be stored. + * + * @param \Drupal\Core\Datetime\DrupalDateTime $date + * The submitted date value object produced by the widget. + * @param array $form + * The form structure where field elements are attached to. This might be a + * full form structure, or a sub-element of a larger form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return bool + * Whether the time zone should be stored. + */ + protected function shouldStoreTimezone(DrupalDateTime $date, array $form, FormStateInterface $form_state) { + return $this->fieldDefinition->getFieldStorageDefinition()->getSetting('timezone_storage'); + } + + /** * Creates a date object for use as a default value. * * This will take a default value, apply the proper timezone for display in @@ -90,6 +198,7 @@ protected function createDefaultValue($date, $timezone) { if ($this->getFieldSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATE) { $date->setDefaultDateTime(); } + // @todo Remove after #2799987, as then the element will handle this. $date->setTimezone(new \DateTimeZone($timezone)); return $date; } diff --git a/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraint.php b/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraint.php index 5ed340d..3143a15 100644 --- a/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraint.php +++ b/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraint.php @@ -35,4 +35,18 @@ class DateTimeFormatConstraint extends Constraint { */ public $badValue = "The datetime value '@value' did not parse properly for the format '@format'"; + /** + * Message for when the value isn't a string. + * + * @var string + */ + public $badTimezoneType = "The timezone value must be a string."; + + /** + * Message for when the value did not parse properly. + * + * @var string + */ + public $badTimezoneValue = "The timezone value '@timezone' did not parse properly."; + } diff --git a/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraintValidator.php b/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraintValidator.php index 4b2ad5d..f7698e3 100644 --- a/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraintValidator.php +++ b/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraintValidator.php @@ -51,6 +51,22 @@ public function validate($item, Constraint $constraint) { ]); } } + if ($item->getFieldDefinition()->getSetting('timezone_storage')) { + $timezone = $item->getValue()['timezone']; + if (!is_string($timezone)) { + $this->context->addViolation($constraint->badTimezoneType); + } + else { + try { + new \DateTimeZone($timezone); + } + catch (\Exception $e) { + $this->context->addViolation($constraint->badTimezoneValue, [ + '@timezone' => $timezone, + ]); + } + } + } } } diff --git a/core/modules/datetime/src/Tests/DateTestBase.php b/core/modules/datetime/src/Tests/DateTestBase.php index ca43b8d..73affaf 100644 --- a/core/modules/datetime/src/Tests/DateTestBase.php +++ b/core/modules/datetime/src/Tests/DateTestBase.php @@ -36,6 +36,13 @@ protected $displayOptions; /** + * An entity storage to use in this test class. + * + * @var \Drupal\Core\Entity\EntityStorageInterface; + */ + protected $entityStorage; + + /** * A field storage to use in this test class. * * @var \Drupal\field\Entity\FieldStorageConfig @@ -101,6 +108,8 @@ protected function setUp() { ]); $this->drupalLogin($web_user); + $this->entityStorage = $this->container->get('entity_type.manager')->getStorage('entity_test'); + // Create a field with settings to validate. $this->createField(); diff --git a/core/modules/datetime/tests/fixtures/update/field.field.node.page.field_date_1.yml b/core/modules/datetime/tests/fixtures/update/field.field.node.page.field_date_1.yml new file mode 100644 index 0000000..6fd580c --- /dev/null +++ b/core/modules/datetime/tests/fixtures/update/field.field.node.page.field_date_1.yml @@ -0,0 +1,21 @@ +uuid: 2eaa16eb-0542-4b82-b590-4143798f2088 +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_date_1 + - node.type.page + module: + - datetime +id: node.page.field_date_1 +field_name: field_date_1 +entity_type: node +bundle: page +label: date_1 +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: datetime diff --git a/core/modules/datetime/tests/fixtures/update/field.storage.node.field_date_1.yml b/core/modules/datetime/tests/fixtures/update/field.storage.node.field_date_1.yml new file mode 100644 index 0000000..2a771e9 --- /dev/null +++ b/core/modules/datetime/tests/fixtures/update/field.storage.node.field_date_1.yml @@ -0,0 +1,20 @@ +uuid: 8bb2593a-bb3d-4ceb-81c9-a992a883a3cc +langcode: en +status: true +dependencies: + module: + - datetime + - node +id: node.field_date_1 +field_name: field_date_1 +entity_type: node +type: datetime +settings: + datetime_type: datetime +module: datetime +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/modules/datetime/tests/src/Functional/DateTestBase.php b/core/modules/datetime/tests/src/Functional/DateTestBase.php index 21bad50..535f09c 100644 --- a/core/modules/datetime/tests/src/Functional/DateTestBase.php +++ b/core/modules/datetime/tests/src/Functional/DateTestBase.php @@ -31,6 +31,13 @@ protected $displayOptions; /** + * An entity storage to use in this test class. + * + * @var \Drupal\Core\Entity\EntityStorageInterface; + */ + protected $entityStorage; + + /** * A field storage to use in this test class. * * @var \Drupal\field\Entity\FieldStorageConfig @@ -96,6 +103,8 @@ protected function setUp() { ]); $this->drupalLogin($web_user); + $this->entityStorage = $this->container->get('entity_type.manager')->getStorage('entity_test'); + // Create a field with settings to validate. $this->createField(); diff --git a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php index 5acd6fc..c20e1a1 100644 --- a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php +++ b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php @@ -12,6 +12,8 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\node\Entity\Node; +use Drupal\user\Entity\User; +use Drupal\datetime\Plugin\Field\ConfigurableTimezoneInterface; /** * Tests Datetime field functionality. @@ -25,7 +27,11 @@ class DateTimeFieldTest extends DateTestBase { * * @var array */ - protected $defaultSettings = ['timezone_override' => '']; + protected $defaultSettings = [ + 'timezone_default' => ConfigurableTimezoneInterface::TIMEZONE_USER, + 'timezone_override' => '', + 'timezone_per_date' => FALSE, + ]; /** * {@inheritdoc} @@ -313,7 +319,7 @@ public function testDatetimeField() { // Verify that the 'timezone_override' setting works. $this->displayOptions['type'] = 'datetime_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_default' => ConfigurableTimezoneInterface::TIMEZONE_FIXED, 'timezone_override' => 'America/New_York'] + $this->defaultSettings; entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); @@ -371,6 +377,329 @@ public function testDatetimeField() { } /** + * Test combinations of timezone configurations. + */ + public function testFormatterTimezoneSettings() { + // Using different timezones wherever possible helps to highlight leakage. + // Giving timezones labels that describes the context in which they are + // stored makes it easier to diagnose test failures. + $timezones = [ + 'site' => 'Pacific/Kwajalein', + 'user' => 'America/Phoenix', + 'input' => 'Africa/Lagos', + 'override' => 'Asia/Kolkata', + 'php' => 'Pacific/Funafuti', + 'storage' => DateTimeItemInterface::STORAGE_TIMEZONE, + ]; + + // Setup test environment. + // Reset php's default timezone to guarantee it's different to others here. + date_default_timezone_set($timezones['php']); + $this->config('system.date') + ->set('timezone.user.configurable', 1) + ->set('timezone.default', $timezones['site']) + ->save(); + $this->setLoggedInUserTimezone($timezones['user']); + + // Prepare the date that will be stored and rendered by the formatter. + $date = new DrupalDateTime("2012-10-15 17:25:00", 'UTC'); + $date->setTimezone(new \DateTimezone(DateTimeItemInterface::STORAGE_TIMEZONE)); + $field_name = $this->fieldStorage->getName(); + $fields = [ + $field_name => [ + 'value' => $date->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT), + ] + ]; + + // Scenario 1: Stored preference not enabled. + // Test the formatter when per-date time zone storage has not been enabled. + $this->fieldStorage->setSetting('datetime_type', 'datetime'); + $this->fieldStorage->setSetting('timezone_storage', FALSE); + $this->fieldStorage->save(); + $entity = $this->entityStorage->create($fields); + $entity->save(); + $this->formatterSettingsTest('not enabled', $entity->id(), $timezones); + + // Scenario 2: Stored preference unspecified. + // Test the formatter on the same entity, after enabling per-date storage, + // in which case existing entities have NULL as their stored time zone. + $this->fieldStorage->setSetting('datetime_type', 'datetime'); + $this->fieldStorage->setSetting('timezone_storage', TRUE); + $this->fieldStorage->save(); + $this->formatterSettingsTest('no value', $entity->id(), $timezones); + + // Scenario 3: Stored preference specified. + // Test the formatter on a new entity, which will now have a preferred + // time zone stored in its field. + $fields[$field_name]['timezone'] = $timezones['input']; + $entity = $this->entityStorage->create($fields); + $entity->save(); + $this->formatterSettingsTest('has value', $entity->id(), $timezones); + + } + + /** + * Tests formatter output for different sets of settings. + */ + protected function formatterSettingsTest($timezone_storage, $id, $timezones) { + // All the possible formatter timezone settings. + $settings = [ + 'per_date' => [ + 'Formatter using preferred time zone for each date' => TRUE, + 'Formatter not using preferred time zone for each date' => FALSE, + ], + 'default' => [ + 'user' => ConfigurableTimezoneInterface::TIMEZONE_USER, + 'site' => ConfigurableTimezoneInterface::TIMEZONE_SITE, + 'override' => ConfigurableTimezoneInterface::TIMEZONE_FIXED, + ], + ]; + + // Gather information about the field. + $field_name = $this->fieldStorage->getName(); + $entity = $this->entityStorage->load($id); + $fieldValues = $entity->get($field_name)->getValue()[0]; + $storedTimezone = $fieldValues['timezone']; + $storedDateInStorageFormat = $fieldValues['value']; + + foreach ($settings['per_date'] as $per_date_label => $per_date) { + foreach ($settings['default'] as $default_label => $default) { + // Prepare the expectations. The timezone used should always be that set + // in the 'default' setting, unless the 'Use stored preference' setting + // is set AND a timezone is stored in the field for the date value being + // rendered. + $expected_timezone_label = array_search($default, $settings['default']); + if ($timezone_storage === 'has value') { + $this->assertEqual($timezones['input'], $storedTimezone, "Verifying the test is correctly setup with the right time zone stored."); + if ($per_date) { + $expected_timezone_label = 'input'; + } + } + + // Setup the formatter using the settings for the current scenario. + // Have the time zone displayed as part of the output. + $output_format = 'm/d/Y g:i:s A e'; + $this->displayOptions['type'] = 'datetime_custom'; + $this->displayOptions['settings'] = [ + 'date_format' => $output_format, + 'timezone_default' => $default, + 'timezone_override' => $timezones['override'], + 'timezone_per_date' => $per_date, + ]; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + // Try to get the rendered field. + $output = $this->renderTestEntity($id); + $dom = new \DOMDocument(); + @$dom->loadHTML($output); + $xpath = new \DOMXPath($dom); + $fieldXpath = $xpath->query("//div[contains(@class, 'field--type-datetime')]"); + $fieldFound = ($fieldXpath->length === 1); + $this->assertTrue($fieldFound, "Looking for the div.field--type-datetime element"); + + // Test the rendered value + if ($fieldFound) { + // Extract a date from the rendered value + $actualText = trim($fieldXpath->item(0)->textContent); + $actualDate = DrupalDateTime::createFromFormat($output_format, $actualText); + + // Prepare debug messages. + $scenario_message = "Per-date timezones in the field: $timezone_storage."; + $stored_message = new FormattableMarkup("The stored time zone is '@stored_timezone_label' ('@stored_timezone').", [ + '@stored_timezone' => $storedTimezone, + '@stored_timezone_label' => array_search($storedTimezone, $timezones), + ]); + $settings_message = new FormattableMarkup("@per_date and 'Default timezone' set to '@default_label' (@default_timezone).", [ + '@per_date' => $per_date_label, + '@default' => $default_label, + '@default_timezone' => $timezones[$default_label], + ]); + $base_message = new FormattableMarkup("Found text: '@actualText'. \n@scenario_message \n@stored_message \n@settings_message", [ + '@actualText' => $actualText, + '@scenario_message' => $scenario_message, + '@stored_message' => $stored_message, + '@settings_message' => $settings_message, + ]); + + // Test that the rendered date matches the stored date. + $actualDateInStorageFormat = $actualDate->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT, ['timezone' => DateTimeItemInterface::STORAGE_TIMEZONE]); + $message = new FormattableMarkup("Rendered date can be interpreted as '@actual', expected @expected. \n@base_message.", [ + '@actual' => $actualDateInStorageFormat, + '@expected' => $storedDateInStorageFormat, + '@base_message' => $base_message, + ]); + $this->assertEqual($storedDateInStorageFormat, $actualDateInStorageFormat, $message); + + // Test that the rendered date used the expected timezone. + // Lookup the logical source of the timezone shown in the rendered date. + $actualTimeZone = $actualDate->getTimezone()->getName(); + if (!$actualTimeSetting = array_search($actualTimeZone, $timezones)) { + $actualTimeSetting = $actualTimeZone; + } + // Compare that with the source expected to be in effect. + $message = new FormattableMarkup("Time formatted using '@actual' timezone, expected to use '@expected' timezone. \n @scenario_message @stored_message @settings_message.", [ + '@actual' => $actualTimeSetting, + '@expected' => $expected_timezone_label, + '@base_message' => $base_message, + ]); + $this->assertIdentical($expected_timezone_label, $actualTimeSetting, $message); + } + } + } + } + + /** + * Tests widget with different settings. + */ + public function testWidgetTimezoneSettings() { + // Using different timezones wherever possible helps to highlight leakage. + // Giving timezones labels that describes the context in which they are + // stored makes it easier to diagnose test failures. + $timezones = [ + 'site' => 'Pacific/Kwajalein', + 'user' => 'America/Phoenix', + 'input' => 'Africa/Lagos', + 'override' => 'Asia/Kolkata', + 'php' => 'Pacific/Funafuti', + 'storage' => DateTimeItemInterface::STORAGE_TIMEZONE, + ]; + + // The test date that should be saved by the widget + $date = new DrupalDateTime('2012-10-15 17:25:00', DateTimeItemInterface::STORAGE_TIMEZONE); + + // Store the test date formatted according to the different timezones, for + // use in error reporting and debugging. + $formattedDates = []; + foreach ($timezones as $label => $timezone) { + $formattedDates[$label] = $date->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT, $timezone); + } + + // Setup test environment. + // Reset php's default timezone to guarantee it's different to others here. + date_default_timezone_set($timezones['php']); + $this->config('system.date') + ->set('timezone.user.configurable', 1) + ->set('timezone.default', $timezones['site']) + ->save(); + $this->setLoggedInUserTimezone($timezones['user']); + + $scenarios = [ + 'per_date' => [ + // The field is not configured to have per-date timezone storage. + 'no storage' => FALSE, + // The field allows per-date timezones, but the widget does not. + 'not allowed' => FALSE, + // The widget allows, but the editor leaves at the default timezone. + 'default' => TRUE, + // The editor selects a timezone other than the default. + 'input' => TRUE, + ], + 'default' => [ + 'user' => ConfigurableTimezoneInterface::TIMEZONE_USER, + 'site' => ConfigurableTimezoneInterface::TIMEZONE_SITE, + 'override' => ConfigurableTimezoneInterface::TIMEZONE_FIXED, + ], + ]; + $this->setLoggedInUserTimezone($timezones['user']); + $field_name = $this->fieldStorage->getName(); + + foreach ($scenarios['per_date'] as $per_date_label => $per_date) { + foreach ($scenarios['default'] as $default_label => $default) { + // Determine the timezone the user will intend for input. We assume the + // user behaves according to the site builder's intention. Therefore the + // timezone used should always be that set in the 'default' setting, + // unless we are testing the scenario where the user can and does + // select a per-date timezone other than the default. + $timezone_intended_label = $default_label; + if ($per_date_label === 'input') { + $timezone_intended_label = 'input'; + } + $timezone_intended = $timezones[$timezone_intended_label]; + // Determine what we expect to be stored in the field's timezone column. + // We expect nothing to be stored if the widget doesn't allow the editor + // to select a per-date time zone. + $timezone_expected_label = NULL; + $timezone_expected = NULL; + if ($per_date) { + $timezone_expected_label = $timezone_intended_label; + $timezone_expected = $timezones[$timezone_expected_label]; + } + + // Set up the field. + $this->fieldStorage->setSetting('datetime_type', 'datetime'); + if ($per_date_label === 'no storage') { + $this->fieldStorage->setSetting('timezone_storage', FALSE); + } + else { + $this->fieldStorage->setSetting('timezone_storage', TRUE); + } + $this->fieldStorage->save(); + + // Setup the widget. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'datetime_default', + 'settings' => [ + 'timezone_default' => $default, + 'timezone_override' => $timezones['override'], + 'timezone_per_date' => $per_date, + ], + ]) + ->save(); + + // Prepare the field values. + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + $field_name = $this->fieldStorage->getName(); + $edit = [ + "{$field_name}[0][value][date]" => $date->format($date_format, ['timezone' => $timezone_intended]), + "{$field_name}[0][value][time]" => $date->format($time_format, ['timezone' => $timezone_intended]), + ]; + // Only set time zone input if testing the scenario where a time zone + // select is exposed and the user selects other than the default. + if ($per_date_label === 'input') { + $edit += [ + "{$field_name}[0][value][timezone]" => $timezone_intended, + ]; + } + + // Try to save the date through a widget on an entity. + $this->drupalPostForm('entity_test/add', $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + // Test what is stored in the field + if ($id) { + $entity = $this->entityStorage->load($id); + $fieldValues = $entity->get($field_name)->getValue()[0]; + $messageBase = new FormattableMarkup("For scenario per-date '@per_date' with default '@default': ", [ + '@per_date' => $per_date_label, + '@default' => $default_label, + ]); + $this->assertEqual($date->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT, ['timezone' => DateTimeItemInterface::STORAGE_TIMEZONE]), $fieldValues['value'], $messageBase . "checking correct date is stored."); + $message = new FormattableMarkup("the time zone stored is expected to be '@expected', actually is '@actual'.", [ + '@expected' => $timezone_expected_label, + '@actual' => array_search($fieldValues['timezone'], $timezones), + ]); + $this->assertEqual($timezone_expected, $fieldValues['timezone'], $messageBase . $message); + } + } + } + } + + /** + * Sets the timezone for the currently logged in user. + */ + protected function setLoggedInUserTimezone($timezone) { + $user = User::load($this->loggedInUser->id()); + $user->set('timezone', $timezone)->save(); + $this->setCurrentUser($user); + } + + /** * Tests Date List Widget functionality. */ public function testDatelistWidget() { @@ -776,6 +1105,7 @@ public function testInvalidField() { $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->assertNoFieldByName("{$field_name}[0][timezone]", '', 'No timezone field appears for dates that do not collect timezone information.'); // Submit invalid dates and ensure they is not accepted. $date_value = ''; diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php index 0fb404d..e70e12a 100644 --- a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php +++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php @@ -91,6 +91,7 @@ protected function getExpectedNormalizedEntity() { static::$fieldName => [ [ 'value' => $this->entity->get(static::$fieldName)->value, + 'timezone' => $this->entity->get(static::$fieldName)->value_timezone, ], ], ]; diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php index cdd4d60..7f5d0fd 100644 --- a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php +++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php @@ -91,6 +91,7 @@ protected function getExpectedNormalizedEntity() { static::$fieldName => [ [ 'value' => $this->entity->get(static::$fieldName)->value, + 'timezone' => $this->entity->get(static::$fieldName)->value_timezone, ], ], ]; diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTimezoneTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTimezoneTest.php new file mode 100644 index 0000000..81b9174 --- /dev/null +++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTimezoneTest.php @@ -0,0 +1,142 @@ + static::$fieldName, + 'type' => 'datetime', + 'entity_type' => static::$entityTypeId, + 'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATETIME, 'timezone_storage' => TRUE], + ]) + ->save(); + + FieldConfig::create([ + 'field_name' => static::$fieldName, + 'entity_type' => static::$entityTypeId, + 'bundle' => $this->entity->bundle(), + 'settings' => ['default_value' => static::$dateString], + ]) + ->save(); + + // Reload entity so that it has the new field. + $this->entity = $this->entityStorage->load($this->entity->id()); + $this->entity->set(static::$fieldName, ['value' => static::$dateString, 'timezone' => static::$timezone]); + $this->entity->save(); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $entity_test = EntityTest::create([ + 'name' => 'Llama', + 'type' => static::$entityTypeId, + static::$fieldName => static::$dateString, + ]); + $entity_test->setOwnerId(0); + $entity_test->save(); + + return $entity_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return parent::getExpectedNormalizedEntity() + [ + static::$fieldName => [ + [ + 'value' => $this->entity->get(static::$fieldName)->value, + 'timezone' => $this->entity->get(static::$fieldName)->timezone, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + static::$fieldName => [ + [ + 'value' => static::$dateString, + 'timezone' => static::$timezone, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) { + parent::assertNormalizationEdgeCases($method, $url, $request_options); + + if ($this->entity->getEntityType()->hasKey('bundle')) { + $fieldName = static::$fieldName; + + // DX: 422 when timezone format is incorrect. + $normalization = $this->getNormalizedPostEntity(); + $value = '2017-03-01T01:02:03'; + $timezone = 'Mars/Phobos'; + $normalization[static::$fieldName][0]['value'] = $value; + $normalization[static::$fieldName][0]['timezone'] = $timezone; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The timezone value '{$timezone}' did not parse properly.\n"; + $this->assertResourceErrorResponse(422, $message, $response); + } + } + +} diff --git a/core/modules/datetime/tests/src/Functional/Update/DatetimeUpdateTest.php b/core/modules/datetime/tests/src/Functional/Update/DatetimeUpdateTest.php new file mode 100644 index 0000000..4c3330c --- /dev/null +++ b/core/modules/datetime/tests/src/Functional/Update/DatetimeUpdateTest.php @@ -0,0 +1,67 @@ +configFactory = $this->container->get('config.factory'); + $this->entityFieldManager = $this->container->get('entity_field.manager'); + } + + /** + * {@inheritdoc} + */ + protected function setDatabaseDumpFiles() { + $this->databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.bare.standard.php.gz', + __DIR__ . '/../../../fixtures/update/datetime-date_8001-values.php', + ]; + } + + /** + * Tests that timezone changes are applied. + * + * @see datetime_update_8001() + */ + public function testTimezoneSettings() { + // Load the 'node.field_date_1' field storage config, and check that the is + // no timezone storage yet. + $config = $this->configFactory->get('field.storage.node.field_date_1'); + $settings = $config->get('settings'); + $this->assertFalse(array_key_exists('timezone_storage', $settings)); + + // Run updates. + $this->runUpdates(); + + // Check the timezone storage has been added and defaulted to FALSE. + $config = $this->configFactory->get('field.storage.node.field_date_1'); + $settings = $config->get('settings'); + $this->assertTrue(array_key_exists('timezone_storage', $settings)); + $this->assertFalse($settings['timezone_storage']); + + // Ensure timezone column added and NULL for existing content. + $node = node_load(1); + $this->assertNull($node->field_date_1->timezone); + } + +} diff --git a/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php b/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php index 75f892a..23645b0 100644 --- a/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php +++ b/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php @@ -47,7 +47,10 @@ protected function setUp() { 'field_name' => 'field_datetime', 'type' => 'datetime', 'entity_type' => 'entity_test', - 'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATETIME], + 'settings' => [ + 'datetime_type' => DateTimeItem::DATETIME_TYPE_DATETIME, + 'timezone_storage' => FALSE, + ], ]); $this->fieldStorage->save(); $this->field = FieldConfig::create([ @@ -346,4 +349,28 @@ public function dateonlyValidationProvider() { ]; } + /** + * Tests DateTimeItem with per-date time zone storage. + */ + public function testTimezoneDate() { + /** @var \Drupal\field\FieldStorageConfigInterface $field_storage */ + $field_storage = FieldStorageConfig::load('entity_test.field_datetime'); + $field_storage->setSetting('timezone_storage', TRUE); + $field_storage->save(); + + // Use a non-UTC timezone. + $timezone = 'America/Yellowknife'; + + $entity = EntityTest::create(); + $value = '2014-01-01T20:00:00Z'; + + $entity->set('field_datetime', ['value' => $value, 'timezone' => $timezone]); + $entity->save(); + + // Load the entity. + $id = $entity->id(); + $entity = EntityTest::load($id); + $this->assertEqual($timezone, $entity->field_datetime[0]->timezone, '"timezone" property can be set.'); + } + } diff --git a/core/modules/datetime_range/config/schema/datetime_range.schema.yml b/core/modules/datetime_range/config/schema/datetime_range.schema.yml index f0f9325..fa2e57e 100644 --- a/core/modules/datetime_range/config/schema/datetime_range.schema.yml +++ b/core/modules/datetime_range/config/schema/datetime_range.schema.yml @@ -55,7 +55,7 @@ field.formatter.settings.daterange_custom: translation context: 'Date range separator' field.widget.settings.daterange_datelist: - type: mapping + type: field.widget.settings.datetime_base label: 'Date range select list display format settings' mapping: increment: @@ -69,5 +69,5 @@ field.widget.settings.daterange_datelist: label: 'Time type' field.widget.settings.daterange_default: - type: mapping + type: field.widget.settings.datetime_base label: 'Date range default display format settings' diff --git a/core/modules/datetime_range/src/Plugin/Field/FieldType/DateRangeItem.php b/core/modules/datetime_range/src/Plugin/Field/FieldType/DateRangeItem.php index 7c34ed1..c17e194 100644 --- a/core/modules/datetime_range/src/Plugin/Field/FieldType/DateRangeItem.php +++ b/core/modules/datetime_range/src/Plugin/Field/FieldType/DateRangeItem.php @@ -55,6 +55,9 @@ public static function propertyDefinitions(FieldStorageDefinitionInterface $fiel ->setClass(DateTimeComputed::class) ->setSetting('date source', 'end_value'); + $properties['timezone'] = DataDefinition::create('string') + ->setLabel(t('Timezone')); + return $properties; } @@ -83,6 +86,18 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state $element['datetime_type']['#options'][static::DATETIME_TYPE_ALLDAY] = $this->t('All Day'); + $element['timezone_storage']['#states'] = [ + // Hide the option for per-date time zone storage if this is a date-only + // or all-day field. + 'visible' => [ + ':input[name="settings[datetime_type]"]' => ['value' => static::DATETIME_TYPE_DATETIME], + ], + 'disabled' => [ + [':input[name="settings[datetime_type]"]' => ['value' => static::DATETIME_TYPE_DATE]], + [':input[name="settings[datetime_type]"]' => ['value' => static::DATETIME_TYPE_ALLDAY]], + ], + ]; + return $element; } diff --git a/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeDefaultWidget.php b/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeDefaultWidget.php index 79f7994..5039dd7 100644 --- a/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeDefaultWidget.php +++ b/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeDefaultWidget.php @@ -9,6 +9,7 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\datetime_range\Plugin\Field\FieldType\DateRangeItem; use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Core\Config\ConfigFactoryInterface; /** * Plugin implementation of the 'daterange_default' widget. @@ -33,8 +34,8 @@ class DateRangeDefaultWidget extends DateRangeWidgetBase implements ContainerFac /** * {@inheritdoc} */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityStorageInterface $date_storage) { - parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ConfigFactoryInterface $config_factory, EntityStorageInterface $date_storage) { + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $config_factory); $this->dateStorage = $date_storage; } @@ -49,6 +50,7 @@ public static function create(ContainerInterface $container, array $configuratio $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], + $container->get('config.factory'), $container->get('entity_type.manager')->getStorage('date_format') ); } diff --git a/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeWidgetBase.php b/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeWidgetBase.php index 85fda9b..c1c258f 100644 --- a/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeWidgetBase.php +++ b/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeWidgetBase.php @@ -30,6 +30,10 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#title' => $this->t('End date'), ] + $element['value']; + // The time zone selector should be present only once, but should apply to + // both start and end date. + $element['end_value']['#expose_timezone'] = FALSE; + if ($items[$delta]->start_date) { /** @var \Drupal\Core\Datetime\DrupalDateTime $start_date */ $start_date = $items[$delta]->start_date; @@ -39,7 +43,9 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen if ($items[$delta]->end_date) { /** @var \Drupal\Core\Datetime\DrupalDateTime $end_date */ $end_date = $items[$delta]->end_date; - $element['end_value']['#default_value'] = $this->createDefaultValue($end_date, $element['end_value']['#date_timezone']); + // By getting $element['value'] instead of $element['end_value'] we use + // the same timezone as the start date. + $element['end_value']['#default_value'] = $this->createDefaultValue($end_date, $element['value']['#date_timezone']); } return $element; @@ -65,19 +71,25 @@ public function massageFormValues(array $values, array $form, FormStateInterface // All day fields start at midnight on the starting date, but are // stored like datetime fields, so we need to adjust the time. // This function is called twice, so to prevent a double conversion - // we need to explicitly set the timezone. - $start_date->setTimeZone(timezone_open(drupal_get_user_timezone())); + // we need to explicitly set the time zone. The correct time zone is + // the default time zone, because all day fields don't allow + // per-date timezones. + $start_date->setTimezone(timezone_open($this->getDefaultTimezone())); $start_date->setTime(0, 0, 0); $format = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; break; default: $format = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; + // Store the time zone if appropriate. + $item['timezone'] = ''; + if ($this->shouldStoreTimezone($start_date, $form, $form_state) && $this->getFieldSetting('timezone_storage') === TRUE) { + $item['timezone'] = $start_date->getTimezone()->getName(); + } break; } // Adjust the date for storage. - $start_date->setTimezone(new \DateTimezone(DateTimeItemInterface::STORAGE_TIMEZONE)); - $item['value'] = $start_date->format($format); + $item['value'] = $start_date->format($format, ['timezone' => DateTimeItemInterface::STORAGE_TIMEZONE]); } if (!empty($item['end_value']) && $item['end_value'] instanceof DrupalDateTime) { @@ -92,19 +104,36 @@ public function massageFormValues(array $values, array $form, FormStateInterface // All day fields end at midnight on the end date, but are // stored like datetime fields, so we need to adjust the time. // This function is called twice, so to prevent a double conversion - // we need to explicitly set the timezone. - $end_date->setTimeZone(timezone_open(drupal_get_user_timezone())); + // we need to explicitly set the time zone. The correct time zone is + // the default time zone, because all day fields don't allow + // per-date timezones. + $end_date->setTimezone(timezone_open($this->getDefaultTimezone())); $end_date->setTime(23, 59, 59); $format = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; break; default: $format = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; + if ($this->getSetting('timezone_per_date') && $this->getFieldSetting('timezone_storage') === TRUE) { + // The datetime element will have interpreted the end date using + // the time zone specified in #date_timezone, because the time + // zone selector is not present on the end date. Therefore we + // have to restore the value back to its raw state, and + // reinterpret it using the time zone from the start value, which + // used the time zone selector. This is safe only because the + // datetime element's valueCallback sets the time zone on its + // returned date object to be the time zone it used to interpret + // the inputted time, and the start time must always be present. + $raw_end_date = $end_date->format($format); + if (!empty($start_date) && $start_date instanceof DrupalDateTime) { + $timezone = $start_date->getTimezone()->getName(); + $end_date = DrupalDateTime::createFromFormat($format, $raw_end_date, $timezone); + } + } break; } // Adjust the date for storage. - $end_date->setTimezone(new \DateTimezone(DateTimeItemInterface::STORAGE_TIMEZONE)); - $item['end_value'] = $end_date->format($format); + $item['end_value'] = $end_date->format($format, ['timezone' => DateTimeItemInterface::STORAGE_TIMEZONE]); } } @@ -112,7 +141,7 @@ public function massageFormValues(array $values, array $form, FormStateInterface } /** - * #element_validate callback to ensure that the start date <= the end date. + * Callback for #element_validate, ensures the start date <= the end date. * * @param array $element * An associative array containing the properties and children of the @@ -127,6 +156,22 @@ public function validateStartEnd(array &$element, FormStateInterface $form_state $end_date = $element['end_value']['#value']['object']; if ($start_date instanceof DrupalDateTime && $end_date instanceof DrupalDateTime) { + // Make sure the start date and end date are using the same timezone. + switch ($this->getFieldSetting('datetime_type')) { + case DateRangeItem::DATETIME_TYPE_DATE: + $format = DateTimeItemInterface::DATE_STORAGE_FORMAT; + break; + case DateRangeItem::DATETIME_TYPE_ALLDAY: + $format = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; + break; + default: + $format = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; + break; + } + $raw_end_date = $end_date->format($format); + $timezone = $start_date->getTimezone()->getName(); + $end_date = DrupalDateTime::createFromFormat($format, $raw_end_date, $timezone); + if ($start_date->getTimestamp() !== $end_date->getTimestamp()) { $interval = $start_date->diff($end_date); if ($interval->invert === 1) { @@ -134,6 +179,7 @@ public function validateStartEnd(array &$element, FormStateInterface $form_state } } } + } } diff --git a/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php b/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php index bd83b98..4bf9aba 100644 --- a/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php +++ b/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php @@ -6,6 +6,7 @@ use Drupal\Component\Utility\Unicode; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Datetime\Entity\DateFormat; +use Drupal\datetime\Plugin\Field\FieldFormatter\DateTimeFormatterBase; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; use Drupal\Tests\datetime\Functional\DateTestBase; use Drupal\datetime_range\Plugin\Field\FieldType\DateRangeItem; @@ -369,7 +370,7 @@ public function testDatetimeRangeField() { // Verify that the 'timezone_override' setting works. $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York', 'timezone_default' => DateTimeFormatterBase::TIMEZONE_FIXED] + $this->defaultSettings; entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); @@ -537,7 +538,7 @@ public function testAlldayRangeField() { // Verify that the 'timezone_override' setting works. $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York', 'timezone_default' => DateTimeFormatterBase::TIMEZONE_FIXED] + $this->defaultSettings; entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); diff --git a/core/modules/datetime_range/tests/src/Kernel/DateRangeItemTest.php b/core/modules/datetime_range/tests/src/Kernel/DateRangeItemTest.php index 54f805f..30cefb0 100644 --- a/core/modules/datetime_range/tests/src/Kernel/DateRangeItemTest.php +++ b/core/modules/datetime_range/tests/src/Kernel/DateRangeItemTest.php @@ -50,7 +50,10 @@ protected function setUp() { 'field_name' => Unicode::strtolower($this->randomMachineName()), 'entity_type' => 'entity_test', 'type' => 'daterange', - 'settings' => ['datetime_type' => DateRangeItem::DATETIME_TYPE_DATE], + 'settings' => [ + 'datetime_type' => DateRangeItem::DATETIME_TYPE_DATE, + 'timezone_storage' => FALSE, + ], ]); $this->fieldStorage->save(); diff --git a/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldFormatterSettingsTest.php b/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldFormatterSettingsTest.php index 7b6e96a..b58f4e4 100644 --- a/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldFormatterSettingsTest.php +++ b/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldFormatterSettingsTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\field\Kernel\Migrate\d6; use Drupal\Core\Entity\Entity\EntityViewDisplay; +use Drupal\datetime\Plugin\Field\FieldFormatter\DateTimeFormatterBase; use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase; /** @@ -93,10 +94,10 @@ public function testEntityDisplaySettings() { $expected['weight'] = 2; $expected['type'] = 'number_decimal'; $expected['settings'] = [ - 'scale' => 2, - 'decimal_separator' => '.', - 'thousand_separator' => ',', - 'prefix_suffix' => TRUE, + 'scale' => 2, + 'decimal_separator' => '.', + 'thousand_separator' => ',', + 'prefix_suffix' => TRUE, ]; $component = $display->getComponent('field_test_three'); $this->assertIdentical($expected, $component); @@ -159,7 +160,7 @@ public function testEntityDisplaySettings() { $this->assertIdentical($expected, $component); // Test date field. - $defaults = ['format_type' => 'fallback', 'timezone_override' => '']; + $defaults = ['format_type' => 'fallback', 'timezone_override' => '', 'timezone_default' => DateTimeFormatterBase::TIMEZONE_USER, 'timezone_per_date' => FALSE]; $expected['weight'] = 10; $expected['type'] = 'datetime_default'; $expected['settings'] = ['format_type' => 'fallback'] + $defaults; diff --git a/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldInstanceTest.php b/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldInstanceTest.php index 6c4e29c..625780c 100644 --- a/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldInstanceTest.php +++ b/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldInstanceTest.php @@ -107,7 +107,7 @@ public function testFieldInstanceMigration() { $this->assertInstanceOf(FieldConfig::class, $field); $this->assertSame('Date Field', $field->label()); $this->assertSame('An example date field.', $field->getDescription()); - $expected = ['datetime_type' => 'datetime']; + $expected = ['datetime_type' => 'datetime', 'timezone_storage' => FALSE]; $this->assertSame($expected, $field->getSettings()); $expected = [ [ @@ -123,7 +123,7 @@ public function testFieldInstanceMigration() { $this->assertInstanceOf(FieldConfig::class, $field); $this->assertSame('Datetime Field', $field->label()); $this->assertSame('An example datetime field.', $field->getDescription()); - $expected = ['datetime_type' => 'datetime']; + $expected = ['datetime_type' => 'datetime', 'timezone_storage' => FALSE]; $this->assertSame($expected, $field->getSettings()); $expected = []; $this->assertSame($expected, $field->getDefaultValueLiteral()); diff --git a/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldWidgetSettingsTest.php b/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldWidgetSettingsTest.php index c6f47fb..2944037 100644 --- a/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldWidgetSettingsTest.php +++ b/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldWidgetSettingsTest.php @@ -91,7 +91,11 @@ public function testWidgetSettings() { $component = $form_display->getComponent('field_test_date'); $expected['type'] = 'datetime_default'; $expected['weight'] = 10; - $expected['settings'] = []; + $expected['settings'] = [ + 'timezone_default' => 'user', + 'timezone_override' => '', + 'timezone_per_date' => FALSE + ]; $this->assertIdentical($expected, $component); $component = $form_display->getComponent('field_test_datestamp'); diff --git a/core/modules/field_ui/src/Form/FieldStorageAddForm.php b/core/modules/field_ui/src/Form/FieldStorageAddForm.php index 584cc96..8974cea 100644 --- a/core/modules/field_ui/src/Form/FieldStorageAddForm.php +++ b/core/modules/field_ui/src/Form/FieldStorageAddForm.php @@ -416,7 +416,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) { } /** - * Configures the field for the default form mode. + * Configures the newly created field for the default view and form modes. * * @param string $field_name * The field name. @@ -442,7 +442,7 @@ protected function configureEntityFormDisplay($field_name, $widget_id = NULL, ar } /** - * Configures the field for the default view mode. + * Configures the newly created field for the default view and form modes. * * @param string $field_name * The field name.