countdown-8.x-1.8/modules/countdown_block/src/Plugin/Block/CountdownBlock.php
modules/countdown_block/src/Plugin/Block/CountdownBlock.php
<?php
declare(strict_types=1);
namespace Drupal\countdown_block\Plugin\Block;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\countdown\CountdownLibraryPluginManager;
use Drupal\countdown\Service\CountdownLibraryDiscoveryInterface;
use Drupal\countdown\Service\CountdownLibraryManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a configurable countdown timer block.
*
* This block allows users to add countdown timers to any region with
* per-block configuration for library, loading method, and timer settings.
* It delegates library-specific configuration to the plugin system.
*
* @Block(
* id = "countdown_block",
* admin_label = @Translation("Countdown Timer"),
* category = @Translation("Countdown")
* )
*/
class CountdownBlock extends BlockBase implements ContainerFactoryPluginInterface {
use StringTranslationTrait;
/**
* The countdown library manager service.
*
* @var \Drupal\countdown\Service\CountdownLibraryManagerInterface
*/
protected CountdownLibraryManagerInterface $libraryManager;
/**
* The countdown library discovery service.
*
* @var \Drupal\countdown\Service\CountdownLibraryDiscoveryInterface
*/
protected CountdownLibraryDiscoveryInterface $libraryDiscovery;
/**
* The configuration factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected ConfigFactoryInterface $configFactory;
/**
* The plugin manager.
*
* @var \Drupal\countdown\CountdownLibraryPluginManager
*/
protected CountdownLibraryPluginManager $pluginManager;
/**
* Constructs a new CountdownBlock instance.
*
* @param array $configuration
* The plugin configuration.
* @param string $plugin_id
* The plugin ID.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\countdown\Service\CountdownLibraryManagerInterface $library_manager
* The countdown library manager service.
* @param \Drupal\countdown\Service\CountdownLibraryDiscoveryInterface $library_discovery
* The countdown library discovery service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The configuration factory.
* @param \Drupal\countdown\CountdownLibraryPluginManager $plugin_manager
* The countdown library plugin manager.
*/
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
CountdownLibraryManagerInterface $library_manager,
CountdownLibraryDiscoveryInterface $library_discovery,
ConfigFactoryInterface $config_factory,
CountdownLibraryPluginManager $plugin_manager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->libraryManager = $library_manager;
$this->libraryDiscovery = $library_discovery;
$this->configFactory = $config_factory;
$this->pluginManager = $plugin_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('countdown.library_manager'),
$container->get('countdown.library_discovery'),
$container->get('config.factory'),
$container->get('plugin.manager.countdown_library'),
);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
// Provide sensible defaults for new blocks.
return [
'method' => 'local',
'library' => 'countdown',
'cdn_provider' => 'jsdelivr',
'event_name' => '',
'event_url' => '',
'plugin_config' => [],
];
}
/**
* Get configuration value with fallback to global settings.
*
* This is used for form defaults and runtime configuration resolution.
*
* @param string $key
* The configuration key.
* @param mixed $default
* The default value if not found.
*
* @return mixed
* The configuration value.
*/
protected function getConfigWithFallback(string $key, $default = NULL) {
// Check block configuration first.
if (!empty($this->configuration[$key])) {
return $this->configuration[$key];
}
// Fall back to global settings.
$config = $this->configFactory->get('countdown.settings');
$map = [
'method' => 'method',
'library' => 'library',
'cdn_provider' => 'cdn.provider',
];
if (isset($map[$key])) {
$value = $config->get($map[$key]);
if ($value !== NULL && $value !== '') {
return $value;
}
}
// Return the provided default or a sensible default.
if ($default !== NULL) {
return $default;
}
// Provide sensible defaults for critical settings.
switch ($key) {
case 'method':
return 'local';
case 'library':
return 'countdown';
case 'cdn_provider':
return 'jsdelivr';
default:
return NULL;
}
}
/**
* Get the current form value during AJAX rebuilds.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param string $key
* The configuration key.
* @param mixed $default
* The default value if not found.
*
* @return mixed
* The current form value or default.
*/
protected function getFormValue(FormStateInterface $form_state, string $key, $default = NULL) {
// Check user input for AJAX rebuilds.
$user_input = $form_state->getUserInput();
// Map configuration keys to form structure.
$input_map = [
'method' => ['settings', 'method'],
'library' => ['settings', 'library_wrapper', 'library'],
'cdn_provider' => ['settings', 'library_wrapper', 'cdn_provider'],
];
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 configuration.
return $this->configuration[$key] ?? $default;
}
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state) {
// Get the current method, checking user input for AJAX rebuilds.
$method = $this->getFormValue($form_state, 'method')
?: $this->getConfigWithFallback('method', 'local');
// Loading method selection.
$form['method'] = [
'#type' => 'radios',
'#title' => $this->t('Loading Method'),
'#description' => $this->t('Choose how to load countdown libraries.'),
'#options' => [
'local' => $this->t('Local (installed libraries only)'),
'cdn' => $this->t('CDN (external libraries)'),
],
'#default_value' => $method,
'#required' => TRUE,
'#ajax' => [
'callback' => [$this, 'updateLibraryOptions'],
'wrapper' => 'countdown-library-wrapper',
'event' => 'change',
],
];
// Library selection container with AJAX wrapper.
$form['library_wrapper'] = [
'#type' => 'container',
'#attributes' => ['id' => 'countdown-library-wrapper'],
];
// Get available libraries based on the selected method.
$library_options = $this->getLibraryOptions($method);
// Get current library, checking user input for AJAX rebuilds.
$current_library = $this->getFormValue($form_state, 'library')
?: $this->getConfigWithFallback('library', 'countdown');
// Validate the library is available for the current method.
if (!isset($library_options[$current_library])) {
$current_library = key($library_options) ?: '';
}
$form['library_wrapper']['library'] = [
'#type' => 'select',
'#title' => $this->t('Countdown Library'),
'#description' => $this->t('Select the countdown library to use.'),
'#options' => $library_options,
'#default_value' => $current_library,
'#required' => TRUE,
'#empty_option' => $this->t('- Select a library -'),
'#ajax' => [
'callback' => [$this, 'updateLibrarySettings'],
'wrapper' => 'countdown-plugin-settings-wrapper',
'event' => 'change',
],
];
// CDN provider selection (only visible for CDN method).
if ($method === 'cdn') {
$cdn_provider = $this->getFormValue($form_state, 'cdn_provider')
?: $this->getConfigWithFallback('cdn_provider', 'jsdelivr');
$form['library_wrapper']['cdn_provider'] = [
'#type' => 'select',
'#title' => $this->t('CDN Provider'),
'#description' => $this->t('Select the CDN provider to use.'),
'#options' => [
'jsdelivr' => $this->t('jsDelivr'),
'cdnjs' => $this->t('cdnjs'),
'unpkg' => $this->t('unpkg'),
],
'#default_value' => $cdn_provider,
'#required' => TRUE,
];
}
// Countdown event settings.
$form['event'] = [
'#type' => 'details',
'#title' => $this->t('Event'),
'#description' => $this->t('Configure countdown event settings.'),
'#open' => TRUE,
'#weight' => 90,
];
// Block-specific settings that don't belong to the plugin.
$form['event']['event_name'] = [
'#type' => 'textfield',
'#title' => $this->t('Event Name'),
'#description' => $this->t('Optional name for the countdown event.'),
'#default_value' => $this->configuration['event_name'],
'#maxlength' => 255,
];
$form['event']['event_url'] = [
'#type' => 'url',
'#title' => $this->t('Event URL'),
'#description' => $this->t('Optional link to more information about the event.'),
'#default_value' => $this->configuration['event_url'],
];
// Plugin configuration container.
$form['plugin_settings_wrapper'] = [
'#type' => 'container',
'#attributes' => ['id' => 'countdown-plugin-settings-wrapper'],
];
// Add plugin-specific configuration if a library is selected.
if (!empty($current_library)) {
$this->buildPluginSettings($form['plugin_settings_wrapper'], $form_state, $current_library);
}
return $form;
}
/**
* AJAX callback to update library options when method changes.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return array
* The updated form element.
*/
public function updateLibraryOptions(array &$form, FormStateInterface $form_state) {
return $form['settings']['library_wrapper'];
}
/**
* AJAX callback to update library-specific settings.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return array
* The updated form element.
*/
public function updateLibrarySettings(array &$form, FormStateInterface $form_state) {
return $form['settings']['plugin_settings_wrapper'];
}
/**
* Get available library options based on loading method.
*
* @param string $method
* The loading method ('local' or 'cdn').
*
* @return array
* Array of library options keyed by library ID.
*/
protected function getLibraryOptions(string $method): array {
return $this->libraryManager->getAvailableLibraryOptions($method);
}
/**
* Build plugin-specific settings form elements.
*
* Delegates the entire configuration form 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 string $library
* The selected library ID.
*/
protected function buildPluginSettings(array &$element, FormStateInterface $form_state, string $library) {
// 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();
$plugin_config = NULL;
// Check for user input during AJAX rebuilds.
if (isset($user_input['settings']['plugin_settings_wrapper'][$library])) {
$plugin_config = $user_input['settings']['plugin_settings_wrapper'][$library];
}
// Fall back to stored configuration.
if ($plugin_config === NULL) {
$plugin_config = $this->configuration[$library]
?? $plugin->getDefaultConfiguration();
}
// Initialize the array element before passing it to the plugin.
$element[$library] = [];
// Let the plugin build its entire configuration form. This includes
// common fields and library-specific fields.
$plugin->buildConfigurationForm(
$element[$library],
$form_state,
$plugin_config
);
}
/**
* {@inheritdoc}
*/
public function blockValidate($form, FormStateInterface $form_state) {
// Get the current values before any modification.
$values = $form_state->getValues();
$library = $values['library_wrapper']['library'] ?? NULL;
if ($library) {
$plugin = $this->pluginManager->getPlugin($library);
if ($plugin && isset($form['plugin_settings_wrapper'][$library])) {
// Get the plugin values.
$plugin_values = $values['plugin_settings_wrapper'][$library] ?? [];
// Create a completely new FormState for plugin validation to avoid
// corrupting the main form_state.
$plugin_form_state = new FormState();
$plugin_form_state->setValues($plugin_values);
// Let the plugin validate its configuration.
$plugin_form_element = $form['plugin_settings_wrapper'][$library];
$plugin->validateConfigurationForm($plugin_form_element, $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 plugin wrapper.
$adjusted_name = 'plugin_settings_wrapper][' . $library . '][' . $name;
$form_state->setErrorByName($adjusted_name, $error);
}
}
}
}
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state) {
// Get the complete form values BEFORE calling parent.
$complete_values = $form_state->getValues();
// Use our saved complete values for processing.
$this->configuration['method'] = $complete_values['method'] ?? 'local';
$this->configuration['library'] = $complete_values['library_wrapper']['library'] ?? '';
if ($this->configuration['method'] === 'cdn') {
$this->configuration['cdn_provider'] = $complete_values['library_wrapper']['cdn_provider'] ?? 'jsdelivr';
}
// Save block-specific settings.
$this->configuration['event_name'] = $complete_values['event']['event_name'] ?? '';
$this->configuration['event_url'] = $complete_values['event']['event_url'] ?? '';
// Save plugin configuration with proper flattening of library_specific
// values so they can be retrieved correctly.
$library = $this->configuration['library'];
if ($library && isset($complete_values['plugin_settings_wrapper'][$library])) {
$plugin_values = $complete_values['plugin_settings_wrapper'][$library];
// Convert DrupalDateTime objects to strings before flattening.
array_walk_recursive($plugin_values, function (&$value) {
if ($value instanceof DrupalDateTime) {
$value = $value->format('Y-m-d\TH:i:s');
}
});
// Flatten the configuration properly.
$flattened_config = [];
// First copy all top-level values (common fields).
foreach ($plugin_values as $key => $value) {
if ($key !== 'library_specific') {
// Convert DrupalDateTime to string for storage.
if ($value instanceof DrupalDateTime) {
$flattened_config[$key] = $value->format('Y-m-d\TH:i:s');
}
else {
$flattened_config[$key] = $value;
}
}
}
// Then merge library_specific values to top level.
if (isset($plugin_values['library_specific']) && is_array($plugin_values['library_specific'])) {
// Recursively flatten nested arrays in library_specific.
$this->flattenArray($plugin_values['library_specific'], $flattened_config);
}
// Save the flattened configuration.
$this->configuration[$library] = $flattened_config;
}
}
/**
* Flatten a nested array into a single-level array.
*
* This helper method flattens nested configuration arrays so that all
* values are accessible at the top level for the plugin's getConfigValue()
* method.
*
* @param array $array
* The array to flatten.
* @param array &$result
* The result array to merge values into.
* @param string $prefix
* Optional prefix for nested keys (not used for library_specific).
*/
protected function flattenArray(array $array, array &$result, string $prefix = ''): void {
foreach ($array as $key => $value) {
// Skip certain nested structures that should remain nested.
$preserve_nested = [
'headings',
'labels',
'theme_options',
'stop_configuration',
'dots_settings',
'line_settings',
'boom_settings',
'swap_settings',
'advanced',
'animation',
'display',
'appearance',
'performance',
'developer',
'transforms',
'transitions',
];
if (in_array($key, $preserve_nested, TRUE) && is_array($value)) {
// For these special nested configs, keep them as arrays but still
// extract individual values to top level for backward compatibility.
$result[$key] = $value;
// Also flatten the values to top level for direct access.
foreach ($value as $nested_key => $nested_value) {
if (!is_array($nested_value)) {
$result[$nested_key] = $nested_value;
}
}
}
elseif (is_array($value) && !in_array($key, ['format', 'show_units', 'labels', 'headings'], TRUE)) {
// For other arrays, continue flattening recursively unless they're
// known array values like format checkboxes.
$this->flattenArray($value, $result);
}
else {
// Direct assignment for non-array values or preserved arrays.
$result[$key] = $value;
}
}
}
/**
* {@inheritdoc}
*/
public function build() {
// Get block configuration with fallbacks for unconfigured blocks.
$library = $this->getConfigWithFallback('library', 'countdown');
$method = $this->getConfigWithFallback('method', 'local');
// Get the plugin instance.
$plugin = $this->pluginManager->getPlugin($library);
if (!$plugin) {
return [
'#markup' => $this->t('Library @library not found.', ['@library' => $library]),
];
}
// Get plugin configuration, using defaults if not configured.
$plugin_config = $this->configuration[$library] ?? $plugin->getDefaultConfiguration();
// Ensure we have a target date. If not, provide a default future date
// (1 day from now) to avoid errors on first placement.
if (empty($plugin_config['target_date'])) {
$default_date = new \DateTime('+1 day');
$plugin_config['target_date'] = $default_date->format('Y-m-d\TH:i:s');
}
// Prepare countdown settings for JavaScript.
$settings = $this->prepareCountdownSettings($plugin, $plugin_config);
// Generate a unique block ID.
$block_id = 'countdown-block-' . ($this->getDerivativeId() ?: uniqid());
// Build the render array using the main module's theme.
$build = [
'#theme' => 'countdown',
'#library' => $library,
'#target_date' => $plugin_config['target_date'],
'#countdown_event_name' => $this->configuration['event_name'],
'#countdown_url' => $this->configuration['event_url'],
'#attributes' => [
'id' => $block_id,
'class' => ['countdown-block'],
'data-block-id' => $block_id,
'data-countdown-library' => $library,
'data-countdown-target' => $plugin_config['target_date'],
'data-countdown-settings' => Json::encode($settings),
'data-countdown-type' => 'block',
],
'#cache' => [
'contexts' => [
'languages:language_interface',
'timezone',
'route',
],
'tags' => $this->getCacheTags(),
'max-age' => $this->getCacheMaxAge(),
],
];
// Get configuration for plugin attachment.
$config = $this->configFactory->get('countdown.settings');
$attachment_config = [
'method' => $method,
'variant' => $config->get('build.variant'),
'cdn_provider' => $this->getConfigWithFallback('cdn_provider', 'jsdelivr'),
'rtl' => $config->get('appearance.rtl'),
'debug' => $config->get('debug.enabled'),
'context' => 'block',
'block_config' => $this->configuration,
];
// Let the plugin build its attachments with context-aware configuration.
$plugin_attachments = $plugin->buildAttachments($attachment_config);
// Merge plugin attachments with our build.
if (!empty($plugin_attachments['#attached'])) {
$build['#attached'] = $plugin_attachments['#attached'];
}
// Attach the bridge integration for countdown_block.
$build['#attached']['library'][] = 'countdown_block/integration';
// Attach block-specific configuration to drupalSettings.
$build['#attached']['drupalSettings']['countdown']['blocks'][$block_id] = [
'type' => 'block',
'library' => $library,
'target' => $plugin_config['target_date'],
'settings' => $settings,
];
return $build;
}
/**
* Prepare countdown settings for JavaScript.
*
* Delegates to the plugin to transform configuration into JavaScript
* settings, then adds block-specific settings.
*
* @param \Drupal\countdown\Plugin\CountdownLibraryPluginInterface $plugin
* The countdown library plugin.
* @param array $plugin_config
* The plugin configuration.
*
* @return array
* The settings array for JavaScript initialization.
*/
protected function prepareCountdownSettings($plugin, array $plugin_config): array {
// Get JavaScript settings from the plugin.
$settings = $plugin->getJavaScriptSettings($plugin_config);
// Add block-level settings.
$settings['library'] = $this->getConfigWithFallback('library', 'countdown');
$settings['method'] = $this->getConfigWithFallback('method', 'local');
// Add CDN provider if using CDN method.
if ($this->getConfigWithFallback('method', 'local') === 'cdn') {
$settings['cdnProvider'] = $this->getConfigWithFallback('cdn_provider', 'jsdelivr');
}
// Add event information if provided.
if (!empty($this->configuration['event_name'])) {
$settings['eventName'] = $this->configuration['event_name'];
}
if (!empty($this->configuration['event_url'])) {
$settings['eventUrl'] = $this->configuration['event_url'];
}
return $settings;
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
$library = $this->getConfigWithFallback('library', 'countdown');
return Cache::mergeTags(parent::getCacheTags(), [
'config:countdown.settings',
'countdown_library:' . $library,
]);
}
/**
* {@inheritdoc}
*/
public function getCacheMaxAge() {
// Get plugin configuration to check target date.
$library = $this->getConfigWithFallback('library', 'countdown');
$plugin_config = $this->configuration[$library] ?? [];
// Calculate cache max age based on target date.
if (!empty($plugin_config['target_date'])) {
try {
$target = new \DateTime($plugin_config['target_date']);
$now = new \DateTime();
$diff = $target->getTimestamp() - $now->getTimestamp();
// Cache until the countdown expires, but not longer than 1 hour.
if ($diff > 0) {
return min($diff, 3600);
}
}
catch (\Exception $e) {
// If date parsing fails, use default cache time.
}
}
// Default to 1 hour cache.
return 3600;
}
}
