diff --git a/scheduler_repeat/scheduler_repeat.info.yml b/scheduler_repeat/scheduler_repeat.info.yml new file mode 100644 index 000000000..e5c8b114d --- /dev/null +++ b/scheduler_repeat/scheduler_repeat.info.yml @@ -0,0 +1,8 @@ +name: Scheduler Repeat +type: module +description: 'Allows content editors specifying repeating schedule for publishing and unpublishing.' +core: 8.x +core_version_requirement: ^8 || ^9 +dependencies: + - drupal:node + - scheduler:scheduler diff --git a/scheduler_repeat/scheduler_repeat.module b/scheduler_repeat/scheduler_repeat.module new file mode 100644 index 000000000..1170fc7ef --- /dev/null +++ b/scheduler_repeat/scheduler_repeat.module @@ -0,0 +1,219 @@ +id() === 'node') { + $fields['scheduler_repeat'] = BaseFieldDefinition::create('scheduler_repeater') + ->setLabel(t('Scheduler Repeat')) + ->setDisplayOptions('form', [ + 'type' => 'scheduler_repeater_widget', + 'weight' => 31, + ]) + ->setDisplayConfigurable('form', TRUE) + ->setTranslatable(TRUE) + ->setRevisionable(TRUE) + ->addConstraint('SchedulerRepeat'); + + return $fields; + } +} + +/** + * Implements hook_form_FORM_ID_alter() for node_form(). + */ +function scheduler_repeat_form_node_form_alter(&$form, FormStateInterface $form_state) { + if (!isset($form['publish_on']) || !isset($form['unpublish_on'])) { + // Remove the repeat selection field. + // @todo What if scheduler_form_node_form_alter() is invoked AFTER this? + // @todo What if scheduler date fields are configured as hidden? + unset($form['scheduler_repeat']); + return; + } + // Move the repeat widget to the 'scheduler_settings' fieldset. + $form['scheduler_repeat']['#group'] = 'scheduler_settings'; +} + +/** + * Implements hook_ENTITY_TYPE_presave() for node entities. + */ +function scheduler_repeat_node_presave(EntityInterface $node) { + if (!$repeater = _scheduler_repeat_get_repeater($node)) { + _scheduler_repeat_clear_next_occurence($node); + return; + } + + if (!$next_publish_on = _scheduler_repeat_next_publish_on($repeater, $node)) { + _scheduler_repeat_clear_next_occurence($node); + return; + } + + if (!$next_unpublish_on = _scheduler_repeat_next_unpublish_on($repeater, $node)) { + _scheduler_repeat_clear_next_occurence($node); + return; + } + + if ($next_publish_on > $next_unpublish_on) { + _scheduler_repeat_log_error( + '@repeater repeater calculated conflicting next occurence for node @nid: @from -> @to', + [ + '@repeater' => $node->scheduler_repeat->plugin, + '@nid' => $node->id(), + '@from' => date("Y-m-d H:i:s", $next_publish_on), + '@to' => date("Y-m-d H:i:s", $next_unpublish_on), + ] + ); + return; + } + + $node->set('scheduler_repeat', [ + 'plugin' => $node->scheduler_repeat->plugin, + 'next_publish_on' => $next_publish_on, + 'next_unpublish_on' => $next_unpublish_on, + ]); +} + +/** + * Remove the repeat plugin and both 'next' date values. + * + * @param \Drupal\node\NodeInterface $node + * The node object to update. + */ +function _scheduler_repeat_clear_next_occurence(NodeInterface &$node) { + $node->set('scheduler_repeat', [ + 'plugin' => NULL, + 'next_publish_on' => NULL, + 'next_unpublish_on' => NULL, + ]); +} + +/** + * Get the next publish_on date. + * + * If the existing publish_on date is empty, return the stored next_publish_on + * date if availbale. If publish_on date is set, use $repeater to calculate the + * next occurence until the value is in the future. + * + * @param \Drupal\scheduler_repeat\SchedulerRepeaterInterface $repeater + * The repeater plugin. + * @param \Drupal\node\NodeInterface $node + * The node to use. + * + * @return mixed|null + * The next publish_on date + */ +function _scheduler_repeat_next_publish_on(SchedulerRepeaterInterface $repeater, NodeInterface $node) { + if ($node->get('publish_on')->isEmpty()) { + return !empty($node->get('scheduler_repeat')->next_publish_on) ? $node->get('scheduler_repeat')->next_publish_on : NULL; + } + + $next_publish_on = $repeater->calculateNextPublishedOn($node->get('publish_on')->value); + $request_time = \Drupal::time()->getRequestTime(); + while ($next_publish_on < $request_time) { + $next_publish_on = $repeater->calculateNextPublishedOn($next_publish_on); + } + return $next_publish_on; +} + +/** + * Get the next unpublish_on date. + * + * If the existing unpublish_on date is empty, return the stored + * next_unpublish_on date if available. If unpublish_on date is set, use + * $repeater to calculate the next occurence until the value is in the future. + * + * @param \Drupal\scheduler_repeat\SchedulerRepeaterInterface $repeater + * The repeater plugin. + * @param \Drupal\node\NodeInterface $node + * The node to use. + * + * @return mixed|null + * The next unpublish_on date + */ +function _scheduler_repeat_next_unpublish_on(SchedulerRepeaterInterface $repeater, NodeInterface $node) { + if ($node->get('unpublish_on')->isEmpty()) { + return !empty($node->get('scheduler_repeat')->next_unpublish_on) ? $node->get('scheduler_repeat')->next_unpublish_on : NULL; + } + + $next_unpublish_on = $repeater->calculateNextUnpublishedOn($node->get('unpublish_on')->value); + $request_time = \Drupal::time()->getRequestTime(); + while ($next_unpublish_on < $request_time) { + $next_unpublish_on = $repeater->calculateNextUnpublishedOn($next_unpublish_on); + } + return $next_unpublish_on; +} + +/** + * Get the repeat plugin if one is defined for the node. + * + * @param \Drupal\node\NodeInterface $node + * The node object to check. + * + * @return \Drupal\scheduler_repeat\SchedulerRepeaterInterface|null + * The repeat plugin instance. + */ +function _scheduler_repeat_get_repeater(NodeInterface $node) { + if (empty($node->scheduler_repeat->plugin)) { + return NULL; + } + // @todo When we cater for optional associated data, the id can be extracted, + // and the other values added into $plugin_data. + $plugin_id = $node->scheduler_repeat->plugin; + $plugin_data = ['node' => $node]; + + /** @var \Drupal\scheduler_repeat\SchedulerRepeaterManager $scheduler_repeater_manager */ + $scheduler_repeater_manager = \Drupal::service('plugin.manager.scheduler_repeat.repeater'); + + /** @var \Drupal\scheduler_repeat\SchedulerRepeaterInterface $repeater */ + try { + $repeater = $scheduler_repeater_manager->createInstance($plugin_id, $plugin_data); + } + catch (PluginException $e) { + _scheduler_repeat_log_warning('Could not create scheduler repeater instance: @message', ['@message' => $e->getMessage()]); + return NULL; + } + + return $repeater; +} + +/** + * Write an error to the db log. + * + * @param string $message + * The message text. + * @param array $context + * Context variables for substitution. + * + * @todo This is only called once. Move into scheduler_repeat_node_presave?. + */ +function _scheduler_repeat_log_error(string $message, array $context) { + \Drupal::logger('scheduler_repeat')->error($message, $context); +} + +/** + * Write a warning to the db log. + * + * @param string $message + * The message text. + * @param array $context + * Context variables for substitution. + * + * @todo This is only called once. Move into _scheduler_repeat_get_repeater?. + */ +function _scheduler_repeat_log_warning(string $message, array $context) { + \Drupal::logger('scheduler_repeat')->warning($message, $context); +} diff --git a/scheduler_repeat/scheduler_repeat.services.yml b/scheduler_repeat/scheduler_repeat.services.yml new file mode 100644 index 000000000..b7bad8688 --- /dev/null +++ b/scheduler_repeat/scheduler_repeat.services.yml @@ -0,0 +1,9 @@ +services: + plugin.manager.scheduler_repeat.repeater: + class: Drupal\scheduler_repeat\SchedulerRepeaterManager + parent: default_plugin_manager + arguments: ['@entity_type.manager', '@config.factory'] + scheduler_repeat.event_subscriber: + class: Drupal\scheduler_repeat\EventSubscriber + tags: + - { name: event_subscriber } diff --git a/scheduler_repeat/src/Annotation/SchedulerRepeater.php b/scheduler_repeat/src/Annotation/SchedulerRepeater.php new file mode 100644 index 000000000..b2b50ad64 --- /dev/null +++ b/scheduler_repeat/src/Annotation/SchedulerRepeater.php @@ -0,0 +1,47 @@ +getNode(); + + // The content has now been unpublished so get the stored dates for the next + // period. We do not want to check if a repeat plugin exists, we only need + // to check if the two 'next' dates are available. In future we could set a + // 'stop after' date which would remove the repeat plugin but leave the last + // pair of 'next' dates for use here. + $next_publish_on = $node->get('scheduler_repeat')->next_publish_on; + $next_unpublish_on = $node->get('scheduler_repeat')->next_unpublish_on; + if (empty($next_publish_on) || empty($next_unpublish_on)) { + // Do not have both dates, so cannot set the next period. + return; + } + // Set the new period publish_on and unpublish_on values. + $node->set('publish_on', $next_publish_on); + $node->set('unpublish_on', $next_unpublish_on); + $event->setNode($node); + } + +} diff --git a/scheduler_repeat/src/InvalidPluginTypeException.php b/scheduler_repeat/src/InvalidPluginTypeException.php new file mode 100644 index 000000000..55d94bd3a --- /dev/null +++ b/scheduler_repeat/src/InvalidPluginTypeException.php @@ -0,0 +1,13 @@ +pluginManager = $container->get('plugin.manager.scheduler_repeat.repeater'); + $this->dateFormatter = $container->get('date.formatter'); + } + + /** + * {@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); + } + + /** + * {@inheritdoc} + */ + public function viewElements(FieldItemListInterface $items, $langcode) { + $elements = []; + foreach ($items as $delta => $item) { + $label = $this->pluginManager->getDefinition($item->plugin)['label']; + $occurence = $this->renderOccurence($item->getEntity()); + $elements[$delta] = [ + '#markup' => Xss::filter("$label ($occurence)"), + ]; + } + return $elements; + } + + /** + * Render the two next occurence dates, for use in views. + * + * @param \Drupal\node\NodeInterface $node + * The node object to use. + * + * @return string + * A string consisting of the next publish_on and unpublish_on dates. + * + * @throws \Drupal\Core\TypedData\Exception\MissingDataException + */ + protected function renderOccurence(NodeInterface $node) { + return $this->renderDate($node->get('scheduler_repeat')->next_publish_on) . ' - ' . $this->renderDate($node->get('scheduler_repeat')->next_unpublish_on); + } + + /** + * Render an individual date timestamp into a string. + * + * @todo Make this configurable? Pass a format parameter. + * + * @param int $timestamp + * The the date integer value to render. + * + * @return string + * The date rendered as a text value in 'short' format. + */ + protected function renderDate($timestamp) { + return $this->dateFormatter->format($timestamp, 'short'); + } + +} diff --git a/scheduler_repeat/src/Plugin/Field/FieldType/SchedulerRepeaterItem.php b/scheduler_repeat/src/Plugin/Field/FieldType/SchedulerRepeaterItem.php new file mode 100644 index 000000000..863fbe9d2 --- /dev/null +++ b/scheduler_repeat/src/Plugin/Field/FieldType/SchedulerRepeaterItem.php @@ -0,0 +1,130 @@ +setLabel('Repeat') + ->setDescription('Specifies the plugin and optionla data to be used for calculating the repeat schedule.'); + $properties['next_publish_on'] = DataDefinition::create('timestamp') + ->setLabel('Next publish on') + ->setDescription('The calculated next date and time for scheduled publishing.'); + $properties['next_unpublish_on'] = DataDefinition::create('timestamp') + ->setLabel('Next unpublish on') + ->setDescription('The calculated next date and time for scheduled unpublishing.'); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + $plugin = $this->get('plugin')->getValue(); + return $plugin === NULL || $plugin === '' || $plugin === 'none'; + } + + /** + * {@inheritdoc} + */ + public function getConstraints() { + $constraints = parent::getConstraints(); + + $constraint_manager = \Drupal::typedDataManager()->getValidationConstraintManager(); + $constraints[] = $constraint_manager->create('ComplexData', [ + 'plugin' => [ + 'Length' => [ + 'max' => self::COLUMN_PLUGIN_MAX_LENGTH, + 'maxMessage' => $this->t('%name: may not be longer than @max characters.', [ + '%name' => $this->getFieldDefinition()->getLabel(), + '@max' => self::COLUMN_PLUGIN_MAX_LENGTH, + ]), + ], + ], + 'next_publish_on' => [ + 'Length' => [ + 'max' => self::COLUMN_TIMESTAMP_MAX_LENGTH, + 'maxMessage' => $this->t('%name: may not be longer than @max characters.', [ + '%name' => $this->getFieldDefinition()->getLabel(), + '@max' => self::COLUMN_TIMESTAMP_MAX_LENGTH, + ]), + ], + ], + 'next_unpublish_on' => [ + 'Length' => [ + 'max' => self::COLUMN_TIMESTAMP_MAX_LENGTH, + 'maxMessage' => $this->t('%name: may not be longer than @max characters.', [ + '%name' => $this->getFieldDefinition()->getLabel(), + '@max' => self::COLUMN_TIMESTAMP_MAX_LENGTH, + ]), + ], + ], + ]); + + return $constraints; + } + + /** + * {@inheritdoc} + */ + public static function generateSampleValue(FieldDefinitionInterface $field_definition) { + $options = [ + 'hourly' => 'Hourly', + 'daily' => 'Daily', + 'weekly' => 'Weekly', + 'monthly' => 'Monthly', + 'yearly' => 'Yearly', + ]; + $values['plugin'] = array_rand($options); + $values['next_publish_on'] = rand(strtotime("+1 day"), strtotime("+3 days")); + // @todo Change unpublish time when we have more $options. + $values['next_unpublish_on'] = $values['next_publish_on'] + rand(1, 3600); + return $values; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + // Can alter this list to allow uninstall of old column names. + return [ + 'columns' => [ + 'plugin' => [ + 'type' => 'varchar', + 'length' => self::COLUMN_PLUGIN_MAX_LENGTH, + ], + 'next_publish_on' => [ + 'type' => 'int', + ], + 'next_unpublish_on' => [ + 'type' => 'int', + ], + ], + ]; + } + +} diff --git a/scheduler_repeat/src/Plugin/Field/FieldWidget/SchedulerRepeaterWidget.php b/scheduler_repeat/src/Plugin/Field/FieldWidget/SchedulerRepeaterWidget.php new file mode 100644 index 000000000..041267711 --- /dev/null +++ b/scheduler_repeat/src/Plugin/Field/FieldWidget/SchedulerRepeaterWidget.php @@ -0,0 +1,122 @@ +pluginManager = $container->get('plugin.manager.scheduler_repeat.repeater'); + $this->dateFormatter = $container->get('date.formatter'); + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static($container, $plugin_id, $plugin_definition, $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings']); + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + $formElement = [ + 'plugin' => [ + '#title' => $this->t('Repeat schedule'), + '#type' => 'select', + '#default_value' => isset($items->get($delta)->plugin) ? $items->get($delta)->plugin : NULL, + '#options' => $this->getRepeaterOptions(), + '#empty_option' => $this->t('None'), + '#empty_value' => 'none', + ], + 'next_publish_on' => $this->addNextDateElement($this->t('Next publish on'), $items->get($delta)->next_publish_on), + 'next_unpublish_on' => $this->addNextDateElement($this->t('Next unpublish on'), $items->get($delta)->next_unpublish_on), + ] + $element; + return $formElement; + } + + /** + * Add a read-only form item to store and display the next date. + * + * @param Drupal\Core\StringTranslation\TranslatableMarkup $title + * The title of the date item. + * @param int $value + * The date value, which may be empty. + * + * @return array + * A form element displaying the next date. + */ + protected function addNextDateElement(TranslatableMarkup $title, $value) { + // Create the element, even if the next date value is empty. + $element = [ + '#type' => 'item', + '#value' => $value, + ]; + + // If there is a value, display the title and the formatted value. + if ($value) { + $element['#title'] = $title; + $element['#markup'] = $this->dateFormatter->format($value); + } + + return $element; + } + + /** + * Get all Scheduler Repeat options. + * + * @return array + * An array of repeat options, for use in form selection element. + */ + protected function getRepeaterOptions() { + $plugin_definitions = $this->pluginManager->getDefinitions(); + // @todo Make the sorting more robust. If a plugin does not have 'weight' we + // get error "array_multisort(): Array sizes are inconsistent". + array_multisort(array_column($plugin_definitions, 'weight'), SORT_ASC, $plugin_definitions); + $options = []; + foreach ($plugin_definitions as $plugin_id => $plugin) { + /** @var \Drupal\Core\StringTranslation\TranslatableMarkup $label */ + $label = $plugin['label']; + $options[$plugin_id] = $label->render(); + } + return $options; + } + +} diff --git a/scheduler_repeat/src/Plugin/SchedulerRepeater/Daily.php b/scheduler_repeat/src/Plugin/SchedulerRepeater/Daily.php new file mode 100644 index 000000000..981cc25bd --- /dev/null +++ b/scheduler_repeat/src/Plugin/SchedulerRepeater/Daily.php @@ -0,0 +1,32 @@ +node = $options['node']; + } + +} diff --git a/scheduler_repeat/src/Plugin/SchedulerRepeater/SixMinutes.php b/scheduler_repeat/src/Plugin/SchedulerRepeater/SixMinutes.php new file mode 100644 index 000000000..d7cf2f12a --- /dev/null +++ b/scheduler_repeat/src/Plugin/SchedulerRepeater/SixMinutes.php @@ -0,0 +1,32 @@ +schedulerRepeatPluginManager = \Drupal::service('plugin.manager.scheduler_repeat.repeater'); + } + + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) { + if ($value->isEmpty()) { + return; + } + + /** @var \Drupal\node\NodeInterface $node */ + $this->node = $value->getEntity(); + if (!$this->shouldValidate()) { + return; + } + + // @todo When associated data is added, the plugin id can be extracted. + $plugin_id = $value->plugin; + + $this->repeater = $this->initializeRepeaterWithPlugin($plugin_id); + // If the calculated next publish_on value is earlier than the current + // unpublish_on value then the dates overlap. This means that the repeat + // period is too short so fail validation. + if ($this->repeater->calculateNextPublishedOn($this->getPublishOn()) <= $this->getUnpublishOn()) { + $this->context->buildViolation($constraint->messageRepeatPeriodSmallerThanScheduledPeriod) + ->atPath('scheduler_repeat') + ->addViolation(); + } + } + + /** + * Determine when validation should be applied. + * + * @return bool + * Whether to validate the repeat value. + */ + private function shouldValidate() { + // We need both dates in order to validate potentially conflicting periods. + // @todo Figure out how to handle $node that is in active scheduled period + return !$this->node->get('publish_on')->isEmpty() && !$this->node->get('unpublish_on')->isEmpty(); + } + + /** + * Gets publish_on date that exists in the node. + * + * @return int + * The current publish_on value. + */ + protected function getPublishOn() { + return $this->node->get('publish_on')->value; + } + + /** + * Gets unpublish_on date that exists in the node. + * + * @return int + * The current unpublish_on value. + */ + protected function getUnpublishOn() { + return $this->node->get('unpublish_on')->value; + } + + /** + * Create an instance of the required repeat plugin. + * + * @param string $plugin + * The plugin information. Currently this is just the plugin id, but could + * be expanded to hold additional associated data. + * + * @return \Drupal\scheduler_repeat\SchedulerRepeaterInterface + * The repeat plugin object. + * + * @throws \Drupal\scheduler_repeat\InvalidPluginTypeException + * + * @todo This duplicates some functionality in _scheduler_repeat_get_repeater. + * Need to consolidate these? + */ + private function initializeRepeaterWithPlugin(string $plugin) { + // @todo When we cater for optional associated data, the id can be + // extracted and the other values added into $plugin_data. + $plugin_id = $plugin; + $plugin_data = ['node' => $this->node]; + $repeater = $this->schedulerRepeatPluginManager->createInstance($plugin_id, $plugin_data); + if (!$repeater instanceof SchedulerRepeaterInterface) { + throw new InvalidPluginTypeException('Scheduler repeater manager returned wrong plugin type: ' . get_class($repeater)); + } + return $repeater; + } + +} diff --git a/scheduler_repeat/src/SchedulerRepeaterInterface.php b/scheduler_repeat/src/SchedulerRepeaterInterface.php new file mode 100644 index 000000000..d25149022 --- /dev/null +++ b/scheduler_repeat/src/SchedulerRepeaterInterface.php @@ -0,0 +1,41 @@ +setCacheBackend($cache_backend, 'scheduler_repeat'); + } + +} diff --git a/scheduler_repeat/tests/src/Functional/SchedulerRepeatCalculationsTest.php b/scheduler_repeat/tests/src/Functional/SchedulerRepeatCalculationsTest.php new file mode 100644 index 000000000..aa539cef5 --- /dev/null +++ b/scheduler_repeat/tests/src/Functional/SchedulerRepeatCalculationsTest.php @@ -0,0 +1,151 @@ +drupalLogin($this->adminUser); + + // Create a node with a repeating schedule. + $options = [ + 'type' => $this->type, + 'title' => 'Repeat ' . $plugin, + 'status' => FALSE, + 'publish_on' => strtotime('+15 mins', $this->requestTime), + 'unpublish_on' => strtotime('+30 mins', $this->requestTime), + 'scheduler_repeat' => ['plugin' => $plugin], + ]; + $node = $this->drupalCreateNode($options); + $nid = $node->id(); + + // Check that the initial next dates have been created correctly. + $expected_next_publish = strtotime($calculation, $options['publish_on']); + $expected_next_unpublish = strtotime($calculation, $options['unpublish_on']); + $this->assertEquals($expected_next_publish, $node->get('scheduler_repeat')->next_publish_on); + $this->assertEquals($expected_next_unpublish, $node->get('scheduler_repeat')->next_unpublish_on); + + // Edit and save. + $editted_publish_on = strtotime('+20 mins', $this->requestTime); + $editted_unpublish_on = strtotime('+45 mins', $this->requestTime); + $edit = [ + 'body[0][value]' => "plugin = $plugin\nncalculation = $calculation", + 'publish_on[0][value][date]' => date('Y-m-d', $editted_publish_on), + 'publish_on[0][value][time]' => date('H:i:s', $editted_publish_on), + 'unpublish_on[0][value][date]' => date('Y-m-d', $editted_unpublish_on), + 'unpublish_on[0][value][time]' => date('H:i:s', $editted_unpublish_on), + ]; + $this->drupalPostForm('node/' . $nid . '/edit', $edit, 'Save'); + + // Reload the node. + $node = $this->nodeStorage->load($nid); + + // Check that the updated next dates have been created correctly. + $expected_next_publish = strtotime($calculation, $editted_publish_on); + $expected_next_unpublish = strtotime($calculation, $editted_unpublish_on); + $this->assertEquals($expected_next_publish, $node->get('scheduler_repeat')->next_publish_on); + $this->assertEquals($expected_next_unpublish, $node->get('scheduler_repeat')->next_unpublish_on); + } + + /** + * Tests that the next dates are calculated after unpublishing via cron. + * + * @dataProvider dataRepeatPlugins() + */ + public function testRepeatCron($plugin, $calculation) { + $this->drupalLogin($this->adminUser); + + // Allow publishing dates in the past, so they can be processed by cron + // without waiting. + $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save(); + + // Create a node with a repeating schedule. + $options = [ + 'type' => $this->type, + 'title' => 'Repeat ' . $plugin, + 'status' => FALSE, + 'publish_on' => strtotime('-30 mins', $this->requestTime), + 'unpublish_on' => strtotime('-10 mins', $this->requestTime), + 'scheduler_repeat' => ['plugin' => $plugin], + ]; + $node = $this->drupalCreateNode($options); + $nid = $node->id(); + + // Call the main scheduler function that executed during a cron run, then + // reset the cache and reload the node. + scheduler_cron(); + $this->nodeStorage->resetCache([$nid]); + $node = $this->nodeStorage->load($nid); + + // Check that the node has been re-scheduled for the next dates. + $expected_publish = strtotime($calculation, $options['publish_on']); + $expected_unpublish = strtotime($calculation, $options['unpublish_on']); + $this->assertEquals($expected_publish, $node->publish_on->value); + $this->assertEquals($expected_unpublish, $node->unpublish_on->value); + + // Check that the node has been re-scheduled for the next dates. + $expected_next_publish = strtotime($calculation, $expected_publish); + $expected_next_unpublish = strtotime($calculation, $expected_unpublish); + $this->assertEquals($expected_next_publish, $node->get('scheduler_repeat')->next_publish_on); + $this->assertEquals($expected_next_unpublish, $node->get('scheduler_repeat')->next_unpublish_on); + } + + /** + * Provides data for testRepeatCreateEdit() and testRepeatCron(). + * + * @return array + * This is a nested array. The top-level keys are not available in the test + * so can be anything, but we use the plugin id for clarity. Each value is + * an associative array with the following key-value pairs: + * plugin - the plugin id + * calculation - a string to use in strToTime() calculation. + */ + public function dataRepeatPlugins() { + + $data = [ + 'hourly' => [ + 'plugin' => 'hourly', + 'calculation' => '+60 mins', + ], + 'daily' => [ + 'plugin' => 'daily', + 'calculation' => '+24 hours', + ], + 'weekly' => [ + 'plugin' => 'weekly', + 'calculation' => '+7 days', + ], + 'monthly' => [ + 'plugin' => 'monthly', + 'calculation' => '+1 month', + ], + 'yearly' => [ + 'plugin' => 'yearly', + 'calculation' => '+12 months', + ], + ]; + + // Use unset($data[x]) to remove a temporarily unwanted item, use + // return [$data[x], $data[y]] to selectively test just some items, or have + // the default return $data to test everything. + return $data; + } + +} diff --git a/scheduler_repeat/tests/src/Functional/SchedulerRepeatFormTest.php b/scheduler_repeat/tests/src/Functional/SchedulerRepeatFormTest.php new file mode 100644 index 000000000..a514b4204 --- /dev/null +++ b/scheduler_repeat/tests/src/Functional/SchedulerRepeatFormTest.php @@ -0,0 +1,115 @@ +onlyPublishOnNodetype = $this->drupalCreateContentType([ + 'type' => 'only_publish_hon_nodetype', + 'name' => 'Only publish on nodetype', + ]); + $this->onlyPublishOnNodetype->setThirdPartySetting('scheduler', 'publish_enable', TRUE) + ->setThirdPartySetting('scheduler', 'unpublish_enable', FALSE) + ->save(); + + // Create node type that only has scheduled unpublishing enabled. + $this->onlyUnpublishOnNodetype = $this->drupalCreateContentType([ + 'type' => 'only_unpublish_hon_nodetype', + 'name' => 'Only unpublish on nodetype', + ]); + $this->onlyUnpublishOnNodetype->setThirdPartySetting('scheduler', 'publish_enable', FALSE) + ->setThirdPartySetting('scheduler', 'unpublish_enable', TRUE) + ->save(); + + } + + /** + * Tests that the repeat input is displayed correctly in the node edit form. + * + * This tests covers scheduler_repeat_form_node_form_alter(). + */ + public function testRepeatNodeForm() { + $this->drupalLogin($this->adminUser); + + /** @var \Drupal\Tests\WebAssert $assert */ + $assert = $this->assertSession(); + + // Check that repeat selection is shown in the vertical tab. + $this->drupalGet('node/add/' . $this->type); + $assert->elementExists('xpath', '//div[contains(@class, "form-type-vertical-tabs")]//details[@id = "edit-scheduler-settings"]//div[@id = "edit-scheduler-repeat-wrapper"]'); + + // Check that repeat selection is shown in the fieldset. + $this->nodetype->setThirdPartySetting('scheduler', 'fields_display_mode', 'fieldset')->save(); + $this->drupalGet('node/add/' . $this->type); + $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings"]//div[@id = "edit-scheduler-repeat-wrapper"]'); + + // Check that repeat selection is shown also when editing node. + $options = [ + 'title' => 'Contains scheduled dates ' . $this->randomMachineName(10), + 'type' => $this->type, + 'publish_on' => strtotime('+1 day'), + 'unpublish_on' => strtotime('+2 day'), + ]; + $node = $this->drupalCreateNode($options); + $this->drupalGet('node/' . $node->id() . '/edit'); + $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings"]//div[@id = "edit-scheduler-repeat-wrapper"]'); + + // Check that repeat selection is not shown when no scheduling is enabled. + $this->drupalGet('node/add/' . $this->nonSchedulerNodeType->id()); + $assert->elementNotExists('xpath', '//div[@id = "edit-scheduler-repeat-wrapper"]'); + + // Check that repeat selection is not shown if publishing is not enabled. + $this->drupalGet('node/add/' . $this->onlyPublishOnNodetype->id()); + $assert->elementNotExists('xpath', '//div[@id = "edit-scheduler-repeat-wrapper"]'); + + // Check that repeat selection is not shown if unpublishing is not enabled. + $this->drupalGet('node/add/' . $this->onlyUnpublishOnNodetype->id()); + $assert->elementNotExists('xpath', '//div[@id = "edit-scheduler-repeat-wrapper"]'); + + } + + /** + * Tests the settings entry in the content type form display. + * + * This test covers scheduler_repeat_entity_base_field_info(). + */ + public function testRepeatManageFormDisplay() { + // Create a custom user with admin permissions but also permission to use + // the field_ui module 'node form display' tab. + $this->adminUser2 = $this->drupalCreateUser([ + 'access content', + 'administer content types', + 'administer node form display', + ]); + $this->drupalLogin($this->adminUser2); + + // Check that the weight input field is displayed when the content type is + // enabled for scheduling. This field still exists even with tabledrag on. + $this->drupalGet('admin/structure/types/manage/' . $this->type . '/form-display'); + $this->assertSession()->fieldExists('edit-fields-scheduler-repeat-weight'); + + // Disabling scheduled publishing and unpublishing has no effect on whether + // the base fields are displayed in the form. This is the same for the main + // Scheduler fields. + } + +}