diff --git a/core/modules/content_moderation/content_moderation.services.yml b/core/modules/content_moderation/content_moderation.services.yml index 904bc0dc33..256095a03a 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 0000000000..53316b793a --- /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['states'], $original_workflow_config['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 b80ccdda87..37d7529884 100644 --- a/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php +++ b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php @@ -131,6 +131,31 @@ public function decorateState(StateInterface $state) { /** * {@inheritdoc} */ + public function workflowHasData(WorkflowInterface $workflow) { + return (bool) $this->entityTypeManager + ->getStorage('content_moderation_state') + ->getQuery() + ->count() + ->condition('workflow', $workflow->id()) + ->execute(); + } + + /** + * {@inheritdoc} + */ + public function workflowStateHasData(WorkflowInterface $workflow, StateInterface $state) { + return (bool) $this->entityTypeManager + ->getStorage('content_moderation_state') + ->getQuery() + ->count() + ->condition('workflow', $workflow->id()) + ->condition('moderation_state', $state->id()) + ->execute(); + } + + /** + * {@inheritdoc} + */ public function buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) { /** @var \Drupal\content_moderation\ContentModerationState $state */ $is_required_state = isset($state) ? in_array($state->id(), $this->getRequiredStates(), TRUE) : FALSE; diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php index 7aab0d46c3..b6fe577fb4 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 0000000000..0680bf1f26 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php @@ -0,0 +1,147 @@ +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->addState('test1', 'Test one'); + $workflow->addState('test2', 'Test two'); + $workflow->addState('test3', 'Test three'); + $workflow->getTypePlugin()->setConfiguration([ + 'states' => [ + 'test1' => [ + 'published' => TRUE, + 'default_revision' => TRUE, + ], + 'test2' => [ + 'published' => TRUE, + 'default_revision' => TRUE, + ], + 'test3' => [ + 'published' => TRUE, + 'default_revision' => TRUE, + ], + ], + ]); + $workflow->getTypePlugin()->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['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['states']['test2']); + unset($config_data['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 b9b833132b..e122f40543 100644 --- a/core/modules/workflows/src/Form/WorkflowDeleteForm.php +++ b/core/modules/workflows/src/Form/WorkflowDeleteForm.php @@ -14,6 +14,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} + */ public function getQuestion() { return $this->t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]); } diff --git a/core/modules/workflows/src/Form/WorkflowStateDeleteForm.php b/core/modules/workflows/src/Form/WorkflowStateDeleteForm.php index c60045ca23..d2c30d7435 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 34af735e3d..394658bc8e 100644 --- a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php +++ b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php @@ -58,6 +58,20 @@ public function checkWorkflowAccess(WorkflowInterface $entity, $operation, Accou /** * {@inheritdoc} */ + public function workflowHasData(WorkflowInterface $workflow) { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function workflowStateHasData(WorkflowInterface $workflow, StateInterface $state) { + return FALSE; + } + + /** + * {@inheritdoc} + */ public function decorateState(StateInterface $state) { return $state; } diff --git a/core/modules/workflows/src/WorkflowTypeInterface.php b/core/modules/workflows/src/WorkflowTypeInterface.php index 2412bf99f4..5af5c6512b 100644 --- a/core/modules/workflows/src/WorkflowTypeInterface.php +++ b/core/modules/workflows/src/WorkflowTypeInterface.php @@ -57,6 +57,30 @@ public function label(); public function checkWorkflowAccess(WorkflowInterface $entity, $operation, AccountInterface $account); /** + * Determines if the workflow is being has data associated with it. + * + * @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. + * + * @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. * * @param \Drupal\workflows\StateInterface $state