date_recur-8.x-2.2/src/Plugin/Field/FieldWidget/DateRecurBasicWidget.php
src/Plugin/Field/FieldWidget/DateRecurBasicWidget.php
<?php
declare(strict_types=1);
namespace Drupal\date_recur\Plugin\Field\FieldWidget;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Datetime\Element\Datetime;
use Drupal\Core\Datetime\TimeZoneFormHelper;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\date_recur\DateRecurHelper;
use Drupal\date_recur\Plugin\Field\FieldType\DateRecurFieldItemList;
use Drupal\date_recur\Plugin\Field\FieldType\DateRecurItem;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem;
use Drupal\datetime_range\Plugin\Field\FieldWidget\DateRangeDefaultWidget;
/**
* Basic RRULE widget.
*
* Displays an input textarea accepting RRULE strings.
*
* @FieldWidget(
* id = "date_recur_basic_widget",
* label = @Translation("Simple Recurring Date Widget"),
* field_types = {
* "date_recur"
* }
* )
*/
class DateRecurBasicWidget extends DateRangeDefaultWidget {
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state): array {
assert($items instanceof DateRecurFieldItemList);
$element = parent::formElement($items, $delta, $element, $form, $form_state);
$element['#theme'] = 'date_recur_basic_widget';
$element['#element_validate'][] = [$this, 'validateRrule'];
// ::createDefaultValue isn't given enough context about the field item, so
// override its functions here.
$element['value']['#default_value'] = $element['end_value']['#default_value'] = NULL;
$element['value']['#date_timezone'] = $element['end_value']['#date_timezone'] = NULL;
$this->createDateRecurDefaultValue($element, $items[$delta]);
// Move fields into a first occurrence container as 'End date' can be
// confused with 'End date' RRULE concept.
$element['first_occurrence'] = [
'#type' => 'fieldset',
'#title' => $this->t('First occurrence'),
// Needs a weight otherwise children do not show up within single
// cardinality widgets.
'#weight' => 0,
];
$firstOccurrenceParents = [
...$element['#field_parents'],
$this->fieldDefinition->getName(),
$delta,
'first_occurrence',
];
$element['value']['#title'] = $this->t('Start');
$element['end_value']['#title'] = $this->t('End');
$element['end_value']['#description'] = $this->t('Leave end empty to copy start date; the occurrence will therefore not have any duration.');
// The end date is never required. Start date is copied over if end date is
// empty.
$element['end_value']['#required'] = FALSE;
$element['value']['#group'] = $element['end_value']['#group'] = implode('][', $firstOccurrenceParents);
// Add custom value callbacks to correctly form a date from time zone field.
// @codingStandardsIgnoreLine
$element['value']['#value_callback'] = $element['end_value']['#value_callback'] = [$this, 'dateValueCallback'];
// Replace \Datetime::validateDatetime validator with our own.
$element['value']['#element_validate'] = $element['end_value']['#element_validate'] = [[$this, 'validateDatetime']];
// Saved values (should) always have a time zone.
$timeZone = $items[$delta]->timezone ?? NULL;
$zones = $this->getTimeZoneOptions();
$element['timezone'] = [
'#type' => 'select',
'#title' => $this->t('Time zone'),
'#default_value' => $timeZone,
'#options' => $zones,
];
$element['rrule'] = [
'#type' => 'textarea',
'#default_value' => $items[$delta]->rrule ?? NULL,
'#title' => $this->t('Repeat rule'),
'#description' => $this->t('Repeat rule in <a href=":link">iCalendar Recurrence Rule</a> (RRULE) format.', [
':link' => 'https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html',
]),
'#access' => $items->getPartGrid()->isRecurringAllowed(),
];
return $element;
}
/**
* Validator for start and end elements.
*
* Sets the time zone before datetime element processes values.
*
* @param array $element
* An associative array containing the properties and children of the
* generic form element.
* @param array|false $input
* Input, if any.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return array
* The value to assign to the element.
*/
public function dateValueCallback(array &$element, $input, FormStateInterface $form_state): array {
if ($input !== FALSE) {
$timeZonePath = array_slice($element['#parents'], 0, -1);
$timeZonePath[] = 'timezone';
// Warning: The time zone is not yet validated, make sure it is valid
// before using.
/** @var string|null $submittedTimeZone */
$submittedTimeZone = NestedArray::getValue($form_state->getUserInput(), $timeZonePath);
if (!isset($submittedTimeZone)) {
// If no time zone was submitted, such as when the 'timezone' field is
// set to #access => FALSE, its necessary to fall back to the fields
// default value.
$timeZoneFieldPath = array_slice($element['#array_parents'], 0, -1);
$timeZoneFieldPath[] = 'timezone';
$timeZoneField = NestedArray::getValue($form_state->getCompleteForm(), $timeZoneFieldPath);
$submittedTimeZone = $timeZoneField['#value'] ?? ($timeZoneField['#default_value'] ?? NULL);
}
$allTimeZones = \DateTimeZone::listIdentifiers();
// @todo Add test for invalid submitted time zone.
if (!in_array($submittedTimeZone, $allTimeZones, TRUE)) {
// A date is invalid if the time zone is invalid.
// Need to kill inputs otherwise
// \Drupal\Core\Datetime\Element\Datetime::validateDatetime thinks there
// is valid input.
// Indicate to validator the value could not be built since time zone
// was invalid combined with a provided non-empty start or end date.
// This key/value is internal and may be modified at any time.
$element['#date_recur_basic_widget__invalid_timezone'] = TRUE;
return [
// Restore the inputs' previous values.
'date' => $input['date'],
'time' => $input['time'],
// Marking object as NULL indicates this field is invalid, see
// \Drupal\Core\Datetime\Element\Datetime::processDatetime.
'object' => NULL,
];
}
$element['#date_timezone'] = $submittedTimeZone;
}
// Setting a callback overrides default value callback in the element,
// call original now.
return Datetime::valueCallback($element, $input, $form_state);
}
/**
* Validates start and end date field.
*
* If a time zone was not provided then its not necessary to validate start
* and end date values if they are non-empty.
*
* @param array $element
* An associative array containing the properties and children of the
* generic form element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param array $complete_form
* The complete form structure.
*/
public function validateDatetime(array &$element, FormStateInterface $form_state, array &$complete_form): void {
$input_exists = FALSE;
$input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists);
if ($input_exists) {
if ((!empty($input['date']) || !empty($input['time'])) && isset($element['#date_recur_basic_widget__invalid_timezone'])) {
$timeZoneFieldPath = array_slice($element['#array_parents'], 0, -1);
$timeZoneFieldPath[] = 'timezone';
$timeZoneField = NestedArray::getValue($form_state->getCompleteForm(), $timeZoneFieldPath);
$form_state->setError($timeZoneField, $this->t('Missing time zone for date.'));
return;
}
}
Datetime::validateDatetime($element, $form_state, $complete_form);
}
/**
* Validates RRULE and first occurrence dates.
*
* @param array $element
* An associative array containing the properties and children of the
* generic form element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param array $complete_form
* The complete form structure.
*/
public function validateRrule(array &$element, FormStateInterface $form_state, array &$complete_form): void {
$input = NestedArray::getValue($form_state->getValues(), $element['#parents']);
/** @var \Drupal\Core\Datetime\DrupalDateTime|array|null $startDate */
$startDate = $input['value'];
/** @var \Drupal\Core\Datetime\DrupalDateTime|array|null $startDateEnd */
$startDateEnd = $input['end_value'];
if (is_array($startDate) || is_array($startDateEnd)) {
// Dates are an array if invalid input was submitted (e.g date:
// 80616-02-01).
return;
}
/** @var string $rrule */
$rrule = $input['rrule'];
if ($startDateEnd && !isset($startDate)) {
$form_state->setError($element['value'], (string) $this->t('Start date must be set if end date is set.'));
}
// If end was empty, copy start date over.
if (!isset($startDateEnd) && $startDate) {
$form_state->setValueForElement($element['end_value'], $startDate);
$startDateEnd = $startDate;
}
// Validate RRULE.
// Only ensure start date is set, as end date is optional.
if (strlen($rrule) > 0 && $startDate) {
try {
DateRecurHelper::create(
$rrule,
$startDate->getPhpDateTime(),
$startDateEnd?->getPhpDateTime(),
);
}
catch (\Exception) {
$form_state->setError($element['rrule'], (string) $this->t('Repeat rule is formatted incorrectly.'));
}
}
}
/**
* Get a list of time zones suitable for a select field.
*
* @return array
* A list of time zones where keys are PHP time zone codes, and values are
* human readable and translatable labels.
*/
protected function getTimeZoneOptions(): array {
return TimeZoneFormHelper::getOptionsListByRegion(TRUE);
}
/**
* Get the current users time zone.
*
* @return string
* A PHP time zone string.
*/
protected function getCurrentUserTimeZone(): string {
return \date_default_timezone_get();
}
/**
* {@inheritdoc}
*/
protected function createDefaultValue($date, $timezone): DrupalDateTime {
assert($date instanceof DrupalDateTime);
assert(is_string($timezone));
// Cannot set time zone here as field item contains time zone.
if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) {
$date->setDefaultDateTime();
}
return $date;
}
/**
* Set element default value and time zone.
*
* @param array $element
* The element.
* @param \Drupal\date_recur\Plugin\Field\FieldType\DateRecurItem $item
* The date recur field item.
*/
protected function createDateRecurDefaultValue(array &$element, DateRecurItem $item): void {
$startDate = $item->start_date;
$startDateEnd = $item->end_date;
$timeZone = isset($item->timezone) ? new \DateTimeZone($item->timezone) : NULL;
if ($timeZone) {
$element['value']['#date_timezone'] = $element['end_value']['#date_timezone'] = $timeZone->getName();
if ($startDate) {
$startDate->setTimezone($timeZone);
$element['value']['#default_value'] = $this->createDefaultValue($startDate, $timeZone->getName());
}
if ($startDateEnd) {
$startDateEnd->setTimezone($timeZone);
$element['end_value']['#default_value'] = $this->createDefaultValue($startDateEnd, $timeZone->getName());
}
}
}
}
