diff --git a/core/modules/datetime/config/schema/datetime.views.schema.yml b/core/modules/datetime/config/schema/datetime.views.schema.yml index 257ea3b34eaf32e5aecd6e7129a1d814af5ca37b..192f729e36c603c41876d8f472b2706fc03f89c0 100644 --- a/core/modules/datetime/config/schema/datetime.views.schema.yml +++ b/core/modules/datetime/config/schema/datetime.views.schema.yml @@ -21,6 +21,10 @@ views.argument.datetime_year_month: views.filter.datetime: type: views.filter.date + mapping: + granularity: + type: string + label: 'Granularity' views.filter_value.datetime: type: views.filter_value.date diff --git a/core/modules/datetime/src/Plugin/views/filter/Date.php b/core/modules/datetime/src/Plugin/views/filter/Date.php index d401917b626bdfbba685ce90f697b9542b2a364c..67e70024832e934cc50c216c433c767f2d724d19 100644 --- a/core/modules/datetime/src/Plugin/views/filter/Date.php +++ b/core/modules/datetime/src/Plugin/views/filter/Date.php @@ -58,6 +58,20 @@ class Date extends NumericDate implements ContainerFactoryPluginInterface { */ protected $requestStack; + /** + * Mapping of granularity values to their corresponding date formats. + * + * @var array + */ + protected $dateFormats = [ + 'second' => 'Y-m-d\TH:i:s', + 'minute' => 'Y-m-d\TH:i', + 'hour' => 'Y-m-d\TH', + 'day' => 'Y-m-d', + 'month' => 'Y-m', + 'year' => 'Y', + ]; + /** * Constructs a new Date handler. * @@ -100,6 +114,61 @@ public static function create(ContainerInterface $container, array $configuratio ); } + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state): void { + parent::buildOptionsForm($form, $form_state); + + $options = []; + // Only show granularity options for times if the field supports a time. + if ($this->fieldStorageDefinition->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATETIME) { + $options = [ + 'second' => $this->t('Second'), + 'minute' => $this->t('Minute'), + 'hour' => $this->t('Hour'), + ]; + } + // All fields (datetime or date-only) will need these options. + $options['day'] = $this->t('Day'); + $options['month'] = $this->t('Month'); + $options['year'] = $this->t('Year'); + + $form['granularity'] = [ + '#type' => 'radios', + '#title' => $this->t('Granularity'), + '#options' => $options, + '#description' => $this->t('The granularity is the smallest unit to use when determining whether two dates are the same; for example, if the granularity is "Year" then all dates in 1999, regardless of when they fall in 1999, will be considered the same date.'), + '#default_value' => $this->options['granularity'], + ]; + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + + $options['granularity'] = [ + // The default depends on if the field is date-only or includes time. + 'default' => $this->fieldStorageDefinition->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATETIME ? 'second' : 'day', + ]; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function query(): void { + // Set the date format based on granularity. + if (isset($this->dateFormats[$this->options['granularity']])) { + $this->dateFormat = $this->dateFormats[$this->options['granularity']]; + } + + parent::query(); + } + /** * {@inheritdoc} */ @@ -159,12 +228,20 @@ protected function opBetween($field) { // Although both 'min' and 'max' values are required, default empty 'min' // value as UNIX timestamp 0. $min = (!empty($this->value['min'])) ? $this->value['min'] : '@0'; + $max = $this->value['max']; + + // If year granularity is specified, then suffix with month and day to + // force DateTimePlus to treat the input as a proper date value. + if ($this->options['granularity'] == 'year') { + $min = preg_replace('/^(\d{4})$/', '$1-01-01', $min); + $max = preg_replace('/^(\d{4})$/', '$1-01-01', $max); + } // Convert to ISO format and format for query. UTC timezone is used since // dates are stored in UTC. $a = new DateTimePlus($min, new \DateTimeZone($timezone)); $a = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($a->getTimestamp() + $origin_offset, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $this->calculateOffset), $this->dateFormat, TRUE); - $b = new DateTimePlus($this->value['max'], new \DateTimeZone($timezone)); + $b = new DateTimePlus($max, new \DateTimeZone($timezone)); $b = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($b->getTimestamp() + $origin_offset, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $this->calculateOffset), $this->dateFormat, TRUE); // This is safe because we are manually scrubbing the values. @@ -180,10 +257,32 @@ protected function opSimple($field) { $timezone = $this->getTimezone(); $origin_offset = $this->getOffset($this->value['value'], $timezone); - // Convert to ISO. UTC timezone is used since dates are stored in UTC. - $value = new DateTimePlus($this->value['value'], new \DateTimeZone($timezone)); - $value = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($value->getTimestamp() + $origin_offset, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $this->calculateOffset), $this->dateFormat, TRUE); + // If year granularity is specified, then suffix with month and day to + // force DateTimePlus to treat the input as a proper date value. + $date_value = $this->value['value']; + if ($this->options['granularity'] === 'year') { + $date_value = preg_replace('/^(\d{4})$/', '$1-01-01', $date_value); + $this->calculateOffset = FALSE; + } + if ($this->options['granularity'] === 'month') { + $date_value = preg_replace('/^([0-1]?[0-9]?)$/', '0000-$1-01', $date_value); + $this->calculateOffset = FALSE; + } + + // Does the value supplied by the filter need to have timezone offsets + // applied? If we've got a datetime field but are using day granularity, we + // want to convert the stored field values, but *not* the supplied filter + // value. That way, since dates are always stored in UTC, if that's not the + // same as the user's TZ or the default site-wide TZ, the query will find + // values that match whatever is being requested. If we also convert the + // filter value to UTC, we won't find date-only matches if the current time + // is a different date in UTC. + $value_needs_offset = $this->options['granularity'] === 'day' ? FALSE : $this->calculateOffset; + + // Convert to ISO. UTC timezone is used since dates are stored in UTC. + $value = new DateTimePlus($date_value, new \DateTimeZone($timezone)); + $value = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($value->getTimestamp() + $origin_offset, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, $timezone) . "'", TRUE, $value_needs_offset), $this->dateFormat, TRUE); // This is safe because we are manually scrubbing the value. $field = $this->query->getDateFormat($this->query->getDateField($field, TRUE, $this->calculateOffset), $this->dateFormat, TRUE); $this->query->addWhereExpression($this->options['group'], "$field $this->operator $value"); diff --git a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php index 677d216bab62c6ece4eb636c25b6909b08c0d53b..e5cfdcaf7d3c3ee76baf81fcc03d9fa8c514ee20 100644 --- a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php +++ b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php @@ -55,7 +55,7 @@ protected function setUp($import_test_views = TRUE): void { '2002-10-10T14:14:14', // The date storage timezone is used (this mimics the steps taken in the // widget: \Drupal\datetime\Plugin\Field\FieldWidget::messageFormValues(). - \Drupal::service('date.formatter')->format(static::$date, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE), + \Drupal::service('date.formatter')->format(static::$date, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, static::$timezone), ]; foreach ($dates as $date) { $node = Node::create([