countdown-8.x-1.8/src/Form/CountdownSettings.php
src/Form/CountdownSettings.php
<?php
declare(strict_types=1);
namespace Drupal\countdown\Form;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\countdown\CountdownConstants;
use Drupal\countdown\CountdownLibraryPluginManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Admin settings form for the Countdown module.
*
* This form provides configuration for countdown libraries, loading methods,
* visibility settings, and other module options. It features a live preview
* that updates via AJAX as settings are changed.
*/
class CountdownSettings extends ConfigFormBase {
/**
* The theme handler service.
*
* @var \Drupal\Core\Extension\ThemeHandlerInterface
*/
protected ThemeHandlerInterface $themeHandler;
/**
* The module handler service.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected ModuleHandlerInterface $moduleHandler;
/**
* The plugin manager.
*
* @var \Drupal\countdown\CountdownLibraryPluginManager
*/
protected CountdownLibraryPluginManager $pluginManager;
/**
* The language manager service.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected LanguageManagerInterface $languageManager;
/**
* Constructs a CountdownSettings object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager
* The typed config manager.
* @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler
* The theme handler.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\countdown\CountdownLibraryPluginManager $plugin_manager
* The plugin manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager service.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
TypedConfigManagerInterface $typed_config_manager,
ThemeHandlerInterface $theme_handler,
ModuleHandlerInterface $module_handler,
CountdownLibraryPluginManager $plugin_manager,
LanguageManagerInterface $language_manager,
) {
parent::__construct($config_factory, $typed_config_manager);
$this->themeHandler = $theme_handler;
$this->moduleHandler = $module_handler;
$this->pluginManager = $plugin_manager;
$this->languageManager = $language_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get('config.typed'),
$container->get('theme_handler'),
$container->get('module_handler'),
$container->get('plugin.manager.countdown_library'),
$container->get('language_manager')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'countdown_admin_settings';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return ['countdown.settings'];
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
$config = $this->config('countdown.settings');
// Method selection (Local vs CDN).
$form['method'] = [
'#type' => 'select',
'#title' => $this->t('Loading Method'),
'#options' => [
'local' => $this->t('Local (Installed Libraries)'),
'cdn' => $this->t('CDN (Remote Loading)'),
],
'#default_value' => $form_state->getValue('method') ?: $config->get('method') ?: 'local',
'#description' => $this->t('Choose how to load the countdown library. Local requires libraries to be installed in the libraries folder.'),
'#ajax' => [
'callback' => '::methodAjaxCallback',
'wrapper' => 'library-area-wrapper',
'event' => 'change',
'effect' => 'fade',
'progress' => ['type' => 'throbber', 'message' => $this->t('Loading libraries...')],
],
];
// Library selection area.
$form['library_area'] = [
'#type' => 'container',
'#attributes' => ['id' => 'library-area-wrapper'],
];
// Library select.
$current_method = $form_state->getValue('method') ?: $config->get('method') ?: 'local';
$library_options = $this->buildLibraryOptions($current_method);
$form['library_area']['library'] = [
'#type' => 'select',
'#title' => $this->t('Active Library'),
'#options' => $library_options,
'#empty_option' => $this->t('- None -'),
'#empty_value' => 'none',
'#default_value' => $form_state->getValue('library') ?: $config->get('library'),
'#description' => $this->t('Select the countdown library to use.'),
'#ajax' => [
'callback' => '::libraryAjaxCallback',
'wrapper' => 'library-ajax-wrapper',
'event' => 'change',
'progress' => ['type' => 'throbber', 'message' => $this->t('Loading library settings...')],
],
];
// Library-dependent elements container.
$form['library_area']['library_dependent'] = [
'#type' => 'container',
'#attributes' => ['id' => 'library-ajax-wrapper'],
];
$this->buildLibraryDependentElements($form['library_area']['library_dependent'], $form_state);
// Loading behavior section.
$form['loading'] = [
'#type' => 'details',
'#title' => $this->t('Loading Behavior'),
'#open' => TRUE,
];
$form['loading']['load'] = [
'#type' => 'checkbox',
'#title' => $this->t('Automatically load library on pages'),
'#default_value' => $config->get('load'),
'#description' => $this->t('If enabled, the selected countdown library will be automatically loaded on pages based on the visibility settings below.'),
];
// Build variant.
$form['loading']['build'] = [
'#type' => 'fieldset',
'#title' => $this->t('Build Variant'),
'#states' => [
'visible' => [
':input[name="load"]' => ['checked' => TRUE],
],
],
];
$form['loading']['build']['variant'] = [
'#type' => 'radios',
'#title' => $this->t('Library version'),
'#options' => [
0 => $this->t('Development (unminified, with source maps)'),
1 => $this->t('Production (minified, optimized)'),
],
'#default_value' => (int) $config->get('build.variant'),
'#description' => $this->t('Production builds are smaller and load faster. Development builds are easier to debug.'),
'#ajax' => [
'callback' => '::previewAjaxCallback',
'wrapper' => 'countdown-preview-wrapper',
],
];
// Compute RTL availability once for conditional element building.
$has_rtl = $this->hasAnyRtlLanguage();
// RTL support.
if ($has_rtl) {
// Show the checkbox only when an RTL language exists.
$form['loading']['rtl'] = [
'#type' => 'checkbox',
'#title' => $this->t('RTL Support'),
'#default_value' => $config->get('rtl') ?? FALSE,
'#description' => $this->t('Enable Right-to-Left language support for countdown displays.'),
'#states' => [
'visible' => [
':input[name="load"]' => ['checked' => TRUE],
],
],
'#ajax' => [
'callback' => '::previewAjaxCallback',
'wrapper' => 'countdown-preview-wrapper',
],
];
}
else {
// Hide the field entirely and force a FALSE value on submit.
// A value element participates in form values without rendering.
$form['loading']['rtl'] = [
'#type' => 'value',
'#value' => 0,
];
}
// Advanced settings.
$form['advanced'] = [
'#type' => 'details',
'#title' => $this->t('Advanced Settings'),
'#open' => FALSE,
];
$form['advanced']['cache_lifetime'] = [
'#type' => 'number',
'#title' => $this->t('Cache Lifetime'),
'#default_value' => $config->get('advanced.cache_lifetime') ?: 86400,
'#description' => $this->t('How long to cache library discovery results in seconds. Default is 24 hours (86400 seconds).'),
'#min' => 0,
'#max' => 604800,
];
$form['advanced']['debug_mode'] = [
'#type' => 'checkbox',
'#title' => $this->t('Debug Mode'),
'#default_value' => $config->get('advanced.debug_mode') ?? FALSE,
'#description' => $this->t('Enable detailed logging for troubleshooting library loading issues.'),
'#ajax' => [
'callback' => '::previewAjaxCallback',
'wrapper' => 'countdown-preview-wrapper',
],
];
// Visibility settings.
$this->buildVisibilitySettings($form, $config);
// Sidebar container for preview and meta.
$form['countdown_sidebar'] = [
'#type' => 'container',
'#accordion' => TRUE,
];
// Preview element in sidebar.
$form['preview'] = [
'#type' => 'container',
'#group' => 'countdown_sidebar',
'#weight' => -10,
];
// Use the countdown_preview element.
$form['preview']['content'] = [
'#type' => 'countdown_preview',
'#form_state' => $form_state,
'#attributes' => ['id' => 'countdown-preview-wrapper'],
];
// Meta element in sidebar.
$form['meta'] = [
'#type' => 'container',
'#group' => 'countdown_sidebar',
];
$form['meta']['content'] = [
'#type' => 'countdown_meta',
'#form_state' => $form_state,
'#attributes' => ['id' => 'library-meta-wrapper'],
];
// Hidden field to persist library status details open/closed state.
$form['hide_status'] = [
'#type' => 'hidden',
'#default_value' => (bool) $config->get('hide'),
'#attributes' => ['data-drupal-selector' => 'edit-hide-status'],
];
$form['countdown_actions'] = [
'#type' => 'container',
'#weight' => -1,
'#multilingual' => TRUE,
'#attributes' => ['class' => ['countdown-sticky', 'gin-sticky', 'gin-sticky-form-actions']],
];
// Move primary action buttons into a dedicated sidebar container.
if (isset($form['actions'])) {
// Move submit/cancel buttons into the action container.
$form['countdown_actions']['actions'] = $form['actions'] ?? [];
$form['countdown_actions']['actions']['#weight'] = 120;
unset($form['actions']);
}
// Add visual toggler to open/close sidebar panel.
$hide_panel = $this->t('Hide sidebar panel');
$form['countdown_actions']['countdown_sidebar_toggle'] = [
'#markup' => '
<a href="#toggle-sidebar" class="meta-sidebar__trigger trigger" role="button" title="' . $hide_panel . '" aria-controls="countdown-sidebar">
<span class="visually-hidden">' . $hide_panel . '</span>
</a>',
'#weight' => 999,
];
// Add close button inside sidebar.
$close_panel = $this->t('Close sidebar panel');
$form['countdown_actions']['countdown_sidebar_close'] = [
'#markup' => '
<a href="#close-sidebar" class="meta-sidebar__close trigger" role="button" title="' . $close_panel . '">
<span class="visually-hidden">' . $close_panel . '</span>
</a>',
];
// Add transparent overlay when sidebar is open.
$form['countdown_sidebar_overlay'] = [
'#markup' => '<div class="meta-sidebar__overlay trigger"></div>',
];
// Attach admin library.
$form['#attached']['library'][] = 'countdown/admin';
// Add cache contexts.
$form['#cache']['contexts'][] = 'user.permissions';
$form['#cache']['tags'][] = CountdownConstants::CACHE_TAG_SETTINGS;
// Final override: render this form with custom theme if defined.
$form['#theme'] = 'countdown_form';
return $form;
}
/**
* Build library-dependent elements.
*
* @param array $element
* The element to build into.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*/
protected function buildLibraryDependentElements(array &$element, FormStateInterface $form_state): void {
$config = $this->config('countdown.settings');
$method = $form_state->getValue('method') ?: $config->get('method') ?: 'local';
$library = $form_state->getValue('library') ?: $config->get('library');
// CDN provider selection.
if ($method === 'cdn' && $library && $library !== 'none') {
// Build translatable option labels using a variable placeholder.
$providers = [
'jsdelivr' => 'jsDelivr',
'unpkg' => 'unpkg',
];
$provider_options = [];
foreach ($providers as $id => $label) {
// Use a placeholder to keep labels variable and translatable.
$provider_options[$id] = $this->t('@label', ['@label' => $label]);
}
$element['cdn_provider'] = [
'#type' => 'select',
'#title' => $this->t('CDN Provider'),
'#options' => $provider_options,
'#default_value' => $form_state->getValue('cdn_provider') ?: $config->get('cdn.provider') ?: 'jsdelivr',
'#ajax' => [
'callback' => '::previewAjaxCallback',
'wrapper' => 'countdown-preview-wrapper',
],
];
}
// Extensions for Tick library.
if ($library === 'tick') {
$element['extensions'] = [
'#type' => 'details',
'#title' => $this->t('Tick Extensions'),
'#open' => TRUE,
];
$plugin = $this->pluginManager->getPlugin('tick');
if ($plugin && $plugin->hasExtensions()) {
$extensions = $plugin->getAvailableExtensions();
$saved = $config->get("file_assets.extensions.tick") ?: [];
foreach ($extensions as $ext_id => $ext_info) {
$element['extensions'][$ext_id] = [
'#type' => 'checkbox',
'#title' => $ext_info['label'],
'#default_value' => in_array($ext_id, $saved),
'#ajax' => [
'callback' => '::previewAjaxCallback',
'wrapper' => 'countdown-preview-wrapper',
],
];
}
}
}
}
/**
* Build visibility settings.
*
* @param array $form
* The form array.
* @param object $config
* The configuration object.
*/
protected function buildVisibilitySettings(array &$form, $config): void {
$form['visibility'] = [
'#type' => 'vertical_tabs',
'#title' => $this->t('Visibility'),
];
// Theme visibility.
$form['theme_visibility'] = [
'#type' => 'details',
'#title' => $this->t('Themes'),
'#open' => TRUE,
'#group' => 'visibility',
];
$themes = $this->themeHandler->listInfo();
$theme_options = [];
foreach ($themes as $theme_name => $theme) {
if ($theme->status) {
$theme_options[$theme_name] = $theme->info['name'];
}
}
$form['theme_visibility']['themes'] = [
'#type' => 'select',
'#title' => $this->t('Themes'),
'#options' => $theme_options,
'#multiple' => TRUE,
'#default_value' => $config->get('theme_groups.themes') ?: [],
'#description' => $this->t('Select themes where the countdown library should be loaded.'),
];
$form['theme_visibility']['theme_negate'] = [
'#type' => 'radios',
'#title' => $this->t('Load on'),
'#options' => [
0 => $this->t('All themes except those selected'),
1 => $this->t('Only the selected themes'),
],
'#default_value' => (int) $config->get('theme_groups.negate'),
];
// Page visibility.
$form['page_visibility'] = [
'#type' => 'details',
'#title' => $this->t('Pages'),
'#group' => 'visibility',
];
$pages = $config->get('request_path.pages') ?: [];
if (is_array($pages)) {
$pages = implode("\n", $pages);
}
$form['page_visibility']['pages'] = [
'#type' => 'textarea',
'#title' => $this->t('Pages'),
'#default_value' => $pages,
'#description' => $this->t("Specify pages by using their paths. Enter one path per line. The '*' character is a wildcard. An example path is %admin-wildcard for every admin page. %front is the front page. You can also exclude pages by prefixing with '~'.", [
'%admin-wildcard' => '/admin/*',
'%front' => '<front>',
]),
];
$form['page_visibility']['page_negate'] = [
'#type' => 'radios',
'#title' => $this->t('Load on'),
'#options' => [
0 => $this->t('All pages except those listed'),
1 => $this->t('Only the listed pages'),
],
'#default_value' => (int) $config->get('request_path.negate'),
];
}
/**
* Build library options based on method.
*
* @param string $method
* The loading method ('local' or 'cdn').
*
* @return array
* Array of library options.
*/
protected function buildLibraryOptions(string $method): array {
if ($method === 'local') {
return $this->pluginManager->getPluginOptions(TRUE, TRUE, FALSE);
}
$options = [];
foreach ($this->pluginManager->getCdnCapablePlugins() as $plugin_id => $plugin) {
$label = $plugin->getLabel();
if ($version = $plugin->getRequiredVersion()) {
$label .= ' (v' . $version . ')';
}
$options[$plugin_id] = $label;
}
return $options;
}
/**
* AJAX callback for method changes.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The AJAX response.
*/
public function methodAjaxCallback(array &$form, FormStateInterface $form_state): AjaxResponse {
$response = new AjaxResponse();
// Replace the library area wrapper.
$response->addCommand(new ReplaceCommand('#library-area-wrapper', $form['library_area']));
// Update the meta and preview as well since library might have changed.
$response->addCommand(new ReplaceCommand('#library-meta-wrapper', $form['meta']['content']));
$response->addCommand(new ReplaceCommand('#countdown-preview-wrapper', $form['preview']['content']));
return $response;
}
/**
* AJAX callback for library changes.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The AJAX response.
*/
public function libraryAjaxCallback(array &$form, FormStateInterface $form_state): AjaxResponse {
$response = new AjaxResponse();
// Update library-dependent elements.
$response->addCommand(new ReplaceCommand('#library-ajax-wrapper', $form['library_area']['library_dependent']));
// Update meta to reflect new library info.
$response->addCommand(new ReplaceCommand('#library-meta-wrapper', $form['meta']['content']));
// Update preview with new library.
$response->addCommand(new ReplaceCommand('#countdown-preview-wrapper', $form['preview']['content']));
return $response;
}
/**
* AJAX callback for preview updates.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The AJAX response.
*/
public function previewAjaxCallback(array &$form, FormStateInterface $form_state): AjaxResponse {
$response = new AjaxResponse();
// Only update the preview and meta.
$response->addCommand(new ReplaceCommand('#library-meta-wrapper', $form['meta']['content']));
$response->addCommand(new ReplaceCommand('#countdown-preview-wrapper', $form['preview']['content']));
return $response;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$values = $form_state->getValues();
// Process pages into array.
$pages_raw = $values['pages'] ?? '';
$pages = $pages_raw !== '' ? _countdown_string_to_array($pages_raw) : [];
// Process extensions if present.
$library_extensions = [];
$current_library = $values['library'];
if ($current_library && $current_library !== 'none') {
$plugin = $this->pluginManager->getPlugin($current_library);
if ($plugin && $plugin->hasExtensions()) {
// Get extensions values.
$extensions_values = $values['extensions'] ?? [];
$enabled = [];
// Handle grouped extensions.
foreach ($extensions_values as $key => $value) {
if (is_array($value)) {
// This is a group.
foreach ($value as $ext_id => $enabled_flag) {
if ($enabled_flag) {
$enabled[] = $ext_id;
}
}
}
else {
// This is a direct extension (not grouped).
if ($value) {
$enabled[] = $key;
}
}
}
$library_extensions[$current_library] = $enabled;
}
}
// Enforce FALSE when no RTL language exists to prevent stale settings.
$rtl = $this->hasAnyRtlLanguage() && $form_state->getValue('rtl');
// Save configuration.
$config = $this->config('countdown.settings');
$config
->set('method', $values['method'])
->set('cdn.provider', $values['cdn_provider'] ?? 'jsdelivr')
->set('library', $values['library'])
->set('hide', $values['hide_status'])
->set('load', $values['load'])
->set('build.variant', (bool) $values['variant'])
->set('rtl', $rtl)
->set('theme_groups.negate', (int) $values['theme_negate'])
->set('theme_groups.themes', array_filter($values['themes'] ?? []))
->set('request_path.negate', (int) $values['page_negate'])
->set('request_path.pages', $pages)
->set('advanced.cache_lifetime', (int) ($values['cache_lifetime'] ?? 86400))
->set('advanced.debug_mode', (bool) ($values['debug_mode'] ?? FALSE));
// Save extensions configuration.
foreach ($library_extensions as $lib_id => $ext_list) {
$config->set("file_assets.extensions.{$lib_id}", $ext_list);
}
$config->save();
// Clear relevant caches.
Cache::invalidateTags([
CountdownConstants::CACHE_TAG_SETTINGS,
CountdownConstants::CACHE_TAG_DISCOVERY,
'library_info',
]);
// Clear plugin manager cache.
$this->pluginManager->resetAllCaches();
parent::submitForm($form, $form_state);
}
/**
* Checks if there is at least one RTL language configured on the site.
*
* Scans all languages (configured and available) and returns early on the
* first RTL match. This is used to conditionally show the RTL checkbox in
* the settings form.
*
* @return bool
* TRUE if any RTL language exists, FALSE otherwise.
*/
protected function hasAnyRtlLanguage(): bool {
// Request all known languages to catch configured and fallback entries.
$languages = $this->languageManager->getLanguages(LanguageInterface::STATE_ALL);
// Iterate once and return early on the first RTL language found.
foreach ($languages as $language) {
if ($language->getDirection() === LanguageInterface::DIRECTION_RTL) {
return TRUE;
}
}
return FALSE;
}
}
