diff --git a/core/modules/content_moderation/content_moderation.module b/core/modules/content_moderation/content_moderation.module index 7345e93..aca5079 100644 --- a/core/modules/content_moderation/content_moderation.module +++ b/core/modules/content_moderation/content_moderation.module @@ -14,12 +14,12 @@ use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityPublishedInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Session\AccountInterface; use Drupal\workflows\WorkflowInterface; -use Drupal\node\NodeInterface; use Drupal\node\Plugin\Action\PublishNode; use Drupal\node\Plugin\Action\UnpublishNode; use Drupal\workflows\Entity\Workflow; @@ -155,36 +155,36 @@ function content_moderation_entity_view(array &$build, EntityInterface $entity, } /** - * Implements hook_node_access(). + * Implements hook_entity_access(). * * Nodes in particular should be viewable if unpublished and the user has * the appropriate permission. This permission is therefore effectively * mandatory for any user that wants to moderate things. */ -function content_moderation_node_access(NodeInterface $node, $operation, AccountInterface $account) { +function content_moderation_entity_access(EntityInterface $entity, $operation, AccountInterface $account) { /** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */ $moderation_info = Drupal::service('content_moderation.moderation_information'); $access_result = NULL; - if ($operation === 'view') { - $access_result = (!$node->isPublished()) + if ($operation === 'view' && $entity instanceof EntityPublishedInterface) { + $access_result = (!$entity->isPublished()) ? AccessResult::allowedIfHasPermission($account, 'view any unpublished content') : AccessResult::neutral(); - $access_result->addCacheableDependency($node); + $access_result->addCacheableDependency($entity); } - elseif ($operation === 'update' && $moderation_info->isModeratedEntity($node) && $node->moderation_state) { + elseif ($operation === 'update' && $moderation_info->isModeratedEntity($entity) && $entity->moderation_state) { /** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */ $transition_validation = \Drupal::service('content_moderation.state_transition_validation'); - $valid_transition_targets = $transition_validation->getValidTransitions($node, $account); - $access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden(); + $permitted_transition_targets = $transition_validation->getPermittedTransitions($entity, $account); + $access_result = $permitted_transition_targets ? AccessResult::neutral() : AccessResult::forbidden(); - $access_result->addCacheableDependency($node); + $access_result->addCacheableDependency($entity); $access_result->addCacheableDependency($account); - $workflow = \Drupal::service('content_moderation.moderation_information')->getWorkflowForEntity($node); + $workflow = \Drupal::service('content_moderation.moderation_information')->getWorkflowForEntity($entity); $access_result->addCacheableDependency($workflow); - foreach ($valid_transition_targets as $valid_transition_target) { + foreach ($permitted_transition_targets as $valid_transition_target) { $access_result->addCacheableDependency($valid_transition_target); } } diff --git a/core/modules/content_moderation/src/EntityTypeInfo.php b/core/modules/content_moderation/src/EntityTypeInfo.php index f11e471..861b945 100644 --- a/core/modules/content_moderation/src/EntityTypeInfo.php +++ b/core/modules/content_moderation/src/EntityTypeInfo.php @@ -65,6 +65,13 @@ class EntityTypeInfo implements ContainerInjectionInterface { protected $currentUser; /** + * The state transition validation service. + * + * @var \Drupal\content_moderation\StateTransitionValidationInterface + */ + protected $validator; + + /** * A keyed array of custom moderation handlers for given entity types. * * Any entity not specified will use a common default. @@ -90,12 +97,13 @@ class EntityTypeInfo implements ContainerInjectionInterface { * @param \Drupal\Core\Session\AccountInterface $current_user * Current user. */ - public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info, AccountInterface $current_user) { + public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info, AccountInterface $current_user, StateTransitionValidationInterface $validator) { $this->stringTranslation = $translation; $this->moderationInfo = $moderation_information; $this->entityTypeManager = $entity_type_manager; $this->bundleInfo = $bundle_info; $this->currentUser = $current_user; + $this->validator = $validator; } /** @@ -107,7 +115,8 @@ public static function create(ContainerInterface $container) { $container->get('content_moderation.moderation_information'), $container->get('entity_type.manager'), $container->get('entity_type.bundle.info'), - $container->get('current_user') + $container->get('current_user'), + $container->get('content_moderation.state_transition_validation') ); } @@ -359,6 +368,23 @@ public function formAlter(array &$form, FormStateInterface $form_state, $form_id $this->entityTypeManager ->getHandler($entity->getEntityTypeId(), 'moderation') ->enforceRevisionsEntityFormAlter($form, $form_state, $form_id); + + if (empty($this->validator->getValidTransitions($entity, $this->currentUser))) { + drupal_set_message('Due to draft revisions on other translations this entity cannot be saved.', 'error'); + } + + $invalid_transitions = array_diff_key($this->validator->getPermittedTransitions($entity, $this->currentUser), $this->validator->getValidTransitions($entity, $this->currentUser)); + if ($invalid_transitions) { + $invalid_transitions_string = implode(', ', array_map(function ($transition) { return $transition->label(); }, $invalid_transitions)); + $form['invalid_transitions'] = [ + '#type' => 'item', + '#prefix' => '', + '#markup' => t('You cannot @invalid this @entity_type_label because there are other translations with unsaved draft revisions.', ['@invalid' => $invalid_transitions_string, '@entity_type_label' => $entity->getEntityType()->getLabel()]), + '#suffix' => '', + '#weight' => 999, + ]; + } + // Submit handler to redirect to the latest version, if available. $form['actions']['submit']['#submit'][] = [EntityTypeInfo::class, 'bundleFormRedirect']; } diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php index c20be52..dda2c5a 100644 --- a/core/modules/content_moderation/src/ModerationInformation.php +++ b/core/modules/content_moderation/src/ModerationInformation.php @@ -140,12 +140,19 @@ public function isLiveRevision(ContentEntityInterface $entity) { /** * {@inheritdoc} */ - public function getWorkflowForEntity(ContentEntityInterface $entity) { - $bundles = $this->bundleInfo->getBundleInfo($entity->getEntityTypeId()); - if (isset($bundles[$entity->bundle()]['workflow'])) { - return $this->entityTypeManager->getStorage('workflow')->load($bundles[$entity->bundle()]['workflow']); + public function getWorkflowForEntityTypeAndBundle(EntityTypeInterface $entity_type, $bundle) { + $bundles = $this->bundleInfo->getBundleInfo($entity_type->id()); + if (isset($bundles[$bundle]['workflow'])) { + return $this->entityTypeManager->getStorage('workflow')->load($bundles[$bundle]['workflow']); }; return NULL; } + /** + * {@inheritdoc} + */ + public function getWorkflowForEntity(ContentEntityInterface $entity) { + return $this->getWorkflowForEntityTypeAndBundle($entity->getEntityType(), $entity->bundle()); + } + } diff --git a/core/modules/content_moderation/src/StateTransitionValidation.php b/core/modules/content_moderation/src/StateTransitionValidation.php index 69f717d..f2f5df3 100644 --- a/core/modules/content_moderation/src/StateTransitionValidation.php +++ b/core/modules/content_moderation/src/StateTransitionValidation.php @@ -39,34 +39,41 @@ public function __construct(ModerationInformationInterface $moderation_info) { * {@inheritdoc} */ public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) { - $workflow = $this->moderationInfo->getWorkflowForEntity($entity); - $current_state = $entity->moderation_state->value ? $workflow->getState($entity->moderation_state->value) : $workflow->getInitialState(); - - return array_filter($current_state->getTransitions(), function(Transition $transition) use ($workflow, $user, $entity) { - // Don't show the transition if the user doesn't have permission to use - // it. - if (!$user->hasPermission('use ' . $workflow->id() . ' transition ' . $transition->id())) { - return FALSE; - } - - // For entities with more than one translation and forward revisions we - // want to only allow specific transitions. - if (count($entity->getTranslationLanguages()) > 1 && $this->moderationInfo->hasForwardRevision($entity)) { + $permitted_transitions = $this->getPermittedTransitions($entity, $user); + // For entities with more than one translation and forward revisions we + // want to only allow specific transitions. + if (count($entity->getTranslationLanguages()) > 1 && $this->moderationInfo->hasForwardRevision($entity)) { + $permitted_transitions = array_filter($permitted_transitions, function(Transition $transition) use ($entity) { // The entity needs to be the latest and translation affected, or be // going from and to the same default revision state, or be going from // and to the same publishing state. return ( $this->moderationInfo->isLatestRevision($entity) && $entity->isRevisionTranslationAffected() ) || (( - ($entity->isDefaultRevision() && $transition->to()->isDefaultRevisionState()) - || (!$entity->isDefaultRevision() && !$transition->to()->isDefaultRevisionState()) - ) && ( - ($entity->isPublished() && $transition->to()->isPublishedState()) - || (!$entity->isPublished() && !$transition->to()->isPublishedState()) - )); - } + ($entity->isDefaultRevision() && $transition->to() + ->isDefaultRevisionState()) + || (!$entity->isDefaultRevision() && !$transition->to() + ->isDefaultRevisionState()) + ) && ( + ($entity->isPublished() && $transition->to()->isPublishedState()) + || (!$entity->isPublished() && !$transition->to() + ->isPublishedState()) + )); + }); + } + + return $permitted_transitions; + } - return TRUE; + /** + * {@inheritdoc} + */ + public function getPermittedTransitions(ContentEntityInterface $entity, AccountInterface $user) { + $workflow = $this->moderationInfo->getWorkflowForEntity($entity); + $current_state = $entity->moderation_state->value ? $workflow->getState($entity->moderation_state->value) : $workflow->getInitialState(); + + return array_filter($current_state->getTransitions(), function(Transition $transition) use ($workflow, $user, $entity) { + return $user->hasPermission('use ' . $workflow->id() . ' transition ' . $transition->id()); }); } diff --git a/core/modules/content_moderation/src/StateTransitionValidationInterface.php b/core/modules/content_moderation/src/StateTransitionValidationInterface.php index 1acbf05..25dd77d 100644 --- a/core/modules/content_moderation/src/StateTransitionValidationInterface.php +++ b/core/modules/content_moderation/src/StateTransitionValidationInterface.php @@ -11,6 +11,21 @@ interface StateTransitionValidationInterface { /** + * Gets a list of transitions that are both legal and valid for this user on + * this entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity to be transitioned. + * @param \Drupal\Core\Session\AccountInterface $user + * The account that wants to perform a transition. + * + * @return \Drupal\workflows\Transition[] + * The list of transitions that are both legal and valid for this user on + * this entity. + */ + public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user); + + /** * Gets a list of transitions that are legal for this user on this entity. * * @param \Drupal\Core\Entity\ContentEntityInterface $entity @@ -21,6 +36,6 @@ * @return \Drupal\workflows\Transition[] * The list of transitions that are legal for this user on this entity. */ - public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user); + public function getPermittedTransitions(ContentEntityInterface $entity, AccountInterface $user); } diff --git a/core/modules/content_moderation/src/Tests/ModerationFormTest.php b/core/modules/content_moderation/src/Tests/ModerationFormTest.php index d91c872..47287a4 100644 --- a/core/modules/content_moderation/src/Tests/ModerationFormTest.php +++ b/core/modules/content_moderation/src/Tests/ModerationFormTest.php @@ -172,6 +172,7 @@ public function testContentTranslationNodeForm() { $this->assertTrue($this->xpath('//input[@value="Save and Create New Draft (this translation)"]')); $this->assertFalse($this->xpath('//input[@value="Save and Publish (this translation)"]')); $this->assertFalse($this->xpath('//input[@value="Save and Archive (this translation)"]')); + $this->assertText('You cannot Publish this Content because there are other translations with unsaved draft revisions.'); $this->drupalPostForm(NULL, [ 'body[0][value]' => 'Forth version of the content.', ], t('Save and Create New Draft (this translation)')); @@ -214,6 +215,8 @@ public function testContentTranslationNodeForm() { $this->assertFalse($this->xpath('//input[@value="Save and Create New Draft (this translation)"]')); $this->assertFalse($this->xpath('//input[@value="Save and Publish (this translation)"]')); $this->assertFalse($this->xpath('//input[@value="Save and Archive (this translation)"]')); + $this->assertText('You cannot Create New Draft, Publish, Archive this Content because there are other translations with unsaved draft revisions.'); + $this->assertText('Due to draft revisions on other translations this entity cannot be saved'); // We should be able to publish the english forward revision. $this->drupalGet($edit_path); @@ -235,6 +238,7 @@ public function testContentTranslationNodeForm() { $this->assertTrue($this->xpath('//input[@value="Save and Create New Draft (this translation)"]')); $this->assertTrue($this->xpath('//input[@value="Save and Publish (this translation)"]')); $this->assertTrue($this->xpath('//input[@value="Save and Archive (this translation)"]')); + } }