countdown-8.x-1.8/modules/countdown_field/src/Plugin/Field/FieldWidget/CountdownWidget.php
modules/countdown_field/src/Plugin/Field/FieldWidget/CountdownWidget.php
<?php
declare(strict_types=1);
namespace Drupal\countdown_field\Plugin\Field\FieldWidget;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\countdown\CountdownLibraryPluginManager;
use Drupal\countdown\Service\CountdownLibraryManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'countdown_default' widget.
*
* This widget provides a datetime selector with library configuration for
* countdown timers. It delegates library-specific configuration to the plugin
* system to avoid code duplication.
*
* @FieldWidget(
* id = "countdown_default",
* label = @Translation("Countdown selector"),
* field_types = {
* "countdown"
* }
* )
*/
class CountdownWidget extends WidgetBase implements ContainerFactoryPluginInterface {
use StringTranslationTrait;
/**
* The countdown library manager.
*
* @var \Drupal\countdown\Service\CountdownLibraryManagerInterface
*/
protected CountdownLibraryManagerInterface $libraryManager;
/**
* The countdown library plugin manager.
*
* @var \Drupal\countdown\CountdownLibraryPluginManager
*/
protected CountdownLibraryPluginManager $pluginManager;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected ConfigFactoryInterface $configFactory;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountProxyInterface
*/
protected AccountProxyInterface $currentUser;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* Constructs a CountdownWidget object.
*
* @param string $plugin_id
* The plugin_id for the widget.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The definition of the field to which the widget is associated.
* @param array $settings
* The widget settings.
* @param array $third_party_settings
* Any third party settings.
* @param \Drupal\countdown\Service\CountdownLibraryManagerInterface $library_manager
* The countdown library manager.
* @param \Drupal\countdown\CountdownLibraryPluginManager $plugin_manager
* The countdown library plugin manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Session\AccountProxyInterface $current_user
* The current user.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(
$plugin_id,
$plugin_definition,
FieldDefinitionInterface $field_definition,
array $settings,
array $third_party_settings,
CountdownLibraryManagerInterface $library_manager,
CountdownLibraryPluginManager $plugin_manager,
ConfigFactoryInterface $config_factory,
AccountProxyInterface $current_user,
EntityTypeManagerInterface $entity_type_manager,
) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
$this->libraryManager = $library_manager;
$this->pluginManager = $plugin_manager;
$this->configFactory = $config_factory;
$this->currentUser = $current_user;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@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('countdown.library_manager'),
$container->get('plugin.manager.countdown_library'),
$container->get('config.factory'),
$container->get('current_user'),
$container->get('entity_type.manager'),
);
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'show_preview' => TRUE,
'inline_labels' => FALSE,
'timezone_handling' => 'site',
'method_override' => FALSE,
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$elements = parent::settingsForm($form, $form_state);
$elements['show_preview'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show preview'),
'#description' => $this->t('Display a live preview of the countdown timer.'),
'#default_value' => $this->getSetting('show_preview'),
];
$elements['inline_labels'] = [
'#type' => 'checkbox',
'#title' => $this->t('Inline labels'),
'#description' => $this->t('Display form labels inline for a more compact layout.'),
'#default_value' => $this->getSetting('inline_labels'),
];
$elements['timezone_handling'] = [
'#type' => 'select',
'#title' => $this->t('Timezone handling'),
'#options' => [
'site' => $this->t('Site timezone'),
'user' => $this->t('User timezone'),
'utc' => $this->t('UTC'),
],
'#default_value' => $this->getSetting('timezone_handling'),
'#description' => $this->t('Timezone for date input and display.'),
];
$elements['method_override'] = [
'#type' => 'checkbox',
'#title' => $this->t('Allow loading method override'),
'#description' => $this->t('Allow users to override the global loading method (local/CDN).'),
'#default_value' => $this->getSetting('method_override'),
];
return $elements;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = parent::settingsSummary();
if ($this->getSetting('show_preview')) {
$summary[] = $this->t('Preview enabled');
}
if ($this->getSetting('inline_labels')) {
$summary[] = $this->t('Inline labels');
}
$timezone_options = [
'site' => $this->t('Site timezone'),
'user' => $this->t('User timezone'),
'utc' => $this->t('UTC'),
];
$timezone = $this->getSetting('timezone_handling');
$summary[] = $this->t('Timezone: @timezone', ['@timezone' => $timezone_options[$timezone]]);
if ($this->getSetting('method_override')) {
$summary[] = $this->t('Method override allowed');
}
return $summary;
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
/** @var \Drupal\countdown_field\Plugin\Field\FieldType\CountdownItem $item */
$item = $items[$delta];
// Get the current loading method.
$method = $this->libraryManager->getLoadingMethod();
// Get allowed libraries for this field.
$allowed_libraries = $item->getAllowedLibraries();
$library_options = $this->getFilteredLibraryOptions($method, $allowed_libraries);
// Check if we have any libraries available.
if (empty($library_options)) {
$element['no_libraries'] = [
'#markup' => '<div class="messages messages--warning">' . $this->t('No countdown libraries are available. Please configure the Countdown module.') . '</div>',
];
return $element;
}
// Generate unique wrapper ID for AJAX.
$wrapper_id = 'countdown-widget-' . $delta . '-wrapper';
// Build the main element container.
$element['#type'] = 'details';
$element['#open'] = TRUE;
$element['#attributes'] = [
'id' => $wrapper_id,
'data-countdown-type' => 'field',
'data-field-name' => $this->fieldDefinition->getName(),
];
// Get the selected library with proper fallback to default.
$selected_library = $this->getFormValue($form_state, $items, $delta, 'library');
// Ensure we have a valid library selected for initial load.
if (empty($selected_library) || !isset($library_options[$selected_library])) {
// Try to get the default library from field storage settings.
$default_library = $this->fieldDefinition
->getFieldStorageDefinition()
->getSetting('default_library');
// If no field default, use global default.
if (empty($default_library)) {
$default_library = $this->libraryManager->getActiveLibrary();
}
// Validate the default library is available.
if (isset($library_options[$default_library])) {
$selected_library = $default_library;
}
else {
// Fall back to first available library.
$selected_library = key($library_options);
}
}
// Library selection.
$element['library'] = [
'#type' => 'select',
'#title' => $this->t('Countdown Library'),
'#options' => $library_options,
'#default_value' => $selected_library,
'#required' => $this->fieldDefinition->isRequired(),
'#ajax' => [
'callback' => [$this, 'ajaxRebuildSettings'],
'wrapper' => $wrapper_id,
'progress' => [
'type' => 'throbber',
'message' => $this->t('Loading library settings...'),
],
],
];
// Method override if allowed.
if ($this->getSetting('method_override')) {
$element['method'] = [
'#type' => 'radios',
'#title' => $this->t('Loading Method'),
'#options' => [
'local' => $this->t('Local'),
'cdn' => $this->t('CDN'),
],
'#default_value' => $item->method ?? $method,
];
}
// Library-specific settings wrapper.
$element['library_settings_wrapper'] = [
'#type' => 'container',
'#tree' => TRUE,
];
// Always build library settings if we have a selected library.
if ($selected_library && isset($library_options[$selected_library])) {
$this->buildLibrarySettings(
$element['library_settings_wrapper'],
$form_state,
$items,
$delta,
$selected_library
);
}
// Event details if allowed.
if ($item->getFieldDefinition()->getSetting('allow_event_details')) {
$element['event_details'] = [
'#type' => 'details',
'#title' => $this->t('Event Details'),
'#open' => FALSE,
];
$element['event_details']['event_name'] = [
'#type' => 'textfield',
'#title' => $this->t('Event Name'),
'#default_value' => $item->event_name ?? '',
'#maxlength' => 255,
];
$element['event_details']['event_url'] = [
'#type' => 'url',
'#title' => $this->t('Event URL'),
'#default_value' => $item->event_url ?? '',
];
}
// Preview container if enabled.
if ($this->getSetting('show_preview')) {
$element['preview'] = [
'#type' => 'container',
'#attributes' => [
'class' => ['countdown-widget-preview'],
'id' => 'countdown-preview-' . $delta,
],
'#weight' => 100,
];
$element['preview']['label'] = [
'#markup' => '<div class="countdown-widget-preview-label">' . $this->t('Preview') . '</div>',
];
$element['preview']['content'] = [
'#type' => 'container',
'#attributes' => [
'class' => ['countdown-preview-container'],
'data-countdown-preview' => 'true',
],
];
}
// Attach widget library.
$element['#attached']['library'][] = 'countdown_field/widget';
// Attach preview settings using unified namespace.
if ($this->getSetting('show_preview') && $selected_library) {
$preview_id = 'preview-' . $delta;
$element['#attached']['drupalSettings']['countdown']['previews'][$preview_id] = [
'type' => 'preview',
'library' => $selected_library,
'target' => '+1 month',
'settings' => [],
];
}
return $element;
}
/**
* AJAX callback to rebuild the widget.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return array
* The updated form element.
*/
public function ajaxRebuildSettings(array &$form, FormStateInterface $form_state) {
$trigger = $form_state->getTriggeringElement();
$parents = array_slice($trigger['#array_parents'], 0, -1);
return NestedArray::getValue($form, $parents);
}
/**
* Get the current form value during AJAX rebuilds.
*
* This method checks for user input during AJAX operations to maintain
* state between library changes.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param \Drupal\Core\Field\FieldItemListInterface $items
* The field items.
* @param int $delta
* The field delta.
* @param string $key
* The configuration key to retrieve.
*
* @return mixed
* The current form value or NULL.
*/
protected function getFormValue(FormStateInterface $form_state, FieldItemListInterface $items, int $delta, string $key) {
// Check user input for AJAX rebuilds.
$user_input = $form_state->getUserInput();
$field_name = $this->fieldDefinition->getName();
// Map configuration keys to form structure.
$input_map = [
'library' => [$field_name, $delta, 'library'],
];
if (isset($input_map[$key])) {
$value = $user_input;
foreach ($input_map[$key] as $part) {
if (isset($value[$part])) {
$value = $value[$part];
}
else {
$value = NULL;
break;
}
}
if ($value !== NULL) {
return $value;
}
}
// Fall back to item value.
if (isset($items[$delta])) {
$item_value = $items[$delta]->$key ?? NULL;
if ($item_value !== NULL) {
return $item_value;
}
}
// For library key specifically, add fallback to default library.
if ($key === 'library') {
// Try field storage default.
$default = $this->fieldDefinition
->getFieldStorageDefinition()
->getSetting('default_library');
if (!empty($default)) {
return $default;
}
// Fall back to global default.
return $this->libraryManager->getActiveLibrary();
}
return NULL;
}
/**
* Build library-specific settings using the plugin's configuration form.
*
* This method delegates the entire configuration form building to the
* plugin, which handles both common fields and library-specific settings.
*
* @param array &$element
* The form element to add settings to.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param \Drupal\Core\Field\FieldItemListInterface $items
* The field items.
* @param int $delta
* The field delta.
* @param string $library
* The selected library ID.
*/
protected function buildLibrarySettings(array &$element, FormStateInterface $form_state, FieldItemListInterface $items, int $delta, string $library): void {
// Get the plugin instance from the plugin manager.
$plugin = $this->pluginManager->getPlugin($library);
if (!$plugin) {
return;
}
// Get default values from configuration or user input.
$user_input = $form_state->getUserInput();
$field_name = $this->fieldDefinition->getName();
$plugin_config = NULL;
// Check for user input during AJAX rebuilds.
if (isset($user_input[$field_name][$delta]['library_settings_wrapper'][$library])) {
$plugin_config = $user_input[$field_name][$delta]['library_settings_wrapper'][$library];
}
elseif ($form_state->isRebuilding()) {
// During rebuilds, check the form state values.
$values = $form_state->getValues();
if (isset($values[$field_name][$delta]['library_settings_wrapper'][$library])) {
$plugin_config = $values[$field_name][$delta]['library_settings_wrapper'][$library];
}
}
// Fall back to stored configuration.
if ($plugin_config === NULL) {
$item = $items[$delta] ?? NULL;
if ($item) {
$library_settings = $item->getLibrarySettings();
$plugin_config = $library_settings[$library] ?? NULL;
}
// If still no config, use plugin defaults.
if ($plugin_config === NULL) {
$plugin_config = $plugin->getDefaultConfiguration();
// If we have an existing item with a target date, use it.
if ($item && !empty($item->target_date)) {
// Ensure target_date is an integer before using gmdate.
$timestamp = is_numeric($item->target_date) ? (int) $item->target_date : 0;
if ($timestamp > 0) {
// Convert timestamp to date string for plugin configuration.
$plugin_config['target_date'] = gmdate('Y-m-d\TH:i:s', $timestamp);
}
$plugin_config['finish_message'] = $item->finish_message ?: "Time's up!";
}
}
}
// Initialize the array element before passing it to the plugin.
$element[$library] = [];
// Let the plugin build its entire configuration form.
$plugin->buildConfigurationForm(
$element[$library],
$form_state,
$plugin_config
);
// Add tree property to ensure proper form value structure.
$element[$library]['#tree'] = TRUE;
}
/**
* Get filtered library options based on method and allowed libraries.
*
* @param string $method
* The loading method ('local' or 'cdn').
* @param array $allowed_libraries
* Array of allowed library IDs.
*
* @return array
* Filtered library options.
*/
protected function getFilteredLibraryOptions(string $method, array $allowed_libraries): array {
$all_options = $this->libraryManager->getAvailableLibraryOptions($method);
if (empty($allowed_libraries)) {
return $all_options;
}
// Filter to only allowed libraries.
$filtered = [];
foreach ($all_options as $id => $label) {
if (in_array($id, $allowed_libraries, TRUE)) {
$filtered[$id] = $label;
}
}
return $filtered;
}
/**
* Get the timezone for display based on widget settings.
*
* @return string|null
* The timezone identifier or NULL for default.
*/
protected function getTimezoneForDisplay(): ?string {
$handling = $this->getSetting('timezone_handling');
switch ($handling) {
case 'site':
return $this->configFactory->get('system.date')->get('timezone.default');
case 'user':
if ($this->currentUser->isAuthenticated()) {
$user_storage = $this->entityTypeManager->getStorage('user');
$user = $user_storage->load($this->currentUser->id());
if ($user) {
$user_timezone = $user->getTimeZone();
if ($user_timezone) {
return $user_timezone;
}
}
}
// Fall back to site timezone if user has no preference.
return $this->configFactory->get('system.date')->get('timezone.default');
case 'utc':
return 'UTC';
default:
return NULL;
}
}
/**
* Convert all DrupalDateTime objects to string format in configuration.
*
* This method recursively processes configuration arrays to ensure all
* DrupalDateTime objects are converted to ISO-8601 strings for storage.
*
* @param mixed $data
* The data to process.
*
* @return mixed
* The processed data with DrupalDateTime objects converted to strings.
*/
protected function convertDateTimeObjects($data) {
if ($data instanceof DrupalDateTime) {
// Convert DrupalDateTime to ISO-8601 string.
return $data->format('Y-m-d\TH:i:s');
}
elseif ($data instanceof \DateTime) {
// Handle regular DateTime objects too.
return $data->format('Y-m-d\TH:i:s');
}
elseif (is_array($data)) {
// Recursively process arrays.
$processed = [];
foreach ($data as $key => $value) {
$processed[$key] = $this->convertDateTimeObjects($value);
}
return $processed;
}
else {
// Return other data types as-is.
return $data;
}
}
/**
* Extract datetime value from various possible formats.
*
* The datetime form element can return values in different formats depending
* on the context. This method normalizes them to a timestamp.
*
* @param mixed $date_value
* The date value in various possible formats.
*
* @return int|null
* The UTC timestamp or NULL if invalid.
*/
protected function extractDateTimeValue($date_value): ?int {
// Handle NULL or empty values.
if (empty($date_value)) {
return NULL;
}
// Handle DrupalDateTime object.
if ($date_value instanceof DrupalDateTime) {
try {
// Get the timezone used for input.
$input_timezone = $this->getTimezoneForDisplay();
// If the date doesn't have a timezone, set it.
if ($input_timezone) {
$date_value->setTimezone(new \DateTimeZone($input_timezone));
}
// Convert to UTC for storage.
$utc_date = clone $date_value;
$utc_date->setTimezone(new \DateTimeZone('UTC'));
return (int) $utc_date->getTimestamp();
}
catch (\Exception $e) {
return NULL;
}
}
// Handle regular DateTime object.
if ($date_value instanceof \DateTime) {
try {
$utc_date = clone $date_value;
$utc_date->setTimezone(new \DateTimeZone('UTC'));
return (int) $utc_date->getTimestamp();
}
catch (\Exception $e) {
return NULL;
}
}
// Handle array format from datetime form element.
if (is_array($date_value)) {
// Check for object key containing DrupalDateTime.
if (isset($date_value['object']) && $date_value['object'] instanceof DrupalDateTime) {
return $this->extractDateTimeValue($date_value['object']);
}
// Check for date and time keys.
if (isset($date_value['date']) && isset($date_value['time'])) {
try {
// Combine date and time into ISO format.
$datetime_string = $date_value['date'] . 'T' . $date_value['time'];
$datetime = new \DateTime($datetime_string);
return (int) $datetime->getTimestamp();
}
catch (\Exception $e) {
return NULL;
}
}
// Check for value key (some form elements use this).
if (isset($date_value['value'])) {
return $this->extractDateTimeValue($date_value['value']);
}
// No recognizable array format.
return NULL;
}
// Handle numeric timestamp.
if (is_numeric($date_value)) {
$timestamp = (int) $date_value;
// Validate reasonable timestamp range (year 1970 to 2100).
if ($timestamp > 0 && $timestamp < 4102444800) {
return $timestamp;
}
return NULL;
}
// Handle string date format.
if (is_string($date_value)) {
try {
$datetime = new \DateTime($date_value);
return (int) $datetime->getTimestamp();
}
catch (\Exception $e) {
return NULL;
}
}
// Unknown format.
return NULL;
}
/**
* {@inheritdoc}
*/
public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
foreach ($values as &$value) {
// Get the selected library.
$library = $value['library'] ?? '';
// Get plugin values from the wrapper.
if ($library && isset($value['library_settings_wrapper'][$library])) {
$plugin_values = $value['library_settings_wrapper'][$library];
// Extract and set target_date at the root level.
if (isset($plugin_values['target_date'])) {
$timestamp = $this->extractDateTimeValue($plugin_values['target_date']);
if ($timestamp !== NULL && $timestamp > 0) {
$datetime = DrupalDateTime::createFromTimestamp($timestamp);
$value['target_date'] = $datetime;
// Also keep it in plugin_values as a string for library_settings.
$plugin_values['target_date'] = gmdate('Y-m-d\TH:i:s', $timestamp);
}
else {
// If invalid or empty, ensure it's unset.
$value['target_date'] = NULL;
$plugin_values['target_date'] = '';
}
}
else {
// No target_date in plugin values, ensure it's unset.
$value['target_date'] = NULL;
}
// Map common plugin fields to field values.
$value['finish_message'] = $plugin_values['finish_message'] ?? "Time's up!";
// Convert all remaining DrupalDateTime objects.
$processed_plugin_values = $this->convertDateTimeObjects($plugin_values);
// Flatten library_specific values.
$flattened_config = [];
foreach ($processed_plugin_values as $key => $val) {
if ($key === 'library_specific' && is_array($val)) {
// Merge library_specific values into the flat structure.
foreach ($val as $nested_key => $nested_val) {
$flattened_config[$nested_key] = $nested_val;
}
}
else {
$flattened_config[$key] = $val;
}
}
// Store the flattened configuration.
$value['library_settings'] = [$library => $flattened_config];
// Clean up the wrapper.
unset($value['library_settings_wrapper']);
}
else {
// No library selected or no plugin values, ensure target_date is NULL.
$value['target_date'] = NULL;
}
// Handle event details.
if (isset($value['event_details'])) {
$value['event_name'] = $value['event_details']['event_name'] ?? '';
$value['event_url'] = $value['event_details']['event_url'] ?? '';
unset($value['event_details']);
}
}
return $values;
}
/**
* {@inheritdoc}
*/
public function extractFormValues(FieldItemListInterface $items, array $form, FormStateInterface $form_state) {
parent::extractFormValues($items, $form, $form_state);
// Validate plugin configuration after extraction.
$field_name = $this->fieldDefinition->getName();
$values = $form_state->getValues();
foreach ($items as $delta => $item) {
if (!empty($item->library) && isset($values[$field_name][$delta]['library_settings_wrapper'][$item->library])) {
$plugin = $this->pluginManager->getPlugin($item->library);
if ($plugin) {
// Create a new FormState for plugin validation.
$plugin_form_state = new FormState();
$plugin_values = $values[$field_name][$delta]['library_settings_wrapper'][$item->library];
$plugin_form_state->setValues($plugin_values);
// Build a dummy form element for validation.
$plugin_form = [];
$plugin->buildConfigurationForm($plugin_form, $plugin_form_state, $plugin_values);
// Let the plugin validate its configuration.
$plugin->validateConfigurationForm($plugin_form, $plugin_form_state);
// Copy any errors back to the main form state with adjusted paths.
foreach ($plugin_form_state->getErrors() as $name => $error) {
// Adjust the error path to include the field wrapper.
$adjusted_name = $field_name . '][' . $delta . '][library_settings_wrapper][' . $item->library . '][' . $name;
$form_state->setErrorByName($adjusted_name, $error);
}
}
}
}
}
}
