fullcalendar_block-1.0.0-rc4/src/Plugin/Block/FullCalendarBlock.php
src/Plugin/Block/FullCalendarBlock.php
<?php
namespace Drupal\fullcalendar_block\Plugin\Block;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Component\Serialization\Exception\InvalidDataTypeException;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Ajax\AjaxHelperTrait;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Serialization\Yaml;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a FullCalendar Block.
*
* @Block(
* id = "fullcalendar_block",
* admin_label = @Translation("FullCalendar block"),
* category = @Translation("Calendar"),
* )
*/
class FullCalendarBlock extends BlockBase implements ContainerFactoryPluginInterface {
use AjaxHelperTrait;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* The config factory service.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The entity manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The token service.
*
* @var \Drupal\Core\Utility\Token
*/
protected $token;
/**
* The Request Stack service.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
// @phpstan-ignore-next-line
$instance = new static($configuration, $plugin_id, $plugin_definition);
$instance->time = $container->get('datetime.time');
$instance->configFactory = $container->get('config.factory');
$instance->languageManager = $container->get('language_manager');
$instance->moduleHandler = $container->get('module_handler');
$instance->entityTypeManager = $container->get('entity_type.manager');
$instance->token = $container->get('token');
$instance->requestStack = $container->get('request_stack');
return $instance;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'event_source' => '',
'use_token' => FALSE,
'initial_view' => 'dayGridMonth',
'header_start' => 'prev,next today',
'header_center' => 'title',
'header_end' => 'dayGridMonth,timeGridWeek,timeGridDay,listMonth',
'open_dialog' => 1,
'dialog_width' => 800,
'advanced' => '',
'advanced_drupal' => '',
'plugins' => [],
];
}
/**
* {@inheritdoc}
*/
public function build() {
$block_index = $this->generateBlockIndex();
$config = $this->getConfiguration();
$event_url = $this->resolveEventUrl($config['event_source']);
$initial_view = $config['initial_view'];
$header_start = $config['header_start'];
$header_center = $config['header_center'];
$header_end = $config['header_end'];
$dialog_open = $config['open_dialog'];
$dialog_width = $config['dialog_width'];
$advanced_settings = $config['advanced'];
// Fullcalendar options.
$calendar_options = [
'initialView' => $initial_view,
'events' => $event_url,
'headerToolbar' => [
'start' => $header_start,
'center' => $header_center,
'end' => $header_end,
],
// Pick up the default localization settings from Drupal.
// https://fullcalendar.io/docs/localization
'firstDay' => $this->configFactory->get('system.date')->get('first_day') ?? 0,
'direction' => $this->languageManager->getCurrentLanguage()->getDirection(),
'locale' => $this->languageManager->getCurrentLanguage()->getId(),
];
if (!empty($advanced_settings)) {
$calendar_options = array_merge($calendar_options, (array) $this->decodeAdvancedSettings($advanced_settings));
}
$block_settings = [
'calendar_options' => $calendar_options,
'dialog_open' => $dialog_open,
'dialog_width' => $dialog_width,
// Advanced Drupal settings to control the dialog behaviours amongst
// other things. Ideally all these would be individual configs, but this
// is the most flexible.
'advanced' => $this->decodeAdvancedSettings($config['advanced_drupal']),
];
$block_content = [
'#theme' => 'fullcalendar_block',
'#block_index' => $block_index,
];
// Allow other modules to alter the block settings.
$this->moduleHandler->alter('fullcalendar_block_settings', $block_settings, $block_content, $this);
// The block settings.
$block_index = $block_content['#block_index'];
$block_content['#attached']['drupalSettings']['fullCalendarBlock'][$block_index] = $block_settings;
// Attach the libraries.
if (!empty($block_settings['advanced']['draggable']) && $this->moduleHandler->moduleExists('jquery_ui_draggable')) {
$block_content['#attached']['library'][] = 'jquery_ui_draggable/draggable';
}
if (!empty($block_settings['advanced']['resizable']) && $this->moduleHandler->moduleExists('jquery_ui_resizable')) {
$block_content['#attached']['library'][] = 'jquery_ui_resizable/resizable';
}
if (!empty($block_settings['advanced']['description_popup'])) {
// Advanced popup is supported, add the DOMPurify library to sanitize
// with.
$block_content['#attached']['library'][] = 'fullcalendar_block/libraries.dompurify';
}
// Add moment.js support.
if (in_array('moment', $this->configuration['plugins'], TRUE)) {
$block_content['#attached']['library'][] = 'fullcalendar_block/libraries.fullcalendar_moment';
}
// Add rrule support.
if (in_array('rrule', $this->configuration['plugins'], TRUE)) {
$block_content['#attached']['library'][] = 'fullcalendar_block/libraries.fullcalendar_rrule';
}
// Add the fullcalendar library.
$block_content['#attached']['library'][] = 'fullcalendar_block/fullcalendar';
return $block_content;
}
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state) {
$form = parent::blockForm($form, $form_state);
$config = $this->getConfiguration();
$form['event_source'] = [
'#type' => 'textfield',
'#title' => $this->t('Event source URL'),
'#description' => $this->t('The URL where the calendar events data feeds'),
'#default_value' => $config['event_source'],
'#required' => TRUE,
];
$form['use_token'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable tokens'),
'#description' => $this->t('Enable the use of tokens for the event source URL'),
'#default_value' => $config['use_token'],
];
// Token support.
if ($this->moduleHandler->moduleExists('token')) {
$form['tokens'] = [
'#title' => $this->t('Tokens (for the event source URL only)'),
'#type' => 'container',
'#states' => [
'invisible' => [
'input[name="settings[use_token]"]' => ['checked' => FALSE],
],
],
];
$form['tokens']['help'] = [
'#theme' => 'token_tree_link',
'#token_types' => 'all',
'#global_types' => TRUE,
'#dialog' => TRUE,
];
}
$form['initial_view'] = [
'#type' => 'textfield',
'#title' => $this->t('Initial View'),
'#description' => $this->t('The initial view of the calendar'),
'#default_value' => $config['initial_view'],
];
// Header toolbar settings.
$form['header_toolbar'] = [
'#type' => 'details',
'#title' => $this->t('Header Toolbar'),
'#description' => $this->t('Header toolbar of the calendar. <br/>See <a href=":url" target="_blank" rel="noopener">the help document</a> for available options.', [
':url' => 'https://fullcalendar.io/docs/headerToolbar',
]),
];
$form['header_toolbar']['header_start'] = [
'#type' => 'textfield',
'#title' => $this->t('Start of the header toolbar'),
'#description' => $this->t('Start area will normally be on the left. if RTL, will be on the right'),
'#default_value' => $config['header_start'],
];
$form['header_toolbar']['header_center'] = [
'#type' => 'textfield',
'#title' => $this->t('Center of the header toolbar'),
'#description' => $this->t('The default value is title if leave this empty'),
'#default_value' => $config['header_center'],
];
$form['header_toolbar']['header_end'] = [
'#type' => 'textfield',
'#title' => $this->t('End of the header toolbar'),
'#description' => $this->t('The default value is Week and Day view if leave this empty'),
'#default_value' => $config['header_end'],
];
// Click event settings.
$form['click_event'] = [
'#type' => 'details',
'#title' => $this->t('Click event settings'),
];
$form['click_event']['open_dialog'] = [
'#type' => 'radios',
'#title' => $this->t('Click on an event'),
'#options' => [
0 => $this->t('Open in a new tab'),
1 => $this->t('Open in a dialog'),
2 => $this->t('Open in current tab'),
],
'#default_value' => $config['open_dialog'],
'#attributes' => [
// Define static data condition attribute so we can easier select it.
'data-condition' => 'field-open-dialog',
],
];
$form['click_event']['dialog_width'] = [
'#type' => 'number',
'#title' => $this->t('Dialog width'),
'#default_value' => $config['dialog_width'],
'#size' => '5',
'#min' => 0,
'#states' => [
// Show this textfield only if 'open in a dialog' is selected above.
'visible' => [
':input[data-condition="field-open-dialog"]' => ['value' => 1],
],
],
];
// Advanced settings.
$form['advanced'] = [
'#type' => 'details',
'#title' => $this->t('Advanced settings'),
'#open' => !empty($config['advanced']) || !empty($config['advanced_drupal']) || !empty($config['plugins']),
];
$form['advanced']['plugins'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Fullcalendar plugins'),
'#description' => $this->t('<a href=":url" target="_blank" rel="noopener">Fullcalendar plugins</a> to enable integration for.', [
':url' => 'https://fullcalendar.io/docs/plugin-index',
]),
'#default_value' => $config['plugins'],
'#options' => [
// Add support for moment, to help with unsupported locales.
// @see https://github.com/fullcalendar/fullcalendar/issues/5565
'moment' => $this->t('Moment <small>[<a href=":url" target="_blank" rel="noopener">docs</a>]</small>', [
':url' => 'https://fullcalendar.io/docs/moment-plugin',
]),
'rrule' => $this->t('RRule <small>[<a href=":url" target="_blank" rel="noopener">docs</a>]</small>', [
':url' => 'https://fullcalendar.io/docs/rrule-plugin',
]),
],
];
$form['advanced']['addition'] = [
'#type' => 'textarea',
'#title' => $this->t('Advanced settings'),
'#default_value' => $config['advanced'],
'#description' => $this->t('It must be in valid JSON/YAML format.<br/>See <a href=":url" target="_blank" rel="noopener">Fullcalendar documentations</a> for available options.', [
':url' => 'https://fullcalendar.io/docs#toc',
]),
'#element_validate' => [[$this, 'validateAdvancedSettings']],
'#attributes' => [
'spellcheck' => 'false',
],
];
$form['advanced']['addition_drupal'] = [
'#type' => 'textarea',
'#title' => $this->t('Advanced Drupal settings'),
'#default_value' => $config['advanced_drupal'],
'#description' => $this->t('It must be in valid JSON/YAML format. This controls the advanced Fullcalendar block behaviours.'),
'#element_validate' => [[$this, 'validateAdvancedSettings']],
'#attributes' => [
'spellcheck' => 'false',
],
];
if (!$this->isAjax()) {
// Ignore the YAML editor enhancements when in an AJAX request. e.g.
// Layout Builder. It can result in weird behaviours with how assets are
// loaded in.
if ($this->moduleHandler->moduleExists('codemirror_editor')) {
// Integrates with https://www.drupal.org/project/codemirror_editor if
// available (only certain options are passed in from PHP).
$form['advanced']['addition']['#codemirror'] =
$form['advanced']['addition_drupal']['#codemirror'] = [
'mode' => 'text/x-yaml',
'buttons' => ['undo', 'redo'],
'lineNumbers' => TRUE,
'lineWrapping' => TRUE,
];
}
elseif ($this->moduleHandler->moduleExists('webform')) {
// Piggyback off the webform module's YAML editor.
$form['advanced']['addition']['#type'] =
$form['advanced']['addition_drupal']['#type'] = 'webform_codemirror';
$form['advanced']['addition']['#mode'] =
$form['advanced']['addition_drupal']['#mode'] = 'yaml';
// We'll handle the validation ourself.
$form['advanced']['addition']['#skip_validation'] =
$form['advanced']['addition_drupal']['#skip_validation'] = TRUE;
}
elseif ($this->moduleHandler->moduleExists('yaml_editor')) {
// Integrates with https://www.drupal.org/project/yaml_editor if
// available.
$form['advanced']['addition']['#attributes']['data-yaml-editor'] =
$form['advanced']['addition_drupal']['#attributes']['data-yaml-editor'] = TRUE;
}
}
return $form;
}
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state) {
parent::blockSubmit($form, $form_state);
$values = $form_state->getValues();
$this->configuration['event_source'] = $values['event_source'];
$this->configuration['use_token'] = (bool) $values['use_token'];
$this->configuration['initial_view'] = $values['initial_view'];
$this->configuration['header_start'] = $values['header_toolbar']['header_start'];
$this->configuration['header_center'] = $values['header_toolbar']['header_center'];
$this->configuration['header_end'] = $values['header_toolbar']['header_end'];
$this->configuration['open_dialog'] = (int) $values['click_event']['open_dialog'];
$this->configuration['dialog_width'] = (int) $values['click_event']['dialog_width'];
$this->configuration['plugins'] = array_keys(array_filter($values['advanced']['plugins']));
// Normalize the line endings.
// https://www.drupal.org/node/3114725
$this->configuration['advanced'] = trim(str_replace(["\r\n", "\r"], "\n", $values['advanced']['addition']));
$this->configuration['advanced_drupal'] = trim(str_replace(["\r\n", "\r"], "\n", $values['advanced']['addition_drupal']));
}
/**
* Callback to validate that the configuration is a valid YAML/JSON object.
*/
public function validateAdvancedSettings(array $element, FormStateInterface $form_state) {
try {
$result = $this->decodeAdvancedSettings($element['#value'], TRUE);
if (!is_array($result)) {
$form_state->setError($element, $this->t('%field must be a valid JSON/JSON object. %type returned.', [
'%field' => $element['#title'],
'%type' => gettype($result),
]));
}
}
catch (InvalidDataTypeException $e) {
$form_state->setError($element, $this->t('%field must be a valid JSON/JSON object: @error.', [
'%field' => $element['#title'],
]));
}
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
$cache_contexts = parent::getCacheContexts();
$event_url = $this->configuration['event_source'];
if ($event_url && !UrlHelper::isExternal($event_url)) {
// Relative link, cache by the URL path.
$cache_contexts = Cache::mergeContexts($cache_contexts, ['url.path']);
}
if ($this->languageManager->isMultilingual()) {
// Configurations may change based on the current locale.
$cache_contexts = Cache::mergeContexts($cache_contexts, ['languages:' . LanguageInterface::TYPE_CONTENT]);
}
return $cache_contexts;
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
$cache_tags = parent::getCacheTags();
// May change when the "First day of week" is updated.
return Cache::mergeTags($cache_tags, ['config:system.date']);
}
/**
* Decode advanced settings into an array object.
*
* YAML is a superset of JSON, and should be easily supported.
*/
protected function decodeAdvancedSettings($settings, $validate = FALSE) {
$settings = trim($settings);
if ($settings) {
try {
return Yaml::decode($settings);
}
catch (InvalidDataTypeException $e) {
/*
* Work around issue with symfony/yaml sometimes being unable to parse
* simple JSON as it's not fully compliant with the YAML spec.
* https://github.com/symfony/symfony/issues/39011
*/
$result = json_decode($settings, TRUE);
if (json_last_error() === JSON_ERROR_NONE) {
return $result;
}
if ($validate) {
throw $e;
}
}
}
return [];
}
/**
* Resolves the current event URL relative to the current URL.
*
* Applying the appropriate URL prefixes to the endpoint as necessary.
* Which is useful for multilingual Drupal instances.
*
* Base relative links are left as is, whereas path relative links ('/') will
* be processed and appropriately prefixed by Drupal.
*
* @param string $event_url
* The current URL.
*
* @return string
* The URL with the appropriate base prefix applied.
*/
protected function resolveEventUrl($event_url) {
if ($this->configuration['use_token']) {
$event_url = $this->resolveUrlTokens($event_url);
}
if ($event_url && $event_url[0] === '/' && !UrlHelper::isExternal($event_url)) {
if (!file_exists(DRUPAL_ROOT . $event_url)) {
// Not a static file, resolve the relative path as normal.
return Url::fromUri('internal:' . $event_url)->toString();
}
}
return $event_url;
}
/**
* Replace the tokens within the URL.
*/
protected function resolveUrlTokens($event_url) {
$types = [];
foreach ($this->requestStack->getCurrentRequest()->attributes as $attribute_name => $attribute_value) {
if ($attribute_value instanceof EntityInterface) {
$types[$attribute_value->getEntityTypeId()] = $attribute_value;
}
elseif (is_string($attribute_value) || is_numeric($attribute_value)) {
// If there's no param enhancer applied, attempt to load the entity from
// its entity storage. e.g. on node previews.
try {
$entity_type_storage = $this->entityTypeManager->getStorage($attribute_name);
$entity = $entity_type_storage->load($attribute_value);
if ($entity instanceof EntityInterface) {
$types[$entity->getEntityTypeId()] = $entity;
}
}
catch (InvalidPluginDefinitionException | PluginNotFoundException $ignore) {
}
}
}
return $this->token->replace($event_url, $types, [
// Don't clear in case there's special post-processing necessary that
// exists outside the normal token API.
'clear' => FALSE,
'langcode' => $this->languageManager->getCurrentLanguage()->getId(),
]);
}
/**
* Generate a unique block index identifier.
*
* Our JavaScript needs to have some means to find the HTML belonging to
* this block. Borrowing from views' "dom_id" implementation.
*
* In order to unequivocally match a block with its HTML, because multiple
* calendar blocks may appear several times on the page.
* We set up a hash with the current time, plugin_id, to issue a
* "unique" identifier for each block. This identifier is used in the
* drupalSettings and stored in the 'data-calendar-block-index' attribute of
* the fullcalendar_block DIV.
*
* @return string
* The unique block index.
*
* @see template_preprocess_fullcalendar_block()
*/
protected function generateBlockIndex() {
return hash('sha256', $this->getPluginId() . $this->time->getRequestTime() . mt_rand());
}
}
