custom_field-1.0.x-dev/src/Plugin/CustomField/FieldWidget/DateRangeWidgetBase.php
src/Plugin/CustomField/FieldWidget/DateRangeWidgetBase.php
<?php
declare(strict_types=1);
namespace Drupal\custom_field\Plugin\CustomField\FieldWidget;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\custom_field\Plugin\CustomField\FieldType\DateRangeType;
use Drupal\custom_field\Plugin\CustomField\FieldType\DateTimeType;
use Drupal\custom_field\Plugin\CustomField\FieldType\DateTimeTypeInterface;
use Drupal\custom_field\Plugin\CustomFieldTypeInterface;
use Drupal\custom_field\Trait\DateRangeTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base plugin class for daterange custom field widgets.
*/
class DateRangeWidgetBase extends DateTimeWidgetBase {
use DateRangeTrait;
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
$instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
$instance->entityTypeManager = $container->get('entity_type.manager');
return $instance;
}
/**
* {@inheritdoc}
*/
public static function defaultSettings(): array {
return [
'start_label' => 'Start date',
'end_label' => 'End date',
'year_range_end' => '1900:2050',
'all_day_checkbox' => FALSE,
'same_day_checkbox' => FALSE,
'date_end_required' => FALSE,
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function widgetSettingsForm(FormStateInterface $form_state, CustomFieldTypeInterface $field): array {
$element = parent::widgetSettingsForm($form_state, $field);
$settings = $this->getSettings() + static::defaultSettings();
$element['year_range']['#title'] = $this->t('Year range start');
$element['year_range_end'] = [
'#type' => 'custom_field_date_year_range',
'#title' => $this->t('Year range end'),
'#default_value' => $settings['year_range_end'],
];
$element['start_label'] = [
'#type' => 'textfield',
'#title' => $this->t('Start date label'),
'#default_value' => $settings['start_label'],
'#description' => $this->t('The label for the start date field.'),
'#required' => TRUE,
'#maxlength' => 255,
];
$element['end_label'] = [
'#type' => 'textfield',
'#title' => $this->t('End date label'),
'#default_value' => $settings['end_label'],
'#description' => $this->t('The label for the end date field.'),
'#required' => TRUE,
'#maxlength' => 255,
];
$element['date_end_required'] = [
'#type' => 'checkbox',
'#title' => $this->t('Require end date'),
'#description' => $this->t('If checked, the end date field will be required if the start date is provided.'),
'#default_value' => $settings['date_end_required'],
];
if ($field->getDatetimeType() === DateTimeType::DATETIME_TYPE_DATETIME) {
$element['all_day_checkbox'] = [
'#type' => 'checkbox',
'#title' => $this->t('All day checkbox'),
'#description' => $this->t('Provide a checkbox to make an event all day which will omit the time input elements.'),
'#default_value' => $settings['all_day_checkbox'],
];
$element['same_day_checkbox'] = [
'#type' => 'checkbox',
'#title' => $this->t('Same day checkbox'),
'#description' => $this->t('Provide a checkbox to make an event same day which will simplify input of the end date.'),
'#default_value' => $settings['same_day_checkbox'],
];
}
return $element;
}
/**
* {@inheritdoc}
*/
public function widget(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state, CustomFieldTypeInterface $field): array {
$element = parent::widget($items, $delta, $element, $form, $form_state, $field);
$item = $items[$delta];
$datetime_type = $field->getDatetimeType();
$field_settings = $field->getFieldSettings();
$settings = $this->getSettings() + static::defaultSettings();
$duration_enabled = !empty($field_settings['duration_enabled']);
$duration_options = $duration_enabled ? $field_settings['duration_options'] ?? [] : [];
$start_date = $form_state->getValue([...$element['#field_parents'], 'value']) ?? $item->{$field->getName() . '__start_date'};
$end_date = $form_state->getValue([...$element['#field_parents'], 'end_value']) ?? $item->{$field->getName() . '__end_date'};
$all_day = $element['all_day']['#default_value'] ?? FALSE;
$same_day = $element['same_day']['#default_value'] ?? FALSE;
$duration_value = NULL;
// Determine if we're showing seconds in the widget.
$show_seconds = !empty($field_settings['seconds_enabled']);
$date_element = $datetime_type === DateTimeType::DATETIME_TYPE_DATETIME ? 'datetime-local' : DateTimeType::DATETIME_TYPE_DATE;
$format = $datetime_type === DateTimeType::DATETIME_TYPE_DATETIME ? DateTimeTypeInterface::DATETIME_STORAGE_FORMAT : DateTimeTypeInterface::DATE_STORAGE_FORMAT;
$time_format = $show_seconds ? 'H:i:s' : 'H:i';
$values = $form_state->getValues();
$wrapper_id = $this->getUniqueElementId($form, $items->getName(), $delta, $field->getName());
$element['#prefix'] = '<div id="' . $wrapper_id . '">';
$element['#suffix'] = '</div>';
$element['#theme'] = 'custom_field_daterange';
$element['#theme_wrappers'] = ['fieldset', 'container'];
$element['#element_validate'][] = [$this, 'validateStartEnd'];
if ($datetime_type === DateTimeType::DATETIME_TYPE_DATETIME) {
if ($settings['all_day_checkbox']) {
$all_day = NestedArray::getValue($values, [...$element['#field_parents'], 'all_day']) ?? $this->isAllDay($start_date, $end_date);
}
if ($settings['same_day_checkbox']) {
$same_day = NestedArray::getValue($values, [...$element['#field_parents'], 'same_day']) ?? !$all_day && $this->isSameDay($start_date, $end_date);
}
if ($settings['all_day_checkbox']) {
$element['all_day'] = [
'#type' => 'checkbox',
'#title' => $this->t('All day'),
'#ajax' => [
'callback' => [$this, 'ajaxUpdateDaterange'],
'wrapper' => $wrapper_id,
],
'#default_value' => $all_day,
'#access' => !$same_day,
];
}
if ($settings['same_day_checkbox']) {
$element['same_day'] = [
'#type' => 'checkbox',
'#title' => $this->t('Same day'),
'#ajax' => [
'callback' => [$this, 'ajaxUpdateDaterange'],
'wrapper' => $wrapper_id,
],
'#default_value' => $same_day,
'#access' => !$all_day,
];
}
if ($all_day) {
$format = DateTimeTypeInterface::DATE_STORAGE_FORMAT;
// The timezone element is irrelevant for all-day date ranges.
if (isset($element['timezone'])) {
$element['timezone']['#access'] = FALSE;
}
}
$user_timezone = new \DateTimeZone(date_default_timezone_get());
$trigger = $form_state->getTriggeringElement();
$triggers = ['all_day', 'same_day'];
if ($trigger && in_array(end($trigger['#parents']), $triggers)) {
$active_trigger = end($trigger['#parents']);
$user_input = $form_state->getUserInput();
$change_input = FALSE;
if ($start_date instanceof DrupalDateTime) {
$start_date->setTimezone($user_timezone);
if ($active_trigger === 'all_day') {
// Reset the time to work with the datetime-local element.
if (!$all_day && $date_element === 'datetime-local') {
$start_date->setTime(0, 0);
}
NestedArray::setValue($user_input, [
...$element['#field_parents'],
'value',
'date',
], $start_date->format($format));
if ($end_date instanceof DrupalDateTime) {
/* @phpstan-ignore-next-line */
$end_date->setTimeZone($user_timezone);
// Reset the time to work with the datetime-local element.
if (!$all_day && $date_element === 'datetime-local') {
$end_date->setTime(23, 59);
}
NestedArray::setValue($user_input, [
...$element['#field_parents'],
'end_value',
'date',
], $end_date->format($format));
}
$change_input = TRUE;
}
if ($active_trigger === 'same_day') {
if ($same_day) {
NestedArray::setValue($user_input, [...$element['#field_parents'], 'end_value', 'date'], $start_date->format('Y-m-d'));
NestedArray::setValue($user_input, [...$element['#field_parents'], 'end_value', 'time'], NULL);
$change_input = TRUE;
}
}
}
if ($change_input) {
$form_state->setUserInput($user_input);
}
}
}
if (!empty($duration_options)) {
$duration_value = NestedArray::getValue($values, [...$element['#field_parents'], 'duration']) ?? $item->{$field->getName() . '__duration'};
// Calculate the duration from default value dates if available.
if (empty($duration_value) && $start_date instanceof DrupalDateTime && $end_date instanceof DrupalDateTime) {
$duration_calc = $end_date->getTimestamp() - $start_date->getTimestamp();
if ($duration_calc > 0) {
$duration_value = $duration_calc;
}
}
$options = [];
foreach ($duration_options as $option) {
$options[$option['key']] = $option['label'];
}
if (empty($duration_value) || !array_key_exists($duration_value, $options)) {
$duration_value = 'custom';
}
$options['custom'] = $this->t('Custom');
$element['duration'] = [
'#type' => 'select',
'#title' => $this->t('Duration'),
'#options' => $options,
'#default_value' => array_key_exists($duration_value, $options) ? $duration_value : 'custom',
'#ajax' => [
'callback' => [$this, 'ajaxUpdateDaterange'],
'wrapper' => $wrapper_id,
],
'#weight' => -9,
'#access' => !$all_day && !$same_day,
];
}
$element['value']['#title'] = $this->t('@label', ['@label' => $settings['start_label']]);
$element['value']['#description'] = NULL;
$element['value']['#type'] = 'custom_field_datetime_date';
$element['value']['#timezone_element'] = FALSE;
$element['value'] += [
'#date_date_element' => $all_day ? 'date' : $date_element,
'#date_date_format' => $format,
'#date_time_format' => '',
'#date_date_callbacks' => [],
'#date_time_element' => 'none',
];
if ($all_day) {
$element['value']['#date_timezone'] = DateTimeTypeInterface::STORAGE_TIMEZONE;
}
$element['end_value'] = [
'#title' => $this->t('@label', ['@label' => $settings['end_label']]),
'#default_value' => NULL,
'#date_year_range' => $this->getSetting('year_range_end'),
'#access' => !$duration_enabled || $duration_value === 'custom' || $all_day || $same_day,
] + $element['value'];
if ($same_day) {
$element['end_value']['#date_date_element'] = 'date';
$element['end_value']['#date_date_format'] = DateTimeTypeInterface::DATE_STORAGE_FORMAT;
$element['end_value']['#date_time_element'] = 'time';
$element['end_value']['#date_time_format'] = $time_format;
$element['end_value']['#same_day'] = TRUE;
}
if ($all_day && $start_date instanceof DrupalDateTime && $end_date instanceof DrupalDateTime) {
$start = clone $start_date;
$end = clone $end_date;
$start->setTimezone(timezone_open('UTC'));
$end->setTimezone(timezone_open('UTC'));
$start->setTime(0, 0);
$diff = $start->diff($end);
$start_date_str = $start->format('Y-m-d');
$end_date_str = $end->format('Y-m-d');
$days = floor(($end->getTimestamp() - $start->getTimestamp()) / 86400);
if ($start_date_str === $end_date_str) {
$days = 0;
}
elseif ($diff->h === 23 && $diff->i === 59) {
$days = max(1, $days - 1);
}
$end = clone $start;
if ($days >= 0) {
$end->add(new \DateInterval("P{$days}D"));
}
$element['value']['#default_value'] = $start;
$element['end_value']['#default_value'] = $end;
}
if (!$all_day && $start_date instanceof DrupalDateTime) {
$timezone = (string) $element['value']['#date_timezone'];
$element['value']['#default_value'] = $this->createDefaultValue($start_date, $timezone, $datetime_type, $show_seconds);
}
if (!$all_day && $end_date instanceof DrupalDateTime) {
$timezone = (string) $element['end_value']['#date_timezone'];
if ((!$duration_enabled || $duration_value === 'custom' || $same_day)) {
$element['end_value']['#default_value'] = $this->createDefaultValue($end_date, $timezone, $datetime_type, $show_seconds);
}
}
if (isset($element['timezone'])) {
$element['timezone']['#weight'] = 10;
}
return $element;
}
/**
* {@inheritdoc}
*/
public function massageFormValue(mixed $value, $column): mixed {
// 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.
$datetime_type = $column['datetime_type'];
$storage_format = $datetime_type === 'date' ? DateTimeTypeInterface::DATE_STORAGE_FORMAT : DateTimeTypeInterface::DATETIME_STORAGE_FORMAT;
$storage_timezone = timezone_open(DateTimeTypeInterface::STORAGE_TIMEZONE);
$user_timezone = timezone_open(date_default_timezone_get());
$all_day_checkbox = $this->getSetting('all_day_checkbox');
if (!is_array($value)) {
return NULL;
}
$start_date = $value['value'] ?? NULL;
$end_date = $value['end_value'] ?? NULL;
$all_day = $value['all_day'] ?? FALSE;
$same_day = $value['same_day'] ?? FALSE;
$duration = $value['duration'] ?? NULL;
if (!$start_date instanceof DrupalDateTime) {
return NULL;
}
if ($all_day_checkbox && !$all_day && $end_date instanceof DrupalDateTime && $datetime_type === DateTimeType::DATETIME_TYPE_DATETIME) {
$start = clone $start_date;
$end = clone $end_date;
$start->setTimezone($user_timezone);
$end->setTimezone($user_timezone);
$diff = $start->diff($end);
if ($diff->h === 23 && $diff->i === 59) {
$days = floor(($start->getTimestamp() - $end->getTimestamp()) / 86400);
$start_date = DrupalDateTime::createFromFormat(
$storage_format,
$start_date->format(DateTimeTypeInterface::DATE_STORAGE_FORMAT) . 'T00:00:00',
$storage_timezone
);
$end_date = DrupalDateTime::createFromFormat(
$storage_format,
$end_date->format(DateTimeTypeInterface::DATE_STORAGE_FORMAT) . 'T23:59:59',
$storage_timezone
);
if ($days > 0) {
$end_date->add(new \DateInterval("P{$days}D"));
}
}
}
if ($datetime_type === DateRangeType::DATETIME_TYPE_ALLDAY || $all_day) {
$user_timezone = $storage_timezone;
// 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.
/* @phpstan-ignore-next-line */
$start_date->setTimeZone($user_timezone)->setTime(0, 0);
if ($end_date instanceof DrupalDateTime) {
/* @phpstan-ignore-next-line */
$end_date->setTimeZone($user_timezone)->setTime(23, 59, 59);
}
}
elseif (!$same_day && is_numeric($duration) && $duration > 0) {
$end_date = clone $start_date;
try {
$interval = new \DateInterval('PT' . $duration . 'S');
$end_date->add($interval);
}
catch (\Exception $e) {
$end_date = NULL;
}
}
// Adjust the date for storage.
$value['value'] = $start_date->setTimezone($storage_timezone)->format($storage_format);
if ($end_date instanceof DrupalDateTime) {
$value['end_value'] = $end_date->setTimezone($storage_timezone)->format($storage_format);
}
return $value;
}
/**
* An #element_validate callback to ensure the start date <= the end date.
*
* @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 validateStartEnd(array &$element, FormStateInterface $form_state, array &$complete_form): void {
// Only validate if the start and end date fields are present.
$require_end_date = $this->getSetting('date_end_required');
if (!isset($element['end_value']) || !$element['end_value']['#access']) {
return;
}
$start_date = $element['value']['#value']['object'];
$end_date = $element['end_value']['#value']['object'];
$same_day = $element['same_day']['#value'] ?? FALSE;
$all_day = $element['all_day']['#value'] ?? FALSE;
// Validate the end date is after the start date.
if ($start_date instanceof DrupalDateTime && $end_date instanceof DrupalDateTime) {
$start_timestamp = $start_date->getTimestamp();
$end_timestamp = $end_date->getTimestamp();
if (!$all_day) {
if ($start_timestamp >= $end_timestamp) {
if ($same_day) {
$error = $this->t('The @title end time must be after the start time', ['@title' => $element['#title']]);
}
else {
$error = $this->t('The @title end date must be after the start date', ['@title' => $element['#title']]);
}
$form_state->setError($element['end_value'], $error);
}
}
elseif ($start_timestamp !== $end_timestamp) {
$interval = $start_date->diff($end_date);
if ($interval->invert === 1) {
$form_state->setError($element['end_value'], $this->t('The @title end date cannot be before the start date', ['@title' => $element['#title']]));
}
}
}
// Validate the end date is required.
elseif ($start_date instanceof DrupalDateTime && $require_end_date && !$end_date instanceof DrupalDateTime) {
$form_state->setError($element['end_value'], $this->t('The @title end date is required.', ['@title' => $element['#title']]));
}
// Validate the end date requires a start date.
elseif ($end_date instanceof DrupalDateTime && !$start_date instanceof DrupalDateTime) {
$form_state->setError($element['value'], $this->t('The @title cannot have an end date with no start date', ['@title' => $element['#title']]));
}
}
/**
* Ajax callback to trigger daterange changes.
*
* @param array $form
* The form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The AJAX response.
*/
public function ajaxUpdateDaterange(array &$form, FormStateInterface $form_state): AjaxResponse {
$triggering_element = $form_state->getTriggeringElement();
$value = $triggering_element['#value'];
$wrapper_id = $triggering_element['#ajax']['wrapper'];
$array_parents = $triggering_element['#array_parents'];
$key = end($array_parents);
$form_state_keys = array_slice($array_parents, 0, -1);
$updated_element = NestedArray::getValue($form, $form_state_keys);
$response = new AjaxResponse();
$response->addCommand(new ReplaceCommand('#' . $wrapper_id, $updated_element));
if ($key === 'duration' && $value === 'custom' || $key === 'all_day' && $value === TRUE) {
$focus_input = $updated_element['end_value']['date']['#name'];
$response->addCommand(new InvokeCommand(':input[name="' . $focus_input . '"]', 'focus'));
}
return $response;
}
}
