diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
index c2c373f32fb..eebb06624ca 100644
--- a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
@@ -15,5 +15,6 @@ use Symfony\Component\Validator\Constraint;
class ModerationStateConstraint extends Constraint {
public $message = 'Invalid state transition from %from to %to';
+ public $invalidStateMessage = 'State %state does not exist on %workflow';
}
diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
index b664c65d259..97ebc9328b0 100644
--- a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
@@ -68,7 +68,7 @@ class ModerationStateConstraintValidator extends ConstraintValidator implements
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
- /** @var \Drupal\Core\Entity\EntityInterface $entity */
+ /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $value->getEntity();
// Ignore entities that are not subject to moderation anyway.
@@ -76,29 +76,43 @@ class ModerationStateConstraintValidator extends ConstraintValidator implements
return;
}
- // Ignore entities that are being created for the first time.
- if ($entity->isNew()) {
- return;
- }
-
- // Ignore entities that are being moderated for the first time, such as
- // when they existed before moderation was enabled for this entity type.
- if ($this->isFirstTimeModeration($entity)) {
- return;
- }
-
- $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id());
- if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
- $original_entity = $original_entity->getTranslation($entity->language()->getId());
- }
-
$workflow = $this->moderationInformation->getWorkflowForEntity($entity);
- $new_state = $workflow->getState($entity->moderation_state->value) ?: $workflow->getInitialState();
- $original_state = $workflow->getState($original_entity->moderation_state->value);
- // @todo - what if $new_state references something that does not exist or
- // is null.
- if (!$original_state->canTransitionTo($new_state->id())) {
- $this->context->addViolation($constraint->message, ['%from' => $original_state->label(), '%to' => $new_state->label()]);
+
+ if (!$workflow->hasState($entity->moderation_state->value)) {
+ // If the state we are transitioning to doesn't exist, we can't validate
+ // the transitions for this entity further.
+ $this->context->addViolation($constraint->invalidStateMessage, [
+ '%state' => $entity->moderation_state->value,
+ '%workflow' => $workflow->label(),
+ ]);
+ return;
+ }
+
+ // If a new state is being set and there is an existing state, validate
+ // there is a valid transition between them.
+ if (!$entity->isNew() && !$this->isFirstTimeModeration($entity)) {
+ $original_entity = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadRevision($entity->getLoadedRevisionId());
+ if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
+ $original_entity = $original_entity->getTranslation($entity->language()->getId());
+ }
+
+ // If the state of the original entity doesn't exist on the workflow,
+ // we cannot do any further validation of transitions, because none will
+ // be setup for a state that doesn't exist. Instead allow any state to
+ // take its place.
+ if (!$workflow->hasState($original_entity->moderation_state->value)) {
+ return;
+ }
+
+ $new_state = $workflow->getState($entity->moderation_state->value);
+ $original_state = $workflow->getState($original_entity->moderation_state->value);
+
+ if (!$original_state->canTransitionTo($new_state->id())) {
+ $this->context->addViolation($constraint->message, [
+ '%from' => $original_state->label(),
+ '%to' => $new_state->label()
+ ]);
+ }
}
}
diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
index 7c4f97d391e..c8ce61b5657 100644
--- a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
@@ -96,6 +96,124 @@ class EntityStateChangeValidationTest extends KernelTestBase {
$this->assertEquals('Invalid state transition from Draft to Archived', $violations->get(0)->getMessage());
}
+ /**
+ * Test validation with an invalid state.
+ */
+ public function testInvalidState() {
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->save();
+ $workflow = Workflow::load('editorial');
+ $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
+ $workflow->save();
+
+ $node = Node::create([
+ 'type' => 'example',
+ 'title' => 'Test title',
+ ]);
+ $node->moderation_state->value = 'invalid_state';
+ $violations = $node->validate();
+
+ $this->assertCount(1, $violations);
+ $this->assertEquals('State invalid_state does not exist on Editorial workflow', $violations->get(0)->getMessage());
+ }
+
+ /**
+ * Test validation with content that has no initial state or an invalid state.
+ */
+ public function testInvalidStateWithoutExisting() {
+ // Create content without moderation enabled for the content type.
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->save();
+ $node = Node::create([
+ 'type' => 'example',
+ 'title' => 'Test title',
+ ]);
+ $node->save();
+
+ // Enable moderation to test validation on existing content, with no
+ // explicit state.
+ $workflow = Workflow::load('editorial');
+ $workflow->addState('deleted_state', 'Deleted state');
+ $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
+ $workflow->save();
+
+ // Validate the invalid state.
+ $node->moderation_state->value = 'invalid_state';
+ $violations = $node->validate();
+ $this->assertCount(1, $violations);
+
+ // Assign the node to a state we're going to delete.
+ $node->moderation_state->value = 'deleted_state';
+ $node->save();
+
+ // Delete the state so $node->original contains an invalid state when
+ // validating.
+ $workflow->deleteState('deleted_state');
+ $workflow->save();
+ $node->moderation_state->value = 'draft';
+ $violations = $node->validate();
+ $this->assertCount(0, $violations);
+ }
+
+ /**
+ * Test state transition validation with multiple languages.
+ */
+ public function testInvalidStateMultilingual() {
+ ConfigurableLanguage::createFromLangcode('fr')->save();
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->save();
+
+ $workflow = Workflow::load('editorial');
+ $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
+ $workflow->save();
+
+ $node = Node::create([
+ 'type' => 'example',
+ 'title' => 'English Published Node',
+ 'langcode' => 'en',
+ 'moderation_state' => 'published',
+ ]);
+ $node->save();
+
+ $node_fr = $node->addTranslation('fr');
+ $node_fr->setTitle('French Published Node');
+ $node_fr->save();
+ $this->assertEquals('published', $node_fr->moderation_state->value);
+
+ // Create a forward revision of the original node.
+ $node->moderation_state = 'draft';
+ $node->setNewRevision(TRUE);
+ $node->isDefaultRevision(FALSE);
+ $node->save();
+
+ // For the forward english revision, there should be a violation from draft
+ // to archived.
+ $node->moderation_state = 'archived';
+ $violations = $node->validate();
+ $this->assertCount(1, $violations);
+ $this->assertEquals('Invalid state transition from Draft to Archived', $violations->get(0)->getMessage());
+
+ // From the default french published revision, there should be none.
+ $node_fr = Node::load($node->id())->getTranslation('fr');
+ $this->assertEquals('published', $node_fr->moderation_state->value);
+ $node_fr->moderation_state = 'archived';
+ $violations = $node_fr->validate();
+ $this->assertCount(0, $violations);
+
+ // From the latest french revision, there should also be no violation.
+ $node_fr = $node->getTranslation('fr');
+ $this->assertEquals('published', $node_fr->moderation_state->value);
+ $node_fr->moderation_state = 'archived';
+ $violations = $node_fr->validate();
+ $this->assertCount(0, $violations);
+ }
+
/**
* Tests that content without prior moderation information can be moderated.
*/