countdown-8.x-1.8/modules/countdown_field/src/Plugin/Field/FieldFormatter/CountdownDefaultFormatter.php
modules/countdown_field/src/Plugin/Field/FieldFormatter/CountdownDefaultFormatter.php
<?php
declare(strict_types=1);
namespace Drupal\countdown_field\Plugin\Field\FieldFormatter;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\countdown\CountdownLibraryPluginManager;
use Drupal\countdown\Service\CountdownLibraryManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Plugin implementation of the 'countdown_default' formatter.
*
* This formatter displays countdown timers using the configured library. It
* delegates to the plugin system for library attachment and JavaScript
* settings preparation to ensure consistency across the module.
*
* @FieldFormatter(
* id = "countdown_default",
* label = @Translation("Countdown display"),
* field_types = {
* "countdown"
* }
* )
*/
class CountdownDefaultFormatter extends FormatterBase 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 request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected RequestStack $requestStack;
/**
* The logger factory.
*
* @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
*/
protected LoggerChannelFactoryInterface $loggerFactory;
/**
* Constructs a CountdownDefaultFormatter object.
*
* @param string $plugin_id
* The plugin_id for the formatter.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The definition of the field to which the formatter is associated.
* @param array $settings
* The formatter settings.
* @param string $label
* The formatter label display setting.
* @param string $view_mode
* The view mode.
* @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 \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger factory.
*/
public function __construct(
$plugin_id,
$plugin_definition,
FieldDefinitionInterface $field_definition,
array $settings,
$label,
$view_mode,
array $third_party_settings,
CountdownLibraryManagerInterface $library_manager,
CountdownLibraryPluginManager $plugin_manager,
ConfigFactoryInterface $config_factory,
RequestStack $request_stack,
LoggerChannelFactoryInterface $logger_factory,
) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings);
$this->libraryManager = $library_manager;
$this->pluginManager = $plugin_manager;
$this->configFactory = $config_factory;
$this->requestStack = $request_stack;
$this->loggerFactory = $logger_factory;
}
/**
* {@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['label'],
$configuration['view_mode'],
$configuration['third_party_settings'],
$container->get('countdown.library_manager'),
$container->get('plugin.manager.countdown_library'),
$container->get('config.factory'),
$container->get('request_stack'),
$container->get('logger.factory'),
);
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'show_event_name' => TRUE,
'link_to_event' => TRUE,
'wrapper_class' => '',
'override_format' => FALSE,
'format_override' => 'dhms',
'show_finish_message' => TRUE,
'finish_message_override' => '',
'accessibility_label' => TRUE,
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$elements = parent::settingsForm($form, $form_state);
$elements['show_event_name'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show event name'),
'#description' => $this->t('Display the event name if provided.'),
'#default_value' => $this->getSetting('show_event_name'),
];
$elements['link_to_event'] = [
'#type' => 'checkbox',
'#title' => $this->t('Link to event URL'),
'#description' => $this->t('Make the event name a link if URL is provided.'),
'#default_value' => $this->getSetting('link_to_event'),
'#states' => [
'visible' => [
':input[name="fields[' . $this->fieldDefinition->getName() . '][settings_edit_form][settings][show_event_name]"]' => ['checked' => TRUE],
],
],
];
$elements['wrapper_class'] = [
'#type' => 'textfield',
'#title' => $this->t('Wrapper CSS class'),
'#description' => $this->t('Additional CSS class(es) for the countdown wrapper.'),
'#default_value' => $this->getSetting('wrapper_class'),
'#maxlength' => 255,
];
$elements['override_format'] = [
'#type' => 'checkbox',
'#title' => $this->t('Override countdown format'),
'#description' => $this->t('Override the display format set in the field value.'),
'#default_value' => $this->getSetting('override_format'),
];
$elements['format_override'] = [
'#type' => 'select',
'#title' => $this->t('Format'),
'#options' => [
'd' => $this->t('Days only'),
'dh' => $this->t('Days and hours'),
'dhm' => $this->t('Days, hours, and minutes'),
'dhms' => $this->t('Days, hours, minutes, and seconds'),
'hms' => $this->t('Hours, minutes, and seconds'),
'ms' => $this->t('Minutes and seconds'),
's' => $this->t('Seconds only'),
],
'#default_value' => $this->getSetting('format_override'),
'#states' => [
'visible' => [
':input[name="fields[' . $this->fieldDefinition->getName() . '][settings_edit_form][settings][override_format]"]' => ['checked' => TRUE],
],
],
];
$elements['show_finish_message'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show finish message'),
'#description' => $this->t('Display a message when the countdown completes.'),
'#default_value' => $this->getSetting('show_finish_message'),
];
$elements['finish_message_override'] = [
'#type' => 'textfield',
'#title' => $this->t('Custom finish message'),
'#description' => $this->t('Leave empty to use field value.'),
'#default_value' => $this->getSetting('finish_message_override'),
'#states' => [
'visible' => [
':input[name="fields[' . $this->fieldDefinition->getName() . '][settings_edit_form][settings][show_finish_message]"]' => ['checked' => TRUE],
],
],
];
$elements['accessibility_label'] = [
'#type' => 'checkbox',
'#title' => $this->t('Add accessibility label'),
'#description' => $this->t('Add an aria-label for screen readers.'),
'#default_value' => $this->getSetting('accessibility_label'),
];
return $elements;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = parent::settingsSummary();
if ($this->getSetting('show_event_name')) {
$summary[] = $this->t('Show event name');
if ($this->getSetting('link_to_event')) {
$summary[] = $this->t('Link to event URL');
}
}
if ($wrapper_class = $this->getSetting('wrapper_class')) {
$summary[] = $this->t('CSS class: @class', ['@class' => $wrapper_class]);
}
if ($this->getSetting('override_format')) {
$formats = [
'd' => $this->t('Days only'),
'dh' => $this->t('Days and hours'),
'dhm' => $this->t('Days, hours, minutes'),
'dhms' => $this->t('Days, hours, minutes, seconds'),
'hms' => $this->t('Hours, minutes, seconds'),
'ms' => $this->t('Minutes and seconds'),
's' => $this->t('Seconds only'),
];
$format = $this->getSetting('format_override');
if (isset($formats[$format])) {
$summary[] = $this->t('Format: @format', ['@format' => $formats[$format]]);
}
}
if ($this->getSetting('show_finish_message')) {
$summary[] = $this->t('Show finish message');
if ($override = $this->getSetting('finish_message_override')) {
$summary[] = $this->t('Custom message: @message', ['@message' => $override]);
}
}
if ($this->getSetting('accessibility_label')) {
$summary[] = $this->t('Accessibility label enabled');
}
return $summary;
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
// Generate a unique field ID for this field instance.
$field_id = $this->fieldDefinition->getName() . '-' . uniqid();
// Check if the field is empty.
$is_empty = TRUE;
foreach ($items as $item) {
if (!$item->isEmpty()) {
$is_empty = FALSE;
break;
}
}
// Log debug information about the field state.
if ($this->configFactory->get('countdown.settings')->get('debug.enabled')) {
$logger = $this->loggerFactory->get('countdown_field');
$logger->debug('viewElements called: field=@field, items=@count, empty=@empty', [
'@field' => $this->fieldDefinition->getName(),
'@count' => count($items),
'@empty' => $is_empty ? 'yes' : 'no',
]);
}
// Process each field item.
/** @var \Drupal\countdown_field\Plugin\Field\FieldType\CountdownItem $item */
foreach ($items as $delta => $item) {
// Get the library for this item, with fallback to default.
$library = $this->getItemLibrary($item);
// Get the plugin for the library.
$plugin = $this->pluginManager->getPlugin($library);
if (!$plugin) {
// Log warning but continue to avoid breaking the page.
$logger = $this->loggerFactory->get('countdown_field');
$logger->warning('Library plugin not found: @library', [
'@library' => $library,
]);
continue;
}
// Get or generate target date.
$target_date_iso = $this->getItemTargetDate($item);
if (!$target_date_iso) {
// If no valid date, create a placeholder element with libraries
// attached.
$elements[$delta] = $this->createPlaceholderElement($field_id, $delta, $item, $plugin);
continue;
}
// Prepare countdown settings using the plugin's method.
$settings = $this->prepareCountdownSettings($item, $plugin);
// Determine the finish message.
$finish_message = $this->getSetting('show_finish_message')
? ($this->getSetting('finish_message_override') ?: $item->finish_message)
: '';
// Build the render array using the field theme.
$element = [
'#theme' => 'countdown_field',
'#library' => $library,
'#target_date' => $target_date_iso,
'#finish_message' => $finish_message,
'#field_id' => $field_id . '-' . $delta,
'#delta' => $delta,
'#attributes' => [
'class' => ['countdown', 'countdown-field'],
'data-field-id' => $field_id . '-' . $delta,
'data-library' => $library,
'data-countdown-type' => 'field',
],
];
// Add event details if enabled.
if ($this->getSetting('show_event_name') && !empty($item->event_name)) {
$element['#event_name'] = $item->event_name;
if ($this->getSetting('link_to_event') && !empty($item->event_url)) {
$element['#event_url'] = $item->event_url;
}
}
// Add wrapper class if specified.
if ($wrapper_class = $this->getSetting('wrapper_class')) {
$element['#attributes']['class'][] = $wrapper_class;
}
// Add accessibility label if enabled.
if ($this->getSetting('accessibility_label')) {
$label = $this->t('Countdown timer for @name', ['@name' => $item->event_name ?: $this->t('event')]);
$element['#attributes']['aria-label'] = $label;
}
// Attach the library using the plugin's buildAttachments method.
$element = $this->attachLibrary($element, $item, $plugin);
// Attach field-specific drupalSettings using unified namespace.
$element['#attached']['drupalSettings']['countdown']['fields'][$field_id . '-' . $delta] = [
'type' => 'field',
'library' => $library,
'target' => $target_date_iso,
'settings' => $settings,
];
// Build cache metadata.
$element['#cache'] = [
'contexts' => [
'languages:language_interface',
'timezone',
],
'tags' => Cache::mergeTags(
$this->fieldDefinition->getCacheTags(),
['countdown_library:' . $library]
),
'max-age' => $this->calculateCacheMaxAge($item),
];
$elements[$delta] = $element;
}
// If the field is completely empty, still attach basic libraries.
if (empty($elements) && $is_empty) {
$elements[0] = $this->createEmptyFieldElement($field_id);
}
return $elements;
}
/**
* Get the library for an item with fallback to default.
*
* @param \Drupal\countdown_field\Plugin\Field\FieldType\CountdownItem $item
* The field item.
*
* @return string
* The library ID.
*/
protected function getItemLibrary($item): string {
// First check if item has a library set.
if (!empty($item->library)) {
return $item->library;
}
// Fall back to field storage default.
$default = $this->fieldDefinition
->getFieldStorageDefinition()
->getSetting('default_library');
if (!empty($default)) {
return $default;
}
// Finally fall back to global default.
return $this->libraryManager->getActiveLibrary();
}
/**
* Get the target date for an item.
*
* @param \Drupal\countdown_field\Plugin\Field\FieldType\CountdownItem $item
* The field item.
*
* @return string|null
* The ISO-8601 date string or NULL.
*/
protected function getItemTargetDate($item): ?string {
// Check if item has a valid target date.
if (!empty($item->target_date)) {
return $item->getTargetDateIso();
}
// For new/empty items, generate a default future date.
$default_date = new \DateTime('+7 days');
return $default_date->format('Y-m-d\TH:i:s\Z');
}
/**
* Create a placeholder element for items without dates.
*
* @param string $field_id
* The field identifier.
* @param int $delta
* The field delta.
* @param \Drupal\countdown_field\Plugin\Field\FieldType\CountdownItem $item
* The field item.
* @param \Drupal\countdown\Plugin\CountdownLibraryPluginInterface $plugin
* The library plugin.
*
* @return array
* The render array.
*/
protected function createPlaceholderElement(string $field_id, int $delta, $item, $plugin): array {
// Create a minimal element that still loads necessary libraries.
$element = [
'#type' => 'container',
'#attributes' => [
'class' => ['countdown-field', 'countdown-field-placeholder'],
'data-field-id' => $field_id . '-' . $delta,
'data-countdown-type' => 'field',
],
'#children' => '',
];
// Still attach libraries for consistency.
$element = $this->attachLibrary($element, $item, $plugin);
return $element;
}
/**
* Create an element for completely empty fields.
*
* @param string $field_id
* The field identifier.
*
* @return array
* The render array.
*/
protected function createEmptyFieldElement(string $field_id): array {
// Create a minimal element that loads base libraries.
$element = [
'#type' => 'container',
'#attributes' => [
'class' => ['countdown-field', 'countdown-field-empty'],
'data-field-id' => $field_id . '-0',
'data-countdown-type' => 'field',
],
'#children' => '',
];
// Attach base field libraries.
$element['#attached']['library'][] = 'countdown_field/integration';
$element['#attached']['library'][] = 'countdown_field/formatter';
return $element;
}
/**
* Attach the countdown library using the plugin's buildAttachments method.
*
* This method leverages the main module's plugin system to properly attach
* all required assets without duplicating logic.
*
* @param array $element
* The render element.
* @param \Drupal\countdown_field\Plugin\Field\FieldType\CountdownItem $item
* The field item.
* @param \Drupal\countdown\Plugin\CountdownLibraryPluginInterface $plugin
* The library plugin.
*
* @return array
* The element with attached libraries.
*/
protected function attachLibrary(array $element, $item, $plugin): array {
// Get configuration for plugin attachment.
$config = $this->configFactory->get('countdown.settings');
// Get library settings from the field item.
$library_settings = $item->getLibrarySettings();
$attachment_config = [
'method' => $item->getEffectiveMethod(),
'variant' => $config->get('build.variant') ?? TRUE,
'cdn_provider' => $config->get('cdn.provider') ?? 'jsdelivr',
'rtl' => $config->get('appearance.rtl') ?? FALSE,
'debug' => $config->get('debug.enabled') ?? FALSE,
'context' => 'field',
'field_config' => $library_settings,
];
// Let the plugin build its attachments with context-aware configuration.
$attachments = $plugin->buildAttachments($attachment_config);
// Merge the attachments from the plugin.
if (!empty($attachments['#attached'])) {
$element['#attached'] = array_merge_recursive(
$element['#attached'] ?? [],
$attachments['#attached']
);
}
// Always attach the field integration bridge.
$element['#attached']['library'][] = 'countdown_field/integration';
// Always attach the formatter library for CSS.
$element['#attached']['library'][] = 'countdown_field/formatter';
// Debug logging if enabled.
if ($config->get('debug.enabled')) {
$logger = $this->loggerFactory->get('countdown_field');
$logger->debug('Libraries attached: @libs', [
'@libs' => print_r($element['#attached']['library'] ?? [], TRUE),
]);
}
return $element;
}
/**
* Prepare countdown settings for JavaScript using the plugin's method.
*
* This delegates JavaScript settings preparation to the plugin to ensure
* consistency and avoid code duplication.
*
* @param \Drupal\countdown_field\Plugin\Field\FieldType\CountdownItem $item
* The countdown field item.
* @param \Drupal\countdown\Plugin\CountdownLibraryPluginInterface $plugin
* The library plugin.
*
* @return array
* The settings array for JavaScript initialization.
*/
protected function prepareCountdownSettings($item, $plugin): array {
// Get the stored library settings.
$library_settings = $item->getLibrarySettings();
$plugin_config = $library_settings[$item->library] ?? [];
// Override finish message if formatter setting is enabled.
if ($this->getSetting('show_finish_message')) {
$override_message = $this->getSetting('finish_message_override');
if ($override_message) {
$plugin_config['finish_message'] = $override_message;
}
elseif (empty($plugin_config['finish_message'])) {
// Ensure we always have a finish message.
$plugin_config['finish_message'] = $item->finish_message ?: "Time's up!";
}
}
// Let the plugin transform configuration to JavaScript settings.
$settings = $plugin->getJavaScriptSettings($plugin_config);
// Add library identifier.
$settings['library'] = $item->library ?: $this->getItemLibrary($item);
// Add format override if configured.
if ($this->getSetting('override_format')) {
$format_string = $this->getSetting('format_override');
$settings['format'] = str_split($format_string);
}
return $settings;
}
/**
* Calculate cache max age based on countdown target.
*
* For countdown timers, we cache until the target date is reached to ensure
* the timer remains accurate. For count-up timers or past dates, we use a
* standard cache duration.
*
* @param \Drupal\countdown_field\Plugin\Field\FieldType\CountdownItem $item
* The field item.
*
* @return int
* The cache max age in seconds.
*/
protected function calculateCacheMaxAge($item): int {
// For countdown timers, cache until the target date.
if (!empty($item->target_date)) {
$request = $this->requestStack->getCurrentRequest();
$now = $request ? $request->server->get('REQUEST_TIME') : time();
$diff = $item->target_date - $now;
// Only cache if target is in the future.
if ($diff > 0) {
// Cache for at most 1 hour to allow for configuration changes.
return min($diff, 3600);
}
}
// Default to 1 hour cache for count-up or past dates.
return 3600;
}
}
