diff --git a/core/lib/Drupal/Core/Datetime/Element/Datetime.php b/core/lib/Drupal/Core/Datetime/Element/Datetime.php index a522d57..10e39a0 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, ]; } @@ -191,6 +200,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 @@ -223,7 +234,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; @@ -324,6 +337,17 @@ public static function processDatetime(&$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..14650d2 --- /dev/null +++ b/core/modules/datetime/datetime.install @@ -0,0 +1,101 @@ +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..8c95e87 --- /dev/null +++ b/core/modules/datetime/src/Plugin/Field/ConfigurableTimezoneInterface.php @@ -0,0 +1,44 @@ +fieldDefinition->getFieldStorageDefinition() + ->getSetting('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. + if ($this->fieldDefinition->getFieldStorageDefinition() + ->getSetting('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 = []; + if ($this->fieldDefinition->getFieldStorageDefinition() + ->getSetting('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 = DATETIME_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 984423d..75bbaff 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php @@ -5,6 +5,7 @@ use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; /** * Plugin implementation of the 'Custom' formatter for 'datetime' fields. @@ -42,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); } } @@ -54,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()); } /** @@ -82,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..44dd582 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php @@ -4,6 +4,7 @@ use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Form\FormStateInterface; +use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; /** * Plugin implementation of the 'Default' formatter for 'datetime' fields. @@ -32,8 +33,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 +68,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 98b1ea3..bb0fa74 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; @@ -12,11 +13,14 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; 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. @@ -33,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 @@ -53,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; } /** @@ -74,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') ); } @@ -83,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(); } @@ -91,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', @@ -108,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; } @@ -127,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; @@ -153,59 +172,41 @@ 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 = DATETIME_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) { + protected function buildDate(DrupalDateTime $date, $timezone = NULL) { if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) { // A date without time will pick up the current time, use the default. datetime_date_default_time($date); } - $this->setTimeZone($date); + $this->setTimeZone($date, $timezone); $build = [ '#markup' => $this->formatDate($date), @@ -224,11 +225,13 @@ 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) { if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) { // A date without time will pick up the current time, use the default. datetime_date_default_time($date); @@ -237,7 +240,7 @@ protected function buildDateWithIsoAttribute(DrupalDateTime $date) { // 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 7f0dee2..10cc9fd 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php @@ -29,7 +29,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); } } @@ -41,8 +41,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) { */ protected function formatDate($date) { $format = $this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE ? DATETIME_DATE_STORAGE_FORMAT : DATETIME_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 330782b..f12fc66 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 { 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/DateTimeDatelistWidget.php b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDatelistWidget.php index 276d92c..f50251f 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDatelistWidget.php +++ b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDatelistWidget.php @@ -4,6 +4,7 @@ use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Plugin implementation of the 'datetime_datelist' widget. 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 f965e21..2994d54 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeWidgetBase.php +++ b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeWidgetBase.php @@ -6,32 +6,116 @@ use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\WidgetBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\datetime\Plugin\Field\ConfigurableTimezoneInterface; +use Drupal\datetime\Plugin\Field\ConfigurableTimezoneTrait; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Field\FieldDefinitionInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; /** * 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} + */ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { $element['value'] = [ '#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'] = DATETIME_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. @@ -40,10 +124,10 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen // time. datetime_date_default_time($date); } + // @todo Remove after #2799987, as then the element will handle this. $date->setTimezone(new \DateTimeZone($element['value']['#date_timezone'])); $element['value']['#default_value'] = $date; } - return $element; } @@ -51,9 +135,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']; @@ -69,12 +154,36 @@ public function massageFormValues(array $values, array $form, FormStateInterface $format = DATETIME_DATETIME_STORAGE_FORMAT; break; } - // Adjust the date for storage. - $date->setTimezone(new \DateTimezone(DATETIME_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' => DATETIME_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 boolean + * Whether the time zone should be stored. + */ + protected function shouldStoreTimezone(DrupalDateTime $date, array $form, FormStateInterface $form_state) { + return $this->getSetting('timezone_per_date'); + } + } diff --git a/core/modules/datetime/src/Tests/DateTestBase.php b/core/modules/datetime/src/Tests/DateTestBase.php index a234ae0..51ca8e4 100644 --- a/core/modules/datetime/src/Tests/DateTestBase.php +++ b/core/modules/datetime/src/Tests/DateTestBase.php @@ -7,6 +7,7 @@ use Drupal\Component\Utility\Unicode; use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\Core\Entity\Entity\EntityViewDisplay; +use Drupal\Core\Entity\EntityStorageInterface; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; use Drupal\entity_test\Entity\EntityTest; use Drupal\field\Entity\FieldConfig; @@ -36,6 +37,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 +109,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/DateTestBase.php b/core/modules/datetime/tests/src/Functional/DateTestBase.php index 9127eb2..bec858a 100644 --- a/core/modules/datetime/tests/src/Functional/DateTestBase.php +++ b/core/modules/datetime/tests/src/Functional/DateTestBase.php @@ -7,6 +7,7 @@ use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; use Drupal\entity_test\Entity\EntityTest; +use Drupal\Core\Entity\EntityStorageInterface; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\Tests\BrowserTestBase; @@ -31,6 +32,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 +104,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 e30cae5..b88e800 100644 --- a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php +++ b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php @@ -11,6 +11,10 @@ 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; +use DOMDocument; +use DOMXPath; /** * Tests Datetime field functionality. @@ -24,7 +28,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} @@ -310,16 +318,6 @@ public function testDatetimeField() { $output = $this->renderTestEntity($id); $this->assertContains($expected, $output, SafeMarkup::format('Formatted date field using datetime_custom format displayed as %expected.', ['%expected' => $expected])); - // 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; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); - $output = $this->renderTestEntity($id); - $this->assertContains($expected, $output, SafeMarkup::format('Formatted date field using datetime_custom format displayed as %expected.', ['%expected' => $expected])); - // Verify that the 'datetime_time_ago' formatter works for intervals in the // past. First update the test entity so that the date difference always // has the same interval. Since the database always stores UTC, and the @@ -367,6 +365,330 @@ public function testDatetimeField() { ]); $output = $this->renderTestEntity($id); $this->assertContains((string) $expected, $output, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); + + } + + /** + * 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' => DATETIME_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(DATETIME_STORAGE_TIMEZONE)); + $field_name = $this->fieldStorage->getName(); + $fields = [ + $field_name => [ + 'value' => $date->format(DATETIME_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(DATETIME_DATETIME_STORAGE_FORMAT, ['timezone' => DATETIME_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' => DATETIME_STORAGE_TIMEZONE, + ]; + + // The test date that should be saved by the widget + $date = new DrupalDateTime('2012-10-15 17:25:00', DATETIME_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(DATETIME_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(DATETIME_DATETIME_STORAGE_FORMAT, ['timezone' => DATETIME_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); } /** @@ -775,6 +1097,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/Kernel/DateTimeItemTest.php b/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php index 9884007..0670703 100644 --- a/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php +++ b/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php @@ -46,7 +46,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([ @@ -333,4 +336,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/src/Plugin/Field/FieldType/DateRangeItem.php b/core/modules/datetime_range/src/Plugin/Field/FieldType/DateRangeItem.php index 49c4dce..8b8929b 100644 --- a/core/modules/datetime_range/src/Plugin/Field/FieldType/DateRangeItem.php +++ b/core/modules/datetime_range/src/Plugin/Field/FieldType/DateRangeItem.php @@ -54,6 +54,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; } @@ -82,6 +85,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 e99374c..e2828d4 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,9 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#title' => $this->t('End date'), ] + $element['value']; + // The time zone selector should be present only once. + $element['end_value']['#expose_timezone'] = FALSE; + if ($items[$delta]->start_date) { /** @var \Drupal\Core\Datetime\DrupalDateTime $start_date */ $start_date = $items[$delta]->start_date; @@ -68,19 +71,26 @@ 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 = DATETIME_DATETIME_STORAGE_FORMAT; break; default: $format = DATETIME_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(DATETIME_STORAGE_TIMEZONE)); - $item['value'] = $start_date->format($format); + $item['value'] = $start_date->format($format, ['timezone' => DATETIME_STORAGE_TIMEZONE,]); } if (!empty($item['end_value']) && $item['end_value'] instanceof DrupalDateTime) { @@ -98,19 +108,38 @@ 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 = DATETIME_DATETIME_STORAGE_FORMAT; break; default: $format = DATETIME_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(); + drupal_set_message(rand(). "starttz:" . $timezone); + $end_date = DrupalDateTime::createFromFormat($format, $raw_end_date, $timezone); + } + } break; } + // Adjust the date for storage. - $end_date->setTimezone(new \DateTimezone(DATETIME_STORAGE_TIMEZONE)); - $item['end_value'] = $end_date->format($format); + $item['end_value'] = $end_date->format($format, ['timezone' => DATETIME_STORAGE_TIMEZONE,]); } } @@ -140,6 +169,7 @@ public function validateStartEnd(array &$element, FormStateInterface $form_state } } } + } /** @@ -164,6 +194,7 @@ protected function createDefaultValue($date, $timezone) { // time. datetime_date_default_time($date); } + // @todo Remove after #2799987, as then the element will handle this. $date->setTimezone(new \DateTimeZone($timezone)); return $date; } diff --git a/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php b/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php index 50f61ee..d8ec4c4 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\FieldType\DateTimeItem; use Drupal\Tests\datetime\Functional\DateTestBase; use Drupal\datetime_range\Plugin\Field\FieldType\DateRangeItem; use Drupal\entity_test\Entity\EntityTest; @@ -368,7 +369,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(); @@ -536,7 +537,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 67e1b9d..a411a18 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 aa47c90..adc8bd7 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\FieldType\DateTimeItem; 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); @@ -156,7 +157,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]; $expected['weight'] = 10; $expected['type'] = 'datetime_default'; $expected['settings'] = ['format_type' => 'fallback'] + $defaults; diff --git a/core/modules/field_ui/src/Form/FieldStorageAddForm.php b/core/modules/field_ui/src/Form/FieldStorageAddForm.php index e1f2787..5e5a842 100644 --- a/core/modules/field_ui/src/Form/FieldStorageAddForm.php +++ b/core/modules/field_ui/src/Form/FieldStorageAddForm.php @@ -410,7 +410,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. @@ -428,7 +428,7 @@ protected function configureEntityFormDisplay($field_name, $widget_id = NULL) { } /** - * 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.