diff --git a/core/modules/content_moderation/content_moderation.services.yml b/core/modules/content_moderation/content_moderation.services.yml index 904bc0dc335ae51100a6be8ecff01af4a7345230..256095a03af0878b7a4b3792941b9128da2eb056 100644 --- a/core/modules/content_moderation/content_moderation.services.yml +++ b/core/modules/content_moderation/content_moderation.services.yml @@ -19,4 +19,9 @@ services: class: Drupal\content_moderation\RevisionTracker arguments: ['@database'] tags: - - { name: backend_overridable } + - { name: backend_overridable } + content_moderation.config_import_subscriber: + class: Drupal\content_moderation\EventSubscriber\ConfigImportSubscriber + arguments: ['@config.manager', '@entity_type.manager'] + tags: + - { name: event_subscriber } diff --git a/core/modules/content_moderation/src/EventSubscriber/ConfigImportSubscriber.php b/core/modules/content_moderation/src/EventSubscriber/ConfigImportSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..bb7d6802830a9c65ed2d2f6aaf313f63580eb2e7 --- /dev/null +++ b/core/modules/content_moderation/src/EventSubscriber/ConfigImportSubscriber.php @@ -0,0 +1,96 @@ +configManager = $config_manager; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public function onConfigImporterValidate(ConfigImporterEvent $event) { + foreach (['update', 'delete'] as $op) { + $unprocessed_configurations = $event->getConfigImporter()->getUnprocessedConfiguration($op); + foreach ($unprocessed_configurations as $unprocessed_configuration) { + if ($workflow = $this->getWorkflow($unprocessed_configuration)) { + if ($op === 'update') { + $original_workflow_config = $event->getConfigImporter() + ->getStorageComparer() + ->getSourceStorage() + ->read($unprocessed_configuration); + $workflow_config = $event->getConfigImporter() + ->getStorageComparer() + ->getTargetStorage() + ->read($unprocessed_configuration); + $diff = array_diff_key($workflow_config['type_settings']['states'], $original_workflow_config['type_settings']['states']); + foreach (array_keys($diff) as $state_id) { + $state = $workflow->getState($state_id); + if ($workflow->getTypePlugin()->workflowStateHasData($workflow, $state)) { + $event->getConfigImporter()->logError($this->t('The moderation state @state_label is being used, but is not in the source storage.', ['@state_label' => $state->label()])); + } + } + } + if ($op === 'delete') { + if ($workflow->getTypePlugin()->workflowHasData($workflow)) { + $event->getConfigImporter()->logError($this->t('The workflow @workflow_label is being used, and cannot be deleted.', ['@workflow_label' => $workflow->label()])); + } + } + } + } + } + } + + /** + * Get the workflow entity object from the configuration name. + * + * @param string $config_name + * The configuration object name. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * An entity object. NULL if no matching entity is found. + */ + protected function getWorkflow($config_name) { + $entity_type_id = $this->configManager->getEntityTypeIdByName($config_name); + /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */ + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + $entity_id = ConfigEntityStorage::getIDFromConfigName($config_name, $entity_type->getConfigPrefix()); + /** @var \Drupal\workflows\WorkflowInterface $workflow */ + return $this->entityTypeManager->getStorage($entity_type_id)->load($entity_id); + } + +} diff --git a/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php index c45803ab83e5175fd3e106f3386f6203707437a3..f4bbef553d86e436fad1eb429420504047003da2 100644 --- a/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php +++ b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php @@ -114,6 +114,35 @@ public function decorateState(StateInterface $state) { return $state; } + /** + * {@inheritdoc} + */ + public function workflowHasData(WorkflowInterface $workflow) { + return (bool) $this->entityTypeManager + ->getStorage('content_moderation_state') + ->getQuery() + ->condition('workflow', $workflow->id()) + ->count() + ->accessCheck(FALSE) + ->range(0, 1) + ->execute(); + } + + /** + * {@inheritdoc} + */ + public function workflowStateHasData(WorkflowInterface $workflow, StateInterface $state) { + return (bool) $this->entityTypeManager + ->getStorage('content_moderation_state') + ->getQuery() + ->condition('workflow', $workflow->id()) + ->condition('moderation_state', $state->id()) + ->count() + ->accessCheck(FALSE) + ->range(0, 1) + ->execute(); + } + /** * {@inheritdoc} */ diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php index 7aab0d46c3d22e8cf651dc562341ef9b82e04b25..b6fe577fb48329a97d6b32d6ad7e19e9c1ad1883 100644 --- a/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php +++ b/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php @@ -451,4 +451,53 @@ public function testContentTranslationNodeForm() { $this->drupalPostForm(NULL, [], t('Save and Create New Draft (this translation)')); } + /** + * Tests that workflows and states can not be deleted if they are in use. + * + * @covers \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration::workflowHasData + * @covers \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration::workflowStateHasData + */ + public function testWorkflowInUse() { + $user = $this->createUser([ + 'administer workflows', + 'create moderated_content content', + 'edit own moderated_content content', + 'use editorial transition create_new_draft', + 'use editorial transition publish', + 'use editorial transition archive' + ]); + $this->drupalLogin($user); + $paths = [ + 'archived_state' => 'admin/config/workflow/workflows/manage/editorial/state/archived/delete', + 'editorial_workflow' => 'admin/config/workflow/workflows/manage/editorial/delete', + ]; + foreach ($paths as $path) { + $this->drupalGet($path); + $this->assertSession()->buttonExists('Delete'); + } + // Create new moderated content in draft. + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'Some moderated content', + 'body[0][value]' => 'First version of the content.', + ], 'Save and Create New Draft'); + + // The archived state is not used yet, so can still be deleted. + $this->drupalGet($paths['archived_state']); + $this->assertSession()->buttonExists('Delete'); + + // The workflow is being used, so can't be deleted. + $this->drupalGet($paths['editorial_workflow']); + $this->assertSession()->buttonNotExists('Delete'); + + $node = $this->drupalGetNodeByTitle('Some moderated content'); + $this->drupalPostForm('node/' . $node->id() . '/edit', [], 'Save and Publish'); + $this->drupalPostForm('node/' . $node->id() . '/edit', [], 'Save and Archive'); + + // Now the archived state is being used so it can not be deleted either. + foreach ($paths as $path) { + $this->drupalGet($path); + $this->assertSession()->buttonNotExists('Delete'); + } + } + } diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php new file mode 100644 index 0000000000000000000000000000000000000000..90ecfcf2dd14f216af152957ca217e5e8be805c4 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php @@ -0,0 +1,132 @@ +installSchema('node', 'node_access'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installEntitySchema('content_moderation_state'); + $this->installConfig('content_moderation'); + + NodeType::create([ + 'type' => 'example', + ])->save(); + + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin() + ->addState('test1', 'Test one') + ->addState('test2', 'Test two') + ->addState('test3', 'Test three') + ->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $this->workflow = $workflow; + + $this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.sync')); + } + + /** + * Test deleting a state via config import. + */ + public function testDeletingStateViaConfiguration() { + $config_data = $this->config('workflows.workflow.editorial')->get(); + unset($config_data['type_settings']['states']['test1']); + \Drupal::service('config.storage.sync')->write('workflows.workflow.editorial', $config_data); + + // There are no Nodes with the moderation state test1, so this should run + // with no errors. + $this->configImporter()->reset()->import(); + + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + 'moderation_state' => 'test2', + ]); + $node->save(); + + $config_data = $this->config('workflows.workflow.editorial')->get(); + unset($config_data['type_settings']['states']['test2']); + unset($config_data['type_settings']['states']['test3']); + \Drupal::service('config.storage.sync')->write('workflows.workflow.editorial', $config_data); + + // Now there is a Node with the moderation state test2, this will fail. + try { + $this->configImporter()->reset()->import(); + $this->fail('ConfigImporterException not thrown, invalid import was not stopped due to deleted state.'); + } + catch (ConfigImporterException $e) { + $this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.'); + $error_log = $this->configImporter->getErrors(); + $expected = ['The moderation state Test two is being used, but is not in the source storage.']; + $this->assertEqual($expected, $error_log); + } + + \Drupal::service('config.storage.sync')->delete('workflows.workflow.editorial'); + + // An error should be thrown when trying to delete an in use workflow. + try { + $this->configImporter()->reset()->import(); + $this->fail('ConfigImporterException not thrown, invalid import was not stopped due to deleted workflow.'); + } + catch (ConfigImporterException $e) { + $this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.'); + $error_log = $this->configImporter->getErrors(); + $expected = [ + 'The moderation state Test two is being used, but is not in the source storage.', + 'The workflow Editorial workflow is being used, and cannot be deleted.', + ]; + $this->assertEqual($expected, $error_log); + } + } + +} diff --git a/core/modules/workflows/src/Form/WorkflowDeleteForm.php b/core/modules/workflows/src/Form/WorkflowDeleteForm.php index b9b833132b73ffb8c79b9a6eed722eabc336be97..e122f4054346bb6f102aeec95d46baed30c7fc77 100644 --- a/core/modules/workflows/src/Form/WorkflowDeleteForm.php +++ b/core/modules/workflows/src/Form/WorkflowDeleteForm.php @@ -11,6 +11,19 @@ */ class WorkflowDeleteForm extends EntityConfirmFormBase { + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + if ($this->entity->getTypePlugin()->workflowHasData($this->entity)) { + $form['#title'] = $this->getQuestion(); + $form['description'] = ['#markup' => $this->t('This workflow is in use. You cannot remove this workflow until you have removed all content using it.')]; + return $form; + } + + return parent::buildForm($form, $form_state); + } + /** * {@inheritdoc} */ diff --git a/core/modules/workflows/src/Form/WorkflowStateDeleteForm.php b/core/modules/workflows/src/Form/WorkflowStateDeleteForm.php index c60045ca23a57487f38cea856e428fc0c46c010e..d2c30d7435c55cc236fc6d1656c25e6af4def307 100644 --- a/core/modules/workflows/src/Form/WorkflowStateDeleteForm.php +++ b/core/modules/workflows/src/Form/WorkflowStateDeleteForm.php @@ -75,6 +75,13 @@ public function buildForm(array $form, FormStateInterface $form_state, WorkflowI } $this->workflow = $workflow; $this->stateId = $workflow_state; + + if ($this->workflow->getTypePlugin()->workflowStateHasData($this->workflow, $this->workflow->getState($this->stateId))) { + $form['#title'] = $this->getQuestion(); + $form['description'] = ['#markup' => $this->t('This workflow state is in use. You cannot remove this workflow state until you have removed all content using it.')]; + return $form; + } + return parent::buildForm($form, $form_state); } diff --git a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php index 0c4a34f427c2cdbfaed4fc30e62023a34add7f7a..efdfe7e10ddc652b927d68e6356e47c32d82f69a 100644 --- a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php +++ b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php @@ -61,6 +61,20 @@ public function checkWorkflowAccess(WorkflowInterface $entity, $operation, Accou return AccessResult::neutral(); } + /** + * {@inheritdoc} + */ + public function workflowHasData(WorkflowInterface $workflow) { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function workflowStateHasData(WorkflowInterface $workflow, StateInterface $state) { + return FALSE; + } + /** * {@inheritdoc} */ diff --git a/core/modules/workflows/src/WorkflowTypeInterface.php b/core/modules/workflows/src/WorkflowTypeInterface.php index d84d1a7ad4fdd6117851fbdf4cbf61ea3aee94d4..6ff8832d78d672677bc2d6746de74426df65718a 100644 --- a/core/modules/workflows/src/WorkflowTypeInterface.php +++ b/core/modules/workflows/src/WorkflowTypeInterface.php @@ -56,6 +56,38 @@ public function label(); */ public function checkWorkflowAccess(WorkflowInterface $entity, $operation, AccountInterface $account); + /** + * Determines if the workflow is being has data associated with it. + * + * @internal + * Marked as internal until it's validated this should form part of the + * public API in https://www.drupal.org/node/2897148. + * + * @param \Drupal\workflows\WorkflowInterface $workflow + * The workflow to check. + * + * @return bool + * TRUE if the workflow is being used, FALSE if not. + */ + public function workflowHasData(WorkflowInterface $workflow); + + /** + * Determines if the workflow state has data associated with it. + * + * @internal + * Marked as internal until it's validated this should form part of the + * public API in https://www.drupal.org/node/2897148. + * + * @param \Drupal\workflows\WorkflowInterface $workflow + * The workflow to check. + * @param \Drupal\workflows\StateInterface $state + * The workflow state to check. + * + * @return bool + * TRUE if the workflow state is being used, FALSE if not. + */ + public function workflowStateHasData(WorkflowInterface $workflow, StateInterface $state); + /** * Decorates states so the WorkflowType can add additional information. *