diff --git a/core/modules/datetime/config/schema/datetime.schema.yml b/core/modules/datetime/config/schema/datetime.schema.yml index 7e01885..975e804 100644 --- a/core/modules/datetime/config/schema/datetime.schema.yml +++ b/core/modules/datetime/config/schema/datetime.schema.yml @@ -34,7 +34,10 @@ field.formatter.settings.datetime_base: label: 'Timezone display' 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 diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php index aa700d2..5d1a090 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; @@ -33,6 +34,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 +61,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 +85,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') ); } @@ -85,6 +97,7 @@ public static function defaultSettings() { return [ 'timezone_display' => DateTimeItem::TIMEZONE_USER, 'timezone_override' => '', + 'timezone_per_date' => FALSE, ] + parent::defaultSettings(); } @@ -99,24 +112,18 @@ public function settingsForm(array $form, FormStateInterface $form_state) { $form['timezone_display'] = [ '#type' => 'select', '#title' => $this->t('Timezone display'), - '#description' => $this->t('The timezone to use when displaying this date.'), + '#description' => $this->t('The timezone to use by default when displaying this date.'), '#options' => [ - DateTimeItem::TIMEZONE_USER => $this->t("The user's timezone"), - DateTimeItem::TIMEZONE_NONE => $this->t("Timezone override"), + DateTimeItem::TIMEZONE_USER => $this->t("The user's preferred timezone"), + DateTimeItem::TIMEZONE_SITE => $this->t("The site's default timezone"), + DateTimeItem::TIMEZONE_NONE => $this->t("A specified timezone"), ], '#default_value' => $this->getSetting('timezone_display'), ]; - // If this field is using per-date timezone storage, add that as an - // option. - if ($this->fieldDefinition->getFieldStorageDefinition()->getSetting('timezone_handling') === DateTimeItem::TIMEZONE_DATE) { - $form['timezone_display']['#options'][DateTimeItem::TIMEZONE_DATE] = $this->t("The date's timezone"); - } - $form['timezone_override'] = [ '#type' => 'select', - '#title' => $this->t('Time zone override'), - '#description' => $this->t('The time zone selected here will always be used'), + '#title' => $this->t('Specify a timezone'), '#options' => system_time_zones(TRUE), '#default_value' => $this->getSetting('timezone_override'), '#states' => [ @@ -125,6 +132,18 @@ public function settingsForm(array $form, FormStateInterface $form_state) { ], ], ]; + + // If this field is using per-date timezone storage, give the option of + // allowing that to override the default. + if ($this->fieldDefinition->getFieldStorageDefinition()->getSetting('timezone_handling') === DateTimeItem::TIMEZONE_DATE) { + $form['timezone_per_date'] = [ + '#type' => 'checkbox', + '#title' => 'Use timezones from individual dates', + '#description' => 'Where a timezone has been specified for a particular date value, use that instead of the default selected above.', + '#default_value' => $this->getSetting('timezone_per_date'), + ]; + } + } return $form; @@ -140,11 +159,21 @@ public function settingsSummary() { $timezone_display = $this->getSetting('timezone_display'); $timezone_override = $this->getSetting('timezone_override'); if ($timezone_display === DateTimeItem::TIMEZONE_NONE && $timezone_override) { - $summary[] = $this->t('Time zone: @timezone', ['@timezone' => $timezone_override]); + $timezone = $timezone_override; + } + elseif ($timezone_display === DateTimeItem::TIMEZONE_SITE) { + $timezone = $this->t("the site's default timezone"); + } + else { + $timezone = $this->t("the user's preferred timezone"); + } + + if ($this->getSetting('timezone_per_date') === TRUE) { + $summary[] = $this->t('Use timezones from individual dates.'); + $summary[] = $this->t('Use @timezone if no individual timezone specified.', ['@timezone' => $timezone]); } else { - // @todo Make human-readable. - $summary[] = $this->t('Time zone display: @timezone', ['@timezone' => $timezone_display]); + $summary[] = $this->t('Timezone: @timezone.', ['@timezone' => $timezone]); } } @@ -208,12 +237,15 @@ protected function setTimeZone(DrupalDateTime $date, $date_instance_timezone = N else { $timezone_display = $this->getSetting('timezone_display'); $timezone_override = $this->getSetting('timezone_override'); - if ($timezone_display === DateTimeItem::TIMEZONE_DATE && !empty($date_instance_timezone)) { + if ($this->getSetting('timezone_per_date') === TRUE && !empty($date_instance_timezone)) { $timezone = $date_instance_timezone; } elseif ($timezone_display === DateTimeItem::TIMEZONE_NONE && $timezone_override) { $timezone = $timezone_override; } + elseif ($timezone_display === DateTimeItem::TIMEZONE_SITE) { + $timezone = $this->config->get('system.date')->get('timezone.default'); + } else { $timezone = drupal_get_user_timezone(); } diff --git a/core/modules/datetime/src/Tests/DateTimeFieldTest.php b/core/modules/datetime/src/Tests/DateTimeFieldTest.php index 545eda9..e10535a 100644 --- a/core/modules/datetime/src/Tests/DateTimeFieldTest.php +++ b/core/modules/datetime/src/Tests/DateTimeFieldTest.php @@ -12,6 +12,7 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\node\Entity\Node; +use Drupal\user\Entity\User; /** * Tests Datetime field functionality. @@ -28,6 +29,7 @@ class DateTimeFieldTest extends DateTestBase { protected $defaultSettings = [ 'timezone_display' => DateTimeItem::TIMEZONE_USER, 'timezone_override' => '', + 'timezone_per_date' => FALSE, ]; /** @@ -199,7 +201,7 @@ public function testDateField() { ->setComponent($field_name, $this->displayOptions) ->save(); $expected = SafeMarkup::format($this->displayOptions['settings']['future_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity'],]) + '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) ]); $this->renderTestEntity($id); $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); @@ -294,20 +296,6 @@ public function testDatetimeField() { $this->renderTestEntity($id); $this->assertText($expected, 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', - 'timezone_display' => DateTimeItem::TIMEZONE_NONE, - ] + $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']); - $this->renderTestEntity($id); - $this->assertText($expected, 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 @@ -355,33 +343,187 @@ public function testDatetimeField() { ]); $this->renderTestEntity($id); $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); - $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); - // Verify timezone display settings. + } + + /** + * 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' => 'America/Los_Angeles', + 'editor' => 'America/New_York', + 'input' => 'Europe/London', + 'viewer' => 'Europe/Moscow', + 'override'=> 'Asia/Tokyo', + 'php' => 'Pacific/Funafuti', + 'testbase' => date_default_timezone_get(), + ]; + + // 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(); + + // Scenario 1: Stored preference not enabled. + // Test the formatter when per-date timezone handling has not been set. + $this->fieldStorage->setSetting('datetime_type', 'datetime'); + $this->fieldStorage->setSetting('timezone_handling', DateTimeItem::TIMEZONE_NONE); + $this->fieldStorage->save(); + $id = $this->saveDatetime($timezones); + // A timezone is not yet set in the field, so pass FALSE. + $this->formatterSettingsTest('Stored preference not enabled', FALSE, $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 timezone. + $this->fieldStorage->setSetting('datetime_type', 'datetime'); $this->fieldStorage->setSetting('timezone_handling', DateTimeItem::TIMEZONE_DATE); $this->fieldStorage->save(); + // A timezone is still not yet in the field, so still pass FALSE. + $this->formatterSettingsTest('Stored preference unspecified', FALSE, $id, $timezones); + + // Scenario 3: Stored preference specified. + // Test the formatter on a new entity, which will now have a preferred + // timezone stored in its field. + $id = $this->saveDatetime($timezones); + // A timezone is now set in the field, so pass TRUE. + $this->formatterSettingsTest('Stored preference specified', TRUE, $id, $timezones); + + } + + /** + * Save a datetime on a test entity. + */ + protected function saveDatetime($timezones, $timezone_used = 'input', $timezone_user = 'editor', $dateString = '2012-10-15 17:25:00', $id = NULL){ + // Specify a different timezone for the editor, to confirm it doesn't leak. + $this->setLoggedInUserTimezone($timezones[$timezone_user]); + // Prepare the field values. + $date = new DrupalDateTime($dateString, 'UTC'); + $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' => 'America/New_York']), - "{$field_name}[0][value][time]" => $date->format($time_format, ['timezone' => 'America/New_York']), - "{$field_name}[0][value][timezone]" => 'America/New_York', + "{$field_name}[0][value][date]" => $date->format($date_format, ['timezone' => $timezones[$timezone_used]]), + "{$field_name}[0][value][time]" => $date->format($time_format, ['timezone' => $timezones[$timezone_used]]), ]; - $this->drupalPostForm('entity_test/add', $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + if ($this->fieldStorage->getSetting('timezone_handling') === DateTimeItem::TIMEZONE_DATE) { + $edit += [ + "{$field_name}[0][value][timezone]" => $timezones[$timezone_used], + ]; + } + + // Create an entity if no id has been supplied, else update the entity. + if (empty($id)) { + $this->drupalPostForm('entity_test/add', $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->url, $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + } + else { + $this->drupalPostForm("entity_test/$id/edit", $edit, t('Save')); + $this->assertText(t('entity_test @id has been updated.', ['@id' => $id])); + } + return $id; + } - $this->displayOptions['type'] = 'datetime_custom'; - $this->displayOptions['settings'] = [ - 'date_format' => 'm/d/Y g:i:s A e', - 'timezone_display' => DateTimeItem::TIMEZONE_DATE, - ] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $this->renderTestEntity($id); - $expected = $date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); - $this->assertText($expected); + /** + * Tests formatter output for different sets of settings. + */ + protected function formatterSettingsTest($scenario, $tz_set_for_date, $id, $timezones) { + // All the possible formatter timezone settings. + // @todo Reset config to default? Aettings from previous setup are still + // present in the config if they can't be overridden now in the UI, so tests + // are not isolated. + $settings = [ + 'per_date' => [ + 'set' => TRUE, + 'unset' => FALSE, + ], + 'display' => [ + 'viewer' => DateTimeItem::TIMEZONE_USER, + 'site' => DateTimeItem::TIMEZONE_SITE, + 'override' =>DateTimeItem::TIMEZONE_NONE, + ], + ]; + $this->setLoggedInUserTimezone($timezones['viewer']); + $field_name = $this->fieldStorage->getName(); + + foreach ($settings['per_date'] as $per_date_label => $per_date) { + foreach ($settings['display'] as $display_label => $display) { + + // Prepare the expectations. The timezone used should always be that set + // in the 'display' setting, unless the 'Use stored preference' setting + // is set AND a timezone is stored in the field for the date value being + // rendered. + if ($per_date && $tz_set_for_date) { + $expectedTimeSetting = 'input'; + } + else { + $expectedTimeSetting = array_search($display, $settings['display']); + } + + // Setup the formatter using the current iteration of the settings. + $output_format = 'm/d/Y g:i:s A e'; + $this->displayOptions['type'] = 'datetime_custom'; + $this->displayOptions['settings'] = [ + 'date_format' => $output_format, + 'timezone_display' => $display, + '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 value + $this->renderTestEntity($id); + $fieldSelector = 'div.field--type-datetime'; + $fieldSelected = $this->cssSelect($fieldSelector); + $this->assertTrue($fieldSelected, "Looking for $fieldSelector element"); + + if ($fieldSelected) { + // Extract the timezone from the rendered value + $actualText = trim((string) $fieldSelected[0]); + $actualTimeZone = DrupalDateTime::createFromFormat($output_format, $actualText) + ->getTimezone() + ->getName(); + // Lookup the logical source of that timezone + if (!$actualTimeSetting = array_search($actualTimeZone, $timezones)) { + $actualTimeSetting = $actualTimeZone; + } + // Compare that with the source expected to be in effect at this point + $settings_message = new FormattableMarkup("'Use stored preferences' @per_date and 'Default timezone' set to '@display'.", [ + '@per_date' => $per_date_label, + '@display' => $display_label, + ]); + $message = new FormattableMarkup("Time formatted using '@actual' timezone, expected to use '@expected' timezone in the '@scenario' scenario with the follwing settings: @settings_message.", [ + '@actual' => $actualTimeSetting, + '@expected' => $expectedTimeSetting, + '@scenario' => $scenario, + '@settings_messge' => $settings_message, + ]); + $this->assertIdentical($expectedTimeSetting, $actualTimeSetting, $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); } /**