summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNathaniel Catchpole2016-08-08 12:26:31 (GMT)
committerNathaniel Catchpole2016-08-08 12:26:31 (GMT)
commitbc00f081e6b8e35e3b7ee57eb963b7e5b92593a2 (patch)
tree7b8ceb1d56763d8bccff57583b4318334d7dabcf
parente1ef487b8d010367dc2325595a0c65d31cd71c7a (diff)
Issue #2725533 by timmillwood, alexpott, amateescu, webchick, dixon_, larowlan, dawehner, catch, Crell, Bojhan, jibran, Wim Leers, agentrickard, Berdir: Add experimental content_moderation module
-rw-r--r--core/MAINTAINERS.txt3
-rw-r--r--core/composer.json1
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state.archived.yml8
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state.draft.yml8
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state.published.yml8
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml11
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml11
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml10
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml11
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml11
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml11
-rw-r--r--core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml10
-rw-r--r--core/modules/content_moderation/config/schema/content_moderation.schema.yml79
-rw-r--r--core/modules/content_moderation/content_moderation.info.yml7
-rw-r--r--core/modules/content_moderation/content_moderation.libraries.yml5
-rw-r--r--core/modules/content_moderation/content_moderation.links.action.yml11
-rw-r--r--core/modules/content_moderation/content_moderation.links.menu.yml21
-rw-r--r--core/modules/content_moderation/content_moderation.links.task.yml3
-rw-r--r--core/modules/content_moderation/content_moderation.module221
-rw-r--r--core/modules/content_moderation/content_moderation.permissions.yml24
-rw-r--r--core/modules/content_moderation/content_moderation.routing.yml73
-rw-r--r--core/modules/content_moderation/content_moderation.services.yml22
-rw-r--r--core/modules/content_moderation/content_moderation.views.inc37
-rw-r--r--core/modules/content_moderation/css/entity-moderation-form.css16
-rw-r--r--core/modules/content_moderation/src/Access/LatestRevisionCheck.php85
-rw-r--r--core/modules/content_moderation/src/ContentModerationStateInterface.php16
-rw-r--r--core/modules/content_moderation/src/ContentModerationStateStorageSchema.php29
-rw-r--r--core/modules/content_moderation/src/ContentPreprocess.php57
-rw-r--r--core/modules/content_moderation/src/Entity/ContentModerationState.php181
-rw-r--r--core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php30
-rw-r--r--core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php75
-rw-r--r--core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php73
-rw-r--r--core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php66
-rw-r--r--core/modules/content_moderation/src/Entity/ModerationState.php97
-rw-r--r--core/modules/content_moderation/src/Entity/ModerationStateTransition.php110
-rw-r--r--core/modules/content_moderation/src/EntityOperations.php279
-rw-r--r--core/modules/content_moderation/src/EntityTypeInfo.php361
-rw-r--r--core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php195
-rw-r--r--core/modules/content_moderation/src/Form/EntityModerationForm.php161
-rw-r--r--core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php49
-rw-r--r--core/modules/content_moderation/src/Form/ModerationStateForm.php82
-rw-r--r--core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php49
-rw-r--r--core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php151
-rw-r--r--core/modules/content_moderation/src/ModerationInformation.php208
-rw-r--r--core/modules/content_moderation/src/ModerationInformationInterface.php212
-rw-r--r--core/modules/content_moderation/src/ModerationStateAccessControlHandler.php38
-rw-r--r--core/modules/content_moderation/src/ModerationStateInterface.php28
-rw-r--r--core/modules/content_moderation/src/ModerationStateListBuilder.php40
-rw-r--r--core/modules/content_moderation/src/ModerationStateTransitionInterface.php36
-rw-r--r--core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php173
-rw-r--r--core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php109
-rw-r--r--core/modules/content_moderation/src/Permissions.php43
-rw-r--r--core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php75
-rw-r--r--core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php75
-rw-r--r--core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php125
-rw-r--r--core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php255
-rw-r--r--core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php82
-rw-r--r--core/modules/content_moderation/src/Plugin/Menu/EditTab.php104
-rw-r--r--core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php19
-rw-r--r--core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php134
-rw-r--r--core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php136
-rw-r--r--core/modules/content_moderation/src/RevisionTracker.php152
-rw-r--r--core/modules/content_moderation/src/RevisionTrackerInterface.php28
-rw-r--r--core/modules/content_moderation/src/Routing/EntityModerationRouteProvider.php122
-rw-r--r--core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php59
-rw-r--r--core/modules/content_moderation/src/StateTransitionValidation.php247
-rw-r--r--core/modules/content_moderation/src/StateTransitionValidationInterface.php71
-rw-r--r--core/modules/content_moderation/src/Tests/ModerationFormTest.php109
-rw-r--r--core/modules/content_moderation/src/Tests/ModerationLocaleTest.php221
-rw-r--r--core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php132
-rw-r--r--core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php133
-rw-r--r--core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php69
-rw-r--r--core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php75
-rw-r--r--core/modules/content_moderation/src/Tests/ModerationStateTestBase.php145
-rw-r--r--core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php91
-rw-r--r--core/modules/content_moderation/src/Tests/NodeAccessTest.php108
-rw-r--r--core/modules/content_moderation/src/ViewsData.php260
-rw-r--r--core/modules/content_moderation/templates/entity-moderation-form.html.twig8
-rw-r--r--core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml409
-rw-r--r--core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml406
-rw-r--r--core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml447
-rw-r--r--core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml315
-rw-r--r--core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml10
-rw-r--r--core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php128
-rw-r--r--core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php107
-rw-r--r--core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php89
-rw-r--r--core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php234
-rw-r--r--core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php197
-rw-r--r--core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php95
-rw-r--r--core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php174
-rw-r--r--core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php69
-rw-r--r--core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php159
-rw-r--r--core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php72
-rw-r--r--core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php75
-rw-r--r--core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php158
-rw-r--r--core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php297
-rw-r--r--core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php1
97 files changed, 10132 insertions, 0 deletions
diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt
index c7f78ed..8f04c06 100644
--- a/core/MAINTAINERS.txt
+++ b/core/MAINTAINERS.txt
@@ -307,6 +307,9 @@ Contact module
- Jibran Ijaz 'jibran' https://www.drupal.org/u/jibran
- Andrey Postnikov 'andypost' https://www.drupal.org/u/andypost
+Content Moderation module
+- Tim Millwood 'timmillwood' https://www.drupal.org/u/timmillwood
+
Content Translation module
- Francesco Placella 'plach' https://www.drupal.org/u/plach
diff --git a/core/composer.json b/core/composer.json
index 9ac5ab0..e38b665 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -63,6 +63,7 @@
"drupal/config": "self.version",
"drupal/config_translation": "self.version",
"drupal/contact": "self.version",
+ "drupal/content_moderation": "self.version",
"drupal/content_translation": "self.version",
"drupal/contextual": "self.version",
"drupal/core-annotation": "self.version",
diff --git a/core/modules/content_moderation/config/install/content_moderation.state.archived.yml b/core/modules/content_moderation/config/install/content_moderation.state.archived.yml
new file mode 100644
index 0000000..0279481
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state.archived.yml
@@ -0,0 +1,8 @@
+langcode: en
+status: true
+dependencies: { }
+id: archived
+label: Archived
+published: false
+default_revision: true
+weight: -8
diff --git a/core/modules/content_moderation/config/install/content_moderation.state.draft.yml b/core/modules/content_moderation/config/install/content_moderation.state.draft.yml
new file mode 100644
index 0000000..c7eb64c
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state.draft.yml
@@ -0,0 +1,8 @@
+langcode: en
+status: true
+dependencies: { }
+id: draft
+label: Draft
+published: false
+default_revision: false
+weight: -10
diff --git a/core/modules/content_moderation/config/install/content_moderation.state.published.yml b/core/modules/content_moderation/config/install/content_moderation.state.published.yml
new file mode 100644
index 0000000..8467e86
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state.published.yml
@@ -0,0 +1,8 @@
+langcode: en
+status: true
+dependencies: { }
+id: published
+label: Published
+published: true
+default_revision: true
+weight: -9
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml
new file mode 100644
index 0000000..8fbf9c3
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - content_moderation.state.archived
+ - content_moderation.state.draft
+id: archived_draft
+label: 'Un-archive to Draft'
+stateFrom: archived
+stateTo: draft
+weight: -5
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml
new file mode 100644
index 0000000..4be7600
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - content_moderation.state.archived
+ - content_moderation.state.published
+id: archived_published
+label: 'Un-archive'
+stateFrom: archived
+stateTo: published
+weight: -4
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml
new file mode 100644
index 0000000..0ba0f34
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - content_moderation.state.draft
+id: draft_draft
+label: 'Create New Draft'
+stateFrom: draft
+stateTo: draft
+weight: -10
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml
new file mode 100644
index 0000000..cf95d3d
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - content_moderation.state.draft
+ - content_moderation.state.published
+id: draft_published
+label: 'Publish'
+stateFrom: draft
+stateTo: published
+weight: -9
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml
new file mode 100644
index 0000000..f3a866a
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - content_moderation.state.archived
+ - content_moderation.state.published
+id: published_archived
+label: 'Archive'
+stateFrom: published
+stateTo: archived
+weight: -6
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml
new file mode 100644
index 0000000..bd25a31
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - content_moderation.state.draft
+ - content_moderation.state.published
+id: published_draft
+label: 'Create New Draft'
+stateFrom: published
+stateTo: draft
+weight: -8
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml
new file mode 100644
index 0000000..3c09a85
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - content_moderation.state.published
+id: published_published
+label: 'Publish'
+stateFrom: published
+stateTo: published
+weight: -7
diff --git a/core/modules/content_moderation/config/schema/content_moderation.schema.yml b/core/modules/content_moderation/config/schema/content_moderation.schema.yml
new file mode 100644
index 0000000..7f9e8fd
--- /dev/null
+++ b/core/modules/content_moderation/config/schema/content_moderation.schema.yml
@@ -0,0 +1,79 @@
+content_moderation.state.*:
+ type: config_entity
+ label: 'Moderation state config'
+ mapping:
+ id:
+ type: string
+ label: 'ID'
+ label:
+ type: label
+ label: 'Label'
+ published:
+ type: boolean
+ label: 'Is published'
+ default_revision:
+ type: boolean
+ label: 'Is default revision'
+ weight:
+ type: integer
+ label: 'Weight'
+
+content_moderation.state_transition.*:
+ type: config_entity
+ label: 'Moderation state transition config'
+ mapping:
+ id:
+ type: string
+ label: 'ID'
+ label:
+ type: label
+ label: 'Label'
+ stateFrom:
+ type: string
+ label: 'From state'
+ stateTo:
+ type: string
+ label: 'To state'
+ weight:
+ type: integer
+ label: 'Weight'
+
+node.type.*.third_party.content_moderation:
+ type: mapping
+ label: 'Enable moderation states for this node type'
+ mapping:
+ enabled:
+ type: boolean
+ label: 'Moderation states enabled'
+ allowed_moderation_states:
+ type: sequence
+ sequence:
+ type: string
+ label: 'Moderation state'
+ default_moderation_state:
+ type: string
+ label: 'Moderation state for new content'
+
+block_content.type.*.third_party.content_moderation:
+ type: mapping
+ label: 'Enable moderation states for this block content type'
+ mapping:
+ enabled:
+ type: boolean
+ label: 'Moderation states enabled'
+ allowed_moderation_states:
+ type: sequence
+ sequence:
+ type: string
+ label: 'Moderation state'
+ default_moderation_state:
+ type: string
+ label: 'Moderation state for new block content'
+
+views.filter.latest_revision:
+ type: views_filter
+ label: 'Latest revision'
+ mapping:
+ value:
+ type: string
+ label: 'Value'
diff --git a/core/modules/content_moderation/content_moderation.info.yml b/core/modules/content_moderation/content_moderation.info.yml
new file mode 100644
index 0000000..6d92b64
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.info.yml
@@ -0,0 +1,7 @@
+name: 'Content Moderation'
+type: module
+description: 'Provides moderation states for content'
+version: VERSION
+core: 8.x
+package: Core (Experimental)
+configure: content_moderation.overview
diff --git a/core/modules/content_moderation/content_moderation.libraries.yml b/core/modules/content_moderation/content_moderation.libraries.yml
new file mode 100644
index 0000000..6caaaf3
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.libraries.yml
@@ -0,0 +1,5 @@
+entity-moderation-form:
+ version: VERSION
+ css:
+ layout:
+ css/entity-moderation-form.css: {}
diff --git a/core/modules/content_moderation/content_moderation.links.action.yml b/core/modules/content_moderation/content_moderation.links.action.yml
new file mode 100644
index 0000000..9de5061
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.links.action.yml
@@ -0,0 +1,11 @@
+entity.moderation_state.add_form:
+ route_name: 'entity.moderation_state.add_form'
+ title: 'Add Moderation state'
+ appears_on:
+ - entity.moderation_state.collection
+
+entity.moderation_state_transition.add_form:
+ route_name: 'entity.moderation_state_transition.add_form'
+ title: 'Add Moderation state transition'
+ appears_on:
+ - entity.moderation_state_transition.collection
diff --git a/core/modules/content_moderation/content_moderation.links.menu.yml b/core/modules/content_moderation/content_moderation.links.menu.yml
new file mode 100644
index 0000000..0fcb3eb
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.links.menu.yml
@@ -0,0 +1,21 @@
+# Moderation state menu items definition
+content_moderation.overview:
+ title: 'Content moderation'
+ route_name: content_moderation.overview
+ description: 'Configure states and transitions for entities.'
+ parent: system.admin_config_workflow
+
+entity.moderation_state.collection:
+ title: 'Moderation states'
+ route_name: entity.moderation_state.collection
+ description: 'Administer moderation states.'
+ parent: content_moderation.overview
+ weight: 10
+
+# Moderation state transition menu items definition
+entity.moderation_state_transition.collection:
+ title: 'Moderation state transitions'
+ route_name: entity.moderation_state_transition.collection
+ description: 'Administer moderation states transitions.'
+ parent: content_moderation.overview
+ weight: 20
diff --git a/core/modules/content_moderation/content_moderation.links.task.yml b/core/modules/content_moderation/content_moderation.links.task.yml
new file mode 100644
index 0000000..d715219
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.links.task.yml
@@ -0,0 +1,3 @@
+moderation_state.entities:
+ deriver: 'Drupal\content_moderation\Plugin\Derivative\DynamicLocalTasks'
+ weight: 100
diff --git a/core/modules/content_moderation/content_moderation.module b/core/modules/content_moderation/content_moderation.module
new file mode 100644
index 0000000..07a4bb5
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.module
@@ -0,0 +1,221 @@
+<?php
+
+/**
+ * @file
+ * Contains content_moderation.module.
+ */
+
+use Drupal\content_moderation\EntityOperations;
+use Drupal\content_moderation\EntityTypeInfo;
+use Drupal\content_moderation\ContentPreprocess;
+use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublishNode;
+use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublishNode;
+use Drupal\content_moderation\Plugin\Menu\EditTab;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\node\NodeInterface;
+use Drupal\node\Plugin\Action\PublishNode;
+use Drupal\node\Plugin\Action\UnpublishNode;
+
+/**
+ * Implements hook_help().
+ */
+function content_moderation_help($route_name, RouteMatchInterface $route_match) {
+ switch ($route_name) {
+ // Main module help for the content_moderation module.
+ case 'help.page.content_moderation':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Content Moderation module provides basic moderation for content. For more information, see the <a href=":content_moderation">online documentation for the Content Moderation module</a>.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation']) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Creates an EntityTypeInfo object to respond to entity hooks.
+ *
+ * @return \Drupal\content_moderation\EntityTypeInfo
+ */
+function _content_moderation_create_entity_type_info() {
+ return new EntityTypeInfo(
+ \Drupal::service('string_translation'),
+ \Drupal::service('content_moderation.moderation_information'),
+ \Drupal::service('entity_type.manager')
+ );
+}
+
+/**
+ * Implements hook_entity_base_field_info().
+ */
+function content_moderation_entity_base_field_info(EntityTypeInterface $entity_type) {
+ return _content_moderation_create_entity_type_info()->entityBaseFieldInfo($entity_type);
+}
+
+/**
+ * Implements hook_entity_type_alter().
+ */
+function content_moderation_entity_type_alter(array &$entity_types) {
+ _content_moderation_create_entity_type_info()->entityTypeAlter($entity_types);
+}
+
+/**
+ * Implements hook_entity_operation().
+ */
+function content_moderation_entity_operation(EntityInterface $entity) {
+ _content_moderation_create_entity_type_info()->entityOperation($entity);
+}
+
+/**
+ * Sets required flag based on enabled state.
+ */
+function content_moderation_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) {
+ _content_moderation_create_entity_type_info()->entityBundleFieldInfoAlter($fields, $entity_type, $bundle);
+}
+
+/**
+ * Creates an EntityOperations object to respond to entity operation hooks.
+ *
+ * @return \Drupal\content_moderation\EntityOperations
+ */
+function _content_moderation_create_entity_operations() {
+ return new EntityOperations(
+ \Drupal::service('content_moderation.moderation_information'),
+ \Drupal::service('entity_type.manager'),
+ \Drupal::service('form_builder'),
+ \Drupal::service('content_moderation.revision_tracker')
+ );
+}
+
+/**
+ * Implements hook_entity_presave().
+ */
+function content_moderation_entity_presave(EntityInterface $entity) {
+ return _content_moderation_create_entity_operations()->entityPresave($entity);
+}
+
+/**
+ * Implements hook_entity_insert().
+ */
+function content_moderation_entity_insert(EntityInterface $entity) {
+ return _content_moderation_create_entity_operations()->entityInsert($entity);
+}
+
+/**
+ * Implements hook_entity_update().
+ */
+function content_moderation_entity_update(EntityInterface $entity) {
+ return _content_moderation_create_entity_operations()->entityUpdate($entity);
+}
+
+/**
+ * Implements hook_local_tasks_alter().
+ */
+function content_moderation_local_tasks_alter(&$local_tasks) {
+ $content_entity_type_ids = array_keys(array_filter(\Drupal::entityTypeManager()->getDefinitions(), function (EntityTypeInterface $entity_type) {
+ return $entity_type->isRevisionable();
+ }));
+
+ foreach ($content_entity_type_ids as $content_entity_type_id) {
+ if (isset($local_tasks["entity.$content_entity_type_id.edit_form"])) {
+ $local_tasks["entity.$content_entity_type_id.edit_form"]['class'] = EditTab::class;
+ $local_tasks["entity.$content_entity_type_id.edit_form"]['entity_type_id'] = $content_entity_type_id;
+ }
+ }
+}
+
+/**
+ * Implements hook_form_alter().
+ */
+function content_moderation_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+ _content_moderation_create_entity_type_info()->bundleFormAlter($form, $form_state, $form_id);
+}
+
+/**
+ * Implements hook_preprocess_HOOK().
+ *
+ * Many default node templates rely on $page to determine whether to output the
+ * node title as part of the node content.
+ */
+function content_moderation_preprocess_node(&$variables) {
+ $content_process = new ContentPreprocess(\Drupal::routeMatch());
+ $content_process->preprocessNode($variables);
+}
+
+/**
+ * Implements hook_entity_extra_field_info().
+ */
+function content_moderation_entity_extra_field_info() {
+ return _content_moderation_create_entity_type_info()->entityExtraFieldInfo();
+}
+
+/**
+ * Implements hook_entity_view().
+ */
+function content_moderation_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
+ _content_moderation_create_entity_operations()->entityView($build, $entity, $display, $view_mode);
+}
+
+/**
+ * Implements hook_node_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) {
+ /** @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())
+ ? AccessResult::allowedIfHasPermission($account, 'view any unpublished content')
+ : AccessResult::neutral();
+
+ $access_result->addCacheableDependency($node);
+ }
+ elseif ($operation === 'update' && $moderation_info->isModeratableEntity($node) && $node->moderation_state && $node->moderation_state->target_id) {
+ /** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */
+ $transition_validation = \Drupal::service('content_moderation.state_transition_validation');
+
+ $valid_transition_targets = $transition_validation->getValidTransitionTargets($node, $account);
+ $access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden();
+
+ $access_result->addCacheableDependency($node);
+ $access_result->addCacheableDependency($account);
+ foreach ($valid_transition_targets as $valid_transition_target) {
+ $access_result->addCacheableDependency($valid_transition_target);
+ }
+ }
+
+ return $access_result;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function content_moderation_theme() {
+ return ['entity_moderation_form' => ['render element' => 'form']];
+}
+
+/**
+ * Implements hook_action_info_alter().
+ */
+function content_moderation_action_info_alter(&$definitions) {
+
+ // The publish/unpublish actions are not valid on moderated entities. So swap
+ // their implementations out for alternates that will become a no-op on a
+ // moderated node. If another module has already swapped out those classes,
+ // though, we'll be polite and do nothing.
+ if (isset($definitions['node_publish_action']['class']) && $definitions['node_publish_action']['class'] == PublishNode::class) {
+ $definitions['node_publish_action']['class'] = ModerationOptOutPublishNode::class;
+ }
+ if (isset($definitions['node_unpublish_action']['class']) && $definitions['node_unpublish_action']['class'] == UnpublishNode::class) {
+ $definitions['node_unpublish_action']['class'] = ModerationOptOutUnpublishNode::class;
+ }
+}
diff --git a/core/modules/content_moderation/content_moderation.permissions.yml b/core/modules/content_moderation/content_moderation.permissions.yml
new file mode 100644
index 0000000..293a77d
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.permissions.yml
@@ -0,0 +1,24 @@
+view any unpublished content:
+ title: 'View any unpublished content'
+ description: 'This permission is necessary for any users that may moderate content.'
+
+'view moderation states':
+ title: 'View moderation states'
+ description: 'View moderation states.'
+
+'administer moderation states':
+ title: 'Administer moderation states'
+ description: 'Create and edit moderation states.'
+ 'restrict access': TRUE
+
+'administer moderation state transitions':
+ title: 'Administer content moderation state transitions'
+ description: 'Create and edit content moderation state transitions.'
+ 'restrict access': TRUE
+
+view latest version:
+ title: 'View the latest version'
+ description: 'View the latest version of an entity. (Also requires "View any unpublished content" permission)'
+
+permission_callbacks:
+ - \Drupal\content_moderation\Permissions::transitionPermissions
diff --git a/core/modules/content_moderation/content_moderation.routing.yml b/core/modules/content_moderation/content_moderation.routing.yml
new file mode 100644
index 0000000..cc7a5ff
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.routing.yml
@@ -0,0 +1,73 @@
+content_moderation.overview:
+ path: '/admin/config/workflow/moderation'
+ defaults:
+ _controller: '\Drupal\system\Controller\SystemController::systemAdminMenuBlockPage'
+ _title: 'Content moderation'
+ requirements:
+ _permission: 'access administration pages'
+
+# ModerationState routing definition
+entity.moderation_state.collection:
+ path: '/admin/config/workflow/moderation/states'
+ defaults:
+ _entity_list: 'moderation_state'
+ _title: 'Moderation states'
+ requirements:
+ _permission: 'administer moderation states'
+
+entity.moderation_state.add_form:
+ path: '/admin/config/workflow/moderation/states/add'
+ defaults:
+ _entity_form: 'moderation_state.add'
+ _title: 'Add Moderation state'
+ requirements:
+ _permission: 'administer moderation states'
+
+entity.moderation_state.edit_form:
+ path: '/admin/config/workflow/moderation/states/{moderation_state}'
+ defaults:
+ _entity_form: 'moderation_state.edit'
+ _title: 'Edit Moderation state'
+ requirements:
+ _permission: 'administer moderation states'
+
+entity.moderation_state.delete_form:
+ path: '/admin/config/workflow/moderation/states/{moderation_state}/delete'
+ defaults:
+ _entity_form: 'moderation_state.delete'
+ _title: 'Delete Moderation state'
+ requirements:
+ _permission: 'administer moderation states'
+
+# ModerationStateTransition routing definition
+entity.moderation_state_transition.collection:
+ path: '/admin/config/workflow/moderation/transitions'
+ defaults:
+ _entity_list: 'moderation_state_transition'
+ _title: 'Moderation state transitions'
+ requirements:
+ _permission: 'administer moderation state transitions'
+
+entity.moderation_state_transition.add_form:
+ path: '/admin/config/workflow/moderation/transitions/add'
+ defaults:
+ _entity_form: 'moderation_state_transition.add'
+ _title: 'Add Moderation state transition'
+ requirements:
+ _permission: 'administer moderation state transitions'
+
+entity.moderation_state_transition.edit_form:
+ path: '/admin/config/workflow/moderation/transitions/{moderation_state_transition}'
+ defaults:
+ _entity_form: 'moderation_state_transition.edit'
+ _title: 'Edit Moderation state transition'
+ requirements:
+ _permission: 'administer moderation state transitions'
+
+entity.moderation_state_transition.delete_form:
+ path: '/admin/config/workflow/moderation/transitions/{moderation_state_transition}/delete'
+ defaults:
+ _entity_form: 'moderation_state_transition.delete'
+ _title: 'Delete Moderation state transition'
+ requirements:
+ _permission: 'administer moderation state transitions'
diff --git a/core/modules/content_moderation/content_moderation.services.yml b/core/modules/content_moderation/content_moderation.services.yml
new file mode 100644
index 0000000..02008ea
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.services.yml
@@ -0,0 +1,22 @@
+services:
+ paramconverter.latest_revision:
+ class: Drupal\content_moderation\ParamConverter\EntityRevisionConverter
+ arguments: ['@entity.manager', '@content_moderation.moderation_information']
+ tags:
+ - { name: paramconverter, priority: 5 }
+ content_moderation.state_transition_validation:
+ class: \Drupal\content_moderation\StateTransitionValidation
+ arguments: ['@entity_type.manager', '@entity.query']
+ content_moderation.moderation_information:
+ class: Drupal\content_moderation\ModerationInformation
+ arguments: ['@entity_type.manager', '@current_user']
+ access_check.latest_revision:
+ class: Drupal\content_moderation\Access\LatestRevisionCheck
+ arguments: ['@content_moderation.moderation_information']
+ tags:
+ - { name: access_check, applies_to: _content_moderation_latest_version }
+ content_moderation.revision_tracker:
+ class: Drupal\content_moderation\RevisionTracker
+ arguments: ['@database']
+ tags:
+ - { name: backend_overridable }
diff --git a/core/modules/content_moderation/content_moderation.views.inc b/core/modules/content_moderation/content_moderation.views.inc
new file mode 100644
index 0000000..faabc6a
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.views.inc
@@ -0,0 +1,37 @@
+<?php
+
+/**
+ * @file
+ * Provide views data for content_moderation.module.
+ *
+ * @ingroup views_module_handlers
+ */
+
+use Drupal\content_moderation\ViewsData;
+
+/**
+ * Implements hook_views_data().
+ */
+function content_moderation_views_data() {
+ return _content_moderation_views_data_object()->getViewsData();
+}
+
+/**
+ * Implements hook_views_data_alter().
+ */
+function content_moderation_views_data_alter(array &$data) {
+ _content_moderation_views_data_object()->alterViewsData($data);
+}
+
+/**
+ * Creates a ViewsData object to respond to views hooks.
+ *
+ * @return \Drupal\content_moderation\ViewsData
+ * The content moderation ViewsData object.
+ */
+function _content_moderation_views_data_object() {
+ return new ViewsData(
+ \Drupal::service('entity_type.manager'),
+ \Drupal::service('content_moderation.moderation_information')
+ );
+}
diff --git a/core/modules/content_moderation/css/entity-moderation-form.css b/core/modules/content_moderation/css/entity-moderation-form.css
new file mode 100644
index 0000000..ec09407
--- /dev/null
+++ b/core/modules/content_moderation/css/entity-moderation-form.css
@@ -0,0 +1,16 @@
+ul.entity-moderation-form {
+ list-style: none;
+ display: -webkit-flex; /* Safari */
+ display: flex;
+ -webkit-flex-wrap: wrap; /* Safari */
+ flex-wrap: wrap;
+ -webkit-justify-content: space-around; /* Safari */
+ justify-content: space-around;
+ -webkit-align-items: flex-end; /* Safari */
+ align-items: flex-end;
+ border-bottom: 1px solid gray;
+}
+
+ul.entity-moderation-form input[type=submit] {
+ margin-bottom: 1.2em;
+}
diff --git a/core/modules/content_moderation/src/Access/LatestRevisionCheck.php b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php
new file mode 100644
index 0000000..528d195
--- /dev/null
+++ b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\content_moderation\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Access check for the entity moderation tab.
+ */
+class LatestRevisionCheck implements AccessInterface {
+
+ /**
+ * The moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInfo;
+
+ /**
+ * Constructs a new LatestRevisionCheck.
+ *
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+ * The moderation information service.
+ */
+ public function __construct(ModerationInformationInterface $moderation_information) {
+ $this->moderationInfo = $moderation_information;
+ }
+
+ /**
+ * Checks that there is a forward revision available.
+ *
+ * This checker assumes the presence of an '_entity_access' requirement key
+ * in the same form as used by EntityAccessCheck.
+ *
+ * @param \Symfony\Component\Routing\Route $route
+ * The route to check against.
+ * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+ * The parametrized route.
+ *
+ * @return \Drupal\Core\Access\AccessResultInterface
+ * The access result.
+ *
+ * @see \Drupal\Core\Entity\EntityAccessCheck
+ */
+ public function access(Route $route, RouteMatchInterface $route_match) {
+ // This tab should not show up unless there's a reason to show it.
+ $entity = $this->loadEntity($route, $route_match);
+ return $this->moderationInfo->hasForwardRevision($entity)
+ ? AccessResult::allowed()->addCacheableDependency($entity)
+ : AccessResult::forbidden()->addCacheableDependency($entity);
+ }
+
+ /**
+ * Returns the default revision of the entity this route is for.
+ *
+ * @param \Symfony\Component\Routing\Route $route
+ * The route to check against.
+ * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+ * The parametrized route.
+ *
+ * @return \Drupal\Core\Entity\ContentEntityInterface
+ * returns the Entity in question.
+ *
+ * @throws \Exception
+ * A generic exception is thrown if the entity couldn't be loaded. This
+ * almost always implies a developer error, so it should get turned into
+ * an HTTP 500.
+ */
+ protected function loadEntity(Route $route, RouteMatchInterface $route_match) {
+ $entity_type = $route->getOption('_content_moderation_entity_type');
+
+ if ($entity = $route_match->getParameter($entity_type)) {
+ if ($entity instanceof EntityInterface) {
+ return $entity;
+ }
+ }
+ throw new \Exception(sprintf('%s is not a valid entity route. The LatestRevisionCheck access checker may only be used with a route that has a single entity parameter.', $route_match->getRouteName()));
+ }
+
+}
diff --git a/core/modules/content_moderation/src/ContentModerationStateInterface.php b/core/modules/content_moderation/src/ContentModerationStateInterface.php
new file mode 100644
index 0000000..5b7ee2e
--- /dev/null
+++ b/core/modules/content_moderation/src/ContentModerationStateInterface.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\user\EntityOwnerInterface;
+
+/**
+ * An interface for Content moderation state entity.
+ *
+ * Content moderation state entities track the moderation state of other content
+ * entities.
+ */
+interface ContentModerationStateInterface extends ContentEntityInterface, EntityOwnerInterface {
+
+}
diff --git a/core/modules/content_moderation/src/ContentModerationStateStorageSchema.php b/core/modules/content_moderation/src/ContentModerationStateStorageSchema.php
new file mode 100644
index 0000000..19ec324
--- /dev/null
+++ b/core/modules/content_moderation/src/ContentModerationStateStorageSchema.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+use Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema;
+
+/**
+ * Defines the content moderation state schema handler.
+ */
+class ContentModerationStateStorageSchema extends SqlContentEntityStorageSchema {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $reset = FALSE) {
+ $schema = parent::getEntitySchema($entity_type, $reset);
+
+ // Creates an index to ensure that the lookup in
+ // \Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList::getModerationState()
+ // is performant.
+ $schema['content_moderation_state_field_data']['indexes'] += array(
+ 'content_moderation_state__lookup' => array('content_entity_type_id', 'content_entity_id', 'content_entity_revision_id'),
+ );
+
+ return $schema;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/ContentPreprocess.php b/core/modules/content_moderation/src/ContentPreprocess.php
new file mode 100644
index 0000000..b3b7337
--- /dev/null
+++ b/core/modules/content_moderation/src/ContentPreprocess.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\node\Entity\Node;
+
+/**
+ * Service to determine whether a route is the "Latest version" tab of a node.
+ */
+class ContentPreprocess {
+
+ /**
+ * The route match service.
+ *
+ * @var \Drupal\Core\Routing\RouteMatchInterface $routeMatch
+ */
+ protected $routeMatch;
+
+ /**
+ * Constructor.
+ *
+ * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+ * Current route match service.
+ */
+ public function __construct(RouteMatchInterface $route_match) {
+ $this->routeMatch = $route_match;
+ }
+
+ /**
+ * Wrapper for hook_preprocess_HOOK().
+ *
+ * @param array $variables
+ * Theme variables to preprocess.
+ */
+ public function preprocessNode(array &$variables) {
+ // Set the 'page' template variable when the node is being displayed on the
+ // "Latest version" tab provided by content_moderation.
+ $variables['page'] = $variables['page'] || $this->isLatestVersionPage($variables['node']);
+ }
+
+ /**
+ * Checks whether a route is the "Latest version" tab of a node.
+ *
+ * @param \Drupal\node\Entity\Node $node
+ * A node.
+ *
+ * @return bool
+ * True if the current route is the latest version tab of the given node.
+ */
+ public function isLatestVersionPage(Node $node) {
+ return $this->routeMatch->getRouteName() == 'entity.node.latest_version'
+ && ($pageNode = $this->routeMatch->getParameter('node'))
+ && $pageNode->id() == $node->id();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/ContentModerationState.php b/core/modules/content_moderation/src/Entity/ContentModerationState.php
new file mode 100644
index 0000000..1ff7f2a
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/ContentModerationState.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Drupal\content_moderation\Entity;
+
+use Drupal\content_moderation\ContentModerationStateInterface;
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityChangedTrait;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\TypedData\TranslatableInterface;
+use Drupal\user\UserInterface;
+
+/**
+ * Defines the Content moderation state entity.
+ *
+ * @ContentEntityType(
+ * id = "content_moderation_state",
+ * label = @Translation("Content moderation state"),
+ * label_singular = @Translation("content moderation state"),
+ * label_plural = @Translation("content moderation states"),
+ * label_count = @PluralTranslation(
+ * singular = "@count content moderation state",
+ * plural = "@count content moderation states"
+ * ),
+ * handlers = {
+ * "storage_schema" = "Drupal\content_moderation\ContentModerationStateStorageSchema",
+ * "views_data" = "\Drupal\views\EntityViewsData",
+ * },
+ * base_table = "content_moderation_state",
+ * revision_table = "content_moderation_state_revision",
+ * data_table = "content_moderation_state_field_data",
+ * revision_data_table = "content_moderation_state_field_revision",
+ * translatable = TRUE,
+ * entity_keys = {
+ * "id" = "id",
+ * "revision" = "revision_id",
+ * "uuid" = "uuid",
+ * "uid" = "uid",
+ * "langcode" = "langcode",
+ * }
+ * )
+ */
+class ContentModerationState extends ContentEntityBase implements ContentModerationStateInterface {
+
+ use EntityChangedTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+ $fields = parent::baseFieldDefinitions($entity_type);
+
+ $fields['uid'] = BaseFieldDefinition::create('entity_reference')
+ ->setLabel(t('User'))
+ ->setDescription(t('The username of the entity creator.'))
+ ->setSetting('target_type', 'user')
+ ->setDefaultValueCallback('Drupal\content_moderation\Entity\ContentModerationState::getCurrentUserId')
+ ->setTranslatable(TRUE)
+ ->setRevisionable(TRUE);
+
+ $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference')
+ ->setLabel(t('Moderation state'))
+ ->setDescription(t('The moderation state of the referenced content.'))
+ ->setSetting('target_type', 'moderation_state')
+ ->setRequired(TRUE)
+ ->setTranslatable(TRUE)
+ ->setRevisionable(TRUE)
+ ->addConstraint('ModerationState', []);
+
+ $fields['content_entity_type_id'] = BaseFieldDefinition::create('string')
+ ->setLabel(t('Content entity type ID'))
+ ->setDescription(t('The ID of the content entity type this moderation state is for.'))
+ ->setRequired(TRUE)
+ ->setRevisionable(TRUE);
+
+ $fields['content_entity_id'] = BaseFieldDefinition::create('integer')
+ ->setLabel(t('Content entity ID'))
+ ->setDescription(t('The ID of the content entity this moderation state is for.'))
+ ->setRequired(TRUE)
+ ->setRevisionable(TRUE);
+
+ // @todo https://www.drupal.org/node/2779931 Add constraint that enforces
+ // unique content_entity_type_id, content_entity_id and
+ // content_entity_revision_id.
+
+ $fields['content_entity_revision_id'] = BaseFieldDefinition::create('integer')
+ ->setLabel(t('Content entity revision ID'))
+ ->setDescription(t('The revision ID of the content entity this moderation state is for.'))
+ ->setRequired(TRUE)
+ ->setRevisionable(TRUE);
+
+ return $fields;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOwner() {
+ return $this->get('uid')->entity;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOwnerId() {
+ return $this->getEntityKey('uid');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setOwnerId($uid) {
+ $this->set('uid', $uid);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setOwner(UserInterface $account) {
+ $this->set('uid', $account->id());
+ return $this;
+ }
+
+ /**
+ * Creates or updates an entity's moderation state whilst saving that entity.
+ *
+ * @param \Drupal\content_moderation\Entity\ContentModerationState $content_moderation_state
+ * The content moderation entity content entity to create or save.
+ *
+ * @internal
+ * This method should only be called as a result of saving the related
+ * content entity.
+ */
+ public static function updateOrCreateFromEntity(ContentModerationState $content_moderation_state) {
+ $content_moderation_state->realSave();
+ }
+
+ /**
+ * Default value callback for the 'uid' base field definition.
+ *
+ * @see \Drupal\content_moderation\Entity\ContentModerationState::baseFieldDefinitions()
+ *
+ * @return array
+ * An array of default values.
+ */
+ public static function getCurrentUserId() {
+ return array(\Drupal::currentUser()->id());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function save() {
+ $related_entity = \Drupal::entityTypeManager()
+ ->getStorage($this->content_entity_type_id->value)
+ ->loadRevision($this->content_entity_revision_id->value);
+ if ($related_entity instanceof TranslatableInterface) {
+ $related_entity = $related_entity->getTranslation($this->activeLangcode);
+ }
+ $related_entity->moderation_state->target_id = $this->moderation_state->target_id;
+ return $related_entity->save();
+ }
+
+ /**
+ * Saves an entity permanently.
+ *
+ * When saving existing entities, the entity is assumed to be complete,
+ * partial updates of entities are not supported.
+ *
+ * @return int
+ * Either SAVED_NEW or SAVED_UPDATED, depending on the operation performed.
+ *
+ * @throws \Drupal\Core\Entity\EntityStorageException
+ * In case of failures an exception is thrown.
+ */
+ protected function realSave() {
+ return parent::save();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php
new file mode 100644
index 0000000..b88b415
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Customizations for block content entities.
+ */
+class BlockContentModerationHandler extends ModerationHandler {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+ $form['revision_information']['revision']['#default_value'] = TRUE;
+ $form['revision_information']['revision']['#disabled'] = TRUE;
+ $form['revision_information']['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+ $form['revision']['#default_value'] = 1;
+ $form['revision']['#disabled'] = TRUE;
+ $form['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.');
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php
new file mode 100644
index 0000000..9d89253
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityHandlerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Common customizations for most/all entities.
+ *
+ * This class is intended primarily as a base class.
+ */
+class ModerationHandler implements ModerationHandlerInterface, EntityHandlerInterface {
+
+ use StringTranslationTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+ return new static();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
+ // This is probably not necessary if configuration is setup correctly.
+ $entity->setNewRevision(TRUE);
+ $entity->isDefaultRevision($default_revision);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function onBundleModerationConfigurationFormSubmit(ConfigEntityInterface $bundle) {
+ // The Revisions portion of Entity API is not uniformly applied or
+ // consistent. Until that's fixed, we'll make a best-attempt to apply it to
+ // the common entity patterns so as to avoid every entity type needing to
+ // implement this method, although some will still need to do so for now.
+ // This is the API that should be universal, but isn't yet.
+ // @see \Drupal\node\Entity\NodeType
+ if (method_exists($bundle, 'setNewRevision')) {
+ $bundle->setNewRevision(TRUE);
+ }
+ // This is the raw property used by NodeType, and likely others.
+ elseif ($bundle->get('new_revision') !== NULL) {
+ $bundle->set('new_revision', TRUE);
+ }
+ // This is the raw property used by BlockContentType, and maybe others.
+ elseif ($bundle->get('revision') !== NULL) {
+ $bundle->set('revision', TRUE);
+ }
+
+ $bundle->save();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php
new file mode 100644
index 0000000..e897cf4
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Defines operations that need to vary by entity type.
+ *
+ * Much of the logic contained in this handler is an indication of flaws
+ * in the Entity API that are insufficiently standardized between entity types.
+ * Hopefully over time functionality can be removed from this interface.
+ */
+interface ModerationHandlerInterface {
+
+ /**
+ * Operates on moderatable content entities preSave().
+ *
+ * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+ * The entity to modify.
+ * @param bool $default_revision
+ * Whether the new revision should be made the default revision.
+ * @param bool $published_state
+ * Whether the state being transitioned to is a published state or not.
+ */
+ public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state);
+
+ /**
+ * Operates on the bundle definition that has been marked as moderatable.
+ *
+ * Note: The values on the EntityModerationForm itself are already saved
+ * so do not need to be saved here. If any changes are made to the bundle
+ * object here it is this method's responsibility to call save() on it.
+ *
+ * The most common use case is to force revisions on for this bundle if
+ * moderation is enabled. That, sadly, does not have a common API in core.
+ *
+ * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle
+ * The bundle definition that is being saved.
+ */
+ public function onBundleModerationConfigurationFormSubmit(ConfigEntityInterface $bundle);
+
+ /**
+ * Alters entity forms to enforce revision handling.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ * @param string $form_id
+ * The form id.
+ *
+ * @see hook_form_alter()
+ */
+ public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id);
+
+ /**
+ * Alters bundle forms to enforce revision handling.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ * @param string $form_id
+ * The form id.
+ *
+ * @see hook_form_alter()
+ */
+ public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id);
+
+}
diff --git a/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php
new file mode 100644
index 0000000..83de187
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Customizations for node entities.
+ */
+class NodeModerationHandler extends ModerationHandler {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
+ if ($this->shouldModerate($entity, $published_state)) {
+ parent::onPresave($entity, $default_revision, $published_state);
+ // Only nodes have a concept of published.
+ /** @var \Drupal\node\NodeInterface $entity */
+ $entity->setPublished($published_state);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+ $form['revision']['#disabled'] = TRUE;
+ $form['revision']['#default_value'] = TRUE;
+ $form['revision']['#description'] = $this->t('Revisions are required.');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+ /* @var \Drupal\node\Entity\NodeType $entity */
+ $entity = $form_state->getFormObject()->getEntity();
+
+ if ($entity->getThirdPartySetting('content_moderation', 'enabled', FALSE)) {
+ // Force the revision checkbox on.
+ $form['workflow']['options']['#default_value']['revision'] = 'revision';
+ $form['workflow']['options']['revision']['#disabled'] = TRUE;
+ }
+ }
+
+ /**
+ * Check if an entity's default revision and/or state needs adjusting.
+ *
+ * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+ * The entity to check.
+ * @param bool $published_state
+ * Whether the state being transitioned to is a published state or not.
+ *
+ * @return bool
+ * TRUE when either the default revision or the state needs to be updated.
+ */
+ protected function shouldModerate(ContentEntityInterface $entity, $published_state) {
+ // @todo clarify the first condition.
+ // First condition is needed so you can add a translation.
+ // Second condition checks to see if the published status has changed.
+ return $entity->isDefaultTranslation() || $entity->isPublished() !== $published_state;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/ModerationState.php b/core/modules/content_moderation/src/Entity/ModerationState.php
new file mode 100644
index 0000000..0522e7d
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/ModerationState.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\content_moderation\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\content_moderation\ModerationStateInterface;
+
+/**
+ * Defines the Moderation state entity.
+ *
+ * @ConfigEntityType(
+ * id = "moderation_state",
+ * label = @Translation("Moderation state"),
+ * handlers = {
+ * "access" = "Drupal\content_moderation\ModerationStateAccessControlHandler",
+ * "list_builder" = "Drupal\content_moderation\ModerationStateListBuilder",
+ * "form" = {
+ * "add" = "Drupal\content_moderation\Form\ModerationStateForm",
+ * "edit" = "Drupal\content_moderation\Form\ModerationStateForm",
+ * "delete" = "Drupal\content_moderation\Form\ModerationStateDeleteForm"
+ * },
+ * },
+ * config_prefix = "state",
+ * entity_keys = {
+ * "id" = "id",
+ * "label" = "label",
+ * "uuid" = "uuid",
+ * "weight" = "weight",
+ * },
+ * links = {
+ * "edit-form" = "/admin/config/workflow/moderation/states/{moderation_state}/edit",
+ * "delete-form" = "/admin/config/workflow/moderation/states/{moderation_state}/delete",
+ * "collection" = "/admin/config/workflow/moderation/states"
+ * },
+ * config_export = {
+ * "id",
+ * "label",
+ * "published",
+ * "default_revision",
+ * "weight",
+ * },
+ * )
+ */
+class ModerationState extends ConfigEntityBase implements ModerationStateInterface {
+
+ /**
+ * The Moderation state ID.
+ *
+ * @var string
+ */
+ protected $id;
+
+ /**
+ * The Moderation state label.
+ *
+ * @var string
+ */
+ protected $label;
+
+ /**
+ * Whether this state represents a published node.
+ *
+ * @var bool
+ */
+ protected $published;
+
+ /**
+ * Relative weight of this state.
+ *
+ * @var int
+ */
+ protected $weight;
+
+ /**
+ * Whether this state represents a default revision of the node.
+ *
+ * If this is a published state, then this property is ignored.
+ *
+ * @var bool
+ */
+ protected $default_revision;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isPublishedState() {
+ return $this->published;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isDefaultRevisionState() {
+ return $this->published || $this->default_revision;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/ModerationStateTransition.php b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php
new file mode 100644
index 0000000..99dbf93
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\content_moderation\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\content_moderation\ModerationStateTransitionInterface;
+
+/**
+ * Defines the Moderation state transition entity.
+ *
+ * @ConfigEntityType(
+ * id = "moderation_state_transition",
+ * label = @Translation("Moderation state transition"),
+ * handlers = {
+ * "list_builder" = "Drupal\content_moderation\ModerationStateTransitionListBuilder",
+ * "form" = {
+ * "add" = "Drupal\content_moderation\Form\ModerationStateTransitionForm",
+ * "edit" = "Drupal\content_moderation\Form\ModerationStateTransitionForm",
+ * "delete" = "Drupal\content_moderation\Form\ModerationStateTransitionDeleteForm"
+ * },
+ * },
+ * config_prefix = "state_transition",
+ * admin_permission = "administer moderation state transitions",
+ * entity_keys = {
+ * "id" = "id",
+ * "label" = "label",
+ * "uuid" = "uuid",
+ * "weight" = "weight"
+ * },
+ * links = {
+ * "edit-form" = "/admin/config/workflow/moderation/transitions/{moderation_state_transition}/edit",
+ * "delete-form" = "/admin/config/workflow/moderation/transitions/{moderation_state_transition}/delete",
+ * "collection" = "/admin/config/workflow/moderation/transitions"
+ * }
+ * )
+ */
+class ModerationStateTransition extends ConfigEntityBase implements ModerationStateTransitionInterface {
+
+ /**
+ * The Moderation state transition ID.
+ *
+ * @var string
+ */
+ protected $id;
+
+ /**
+ * The Moderation state transition label.
+ *
+ * @var string
+ */
+ protected $label;
+
+ /**
+ * ID of from state.
+ *
+ * @var string
+ */
+ protected $stateFrom;
+
+ /**
+ * ID of to state.
+ *
+ * @var string
+ */
+ protected $stateTo;
+
+ /**
+ * Relative weight of this transition.
+ *
+ * @var int
+ */
+ protected $weight;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function calculateDependencies() {
+ parent::calculateDependencies();
+
+ if ($this->stateFrom) {
+ $this->addDependency('config', ModerationState::load($this->stateFrom)->getConfigDependencyName());
+ }
+ if ($this->stateTo) {
+ $this->addDependency('config', ModerationState::load($this->stateTo)->getConfigDependencyName());
+ }
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFromState() {
+ return $this->stateFrom;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getToState() {
+ return $this->stateTo;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getWeight() {
+ return $this->weight;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/EntityOperations.php b/core/modules/content_moderation/src/EntityOperations.php
new file mode 100644
index 0000000..618fda8
--- /dev/null
+++ b/core/modules/content_moderation/src/EntityOperations.php
@@ -0,0 +1,279 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\content_moderation\Entity\ContentModerationState;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBuilderInterface;
+use Drupal\Core\TypedData\TranslatableInterface;
+use Drupal\content_moderation\Form\EntityModerationForm;
+
+/**
+ * Defines a class for reacting to entity events.
+ */
+class EntityOperations {
+
+ /**
+ * The Moderation Information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInfo;
+
+ /**
+ * The Entity Type Manager service.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The Form Builder service.
+ *
+ * @var \Drupal\Core\Form\FormBuilderInterface
+ */
+ protected $formBuilder;
+
+ /**
+ * The Revision Tracker service.
+ *
+ * @var \Drupal\content_moderation\RevisionTrackerInterface
+ */
+ protected $tracker;
+
+ /**
+ * Constructs a new EntityOperations object.
+ *
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+ * Moderation information service.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * Entity type manager service.
+ * @param \Drupal\Core\Form\FormBuilderInterface $form_builder
+ * The form builder.
+ * @param \Drupal\content_moderation\RevisionTrackerInterface $tracker
+ * The revision tracker.
+ */
+ public function __construct(ModerationInformationInterface $moderation_info, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, RevisionTrackerInterface $tracker) {
+ $this->moderationInfo = $moderation_info;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->formBuilder = $form_builder;
+ $this->tracker = $tracker;
+ }
+
+ /**
+ * Determines the default moderation state on load for an entity.
+ *
+ * This method is only applicable when an entity is loaded that has
+ * no moderation state on it, but should. In those cases, failing to set
+ * one may result in NULL references elsewhere when other code tries to check
+ * the moderation state of the entity.
+ *
+ * The amount of indirection here makes performance a concern, but
+ * given how Entity API works I don't know how else to do it.
+ * This reliably gets us *A* valid state. However, that state may be
+ * not the ideal one. Suggestions on how to better select the default
+ * state here are welcome.
+ *
+ * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+ * The entity for which we want a default state.
+ *
+ * @return string
+ * The default state for the given entity.
+ */
+ protected function getDefaultLoadStateId(ContentEntityInterface $entity) {
+ return $this->moderationInfo
+ ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle())
+ ->getThirdPartySetting('content_moderation', 'default_moderation_state');
+ }
+
+ /**
+ * Acts on an entity and set published status based on the moderation state.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity being saved.
+ */
+ public function entityPresave(EntityInterface $entity) {
+ if (!$this->moderationInfo->isModeratableEntity($entity)) {
+ return;
+ }
+ if ($entity->moderation_state->target_id) {
+ $moderation_state = $this->entityTypeManager
+ ->getStorage('moderation_state')
+ ->load($entity->moderation_state->target_id);
+ $published_state = $moderation_state->isPublishedState();
+
+ // This entity is default if it is new, the default revision, or the
+ // default revision is not published.
+ $update_default_revision = $entity->isNew()
+ || $moderation_state->isDefaultRevisionState()
+ || !$this->isDefaultRevisionPublished($entity);
+
+ // Fire per-entity-type logic for handling the save process.
+ $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->onPresave($entity, $update_default_revision, $published_state);
+ }
+ }
+
+ /**
+ * Hook bridge.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity that was just saved.
+ *
+ * @see hook_entity_insert()
+ */
+ public function entityInsert(EntityInterface $entity) {
+ if (!$this->moderationInfo->isModeratableEntity($entity)) {
+ return;
+ }
+ $this->updateOrCreateFromEntity($entity);
+ $this->setLatestRevision($entity);
+ }
+
+ /**
+ * Hook bridge.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity that was just saved.
+ *
+ * @see hook_entity_update()
+ */
+ public function entityUpdate(EntityInterface $entity) {
+ if (!$this->moderationInfo->isModeratableEntity($entity)) {
+ return;
+ }
+ $this->updateOrCreateFromEntity($entity);
+ $this->setLatestRevision($entity);
+ }
+
+ /**
+ * Creates or updates the moderation state of an entity.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity to update or create a moderation state for.
+ */
+ protected function updateOrCreateFromEntity(EntityInterface $entity) {
+ $moderation_state = $entity->moderation_state->target_id;
+ /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+ if (!$moderation_state) {
+ $moderation_state = $this->moderationInfo
+ ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle())
+ ->getThirdPartySetting('content_moderation', 'default_moderation_state');
+ }
+
+ // @todo what if $entity->moderation_state->target_id is null at this point?
+ $entity_type_id = $entity->getEntityTypeId();
+ $entity_id = $entity->id();
+ $entity_revision_id = $entity->getRevisionId();
+ $entity_langcode = $entity->language()->getId();
+
+ $storage = $this->entityTypeManager->getStorage('content_moderation_state');
+ $entities = $storage->loadByProperties([
+ 'content_entity_type_id' => $entity_type_id,
+ 'content_entity_id' => $entity_id,
+ ]);
+
+ /** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */
+ $content_moderation_state = reset($entities);
+ if (!($content_moderation_state instanceof ContentModerationStateInterface)) {
+ $content_moderation_state = $storage->create([
+ 'content_entity_type_id' => $entity_type_id,
+ 'content_entity_id' => $entity_id,
+ ]);
+ }
+ else {
+ // Create a new revision.
+ $content_moderation_state->setNewRevision(TRUE);
+ }
+
+ // Sync translations.
+ if (!$content_moderation_state->hasTranslation($entity_langcode)) {
+ $content_moderation_state->addTranslation($entity_langcode);
+ }
+ if ($content_moderation_state->language()->getId() !== $entity_langcode) {
+ $content_moderation_state = $content_moderation_state->getTranslation($entity_langcode);
+ }
+
+ // Create the ContentModerationState entity for the inserted entity.
+ $content_moderation_state->set('content_entity_revision_id', $entity_revision_id);
+ $content_moderation_state->set('moderation_state', $moderation_state);
+ ContentModerationState::updateOrCreateFromEntity($content_moderation_state);
+ }
+
+ /**
+ * Set the latest revision.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The content entity to create content_moderation_state entity for.
+ */
+ protected function setLatestRevision(EntityInterface $entity) {
+ /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+ $this->tracker->setLatestRevision(
+ $entity->getEntityTypeId(),
+ $entity->id(),
+ $entity->language()->getId(),
+ $entity->getRevisionId()
+ );
+ }
+
+ /**
+ * Act on entities being assembled before rendering.
+ *
+ * This is a hook bridge.
+ *
+ * @see hook_entity_view()
+ * @see EntityFieldManagerInterface::getExtraFields()
+ */
+ public function entityView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
+ if (!$this->moderationInfo->isModeratableEntity($entity)) {
+ return;
+ }
+ if (!$this->moderationInfo->isLatestRevision($entity)) {
+ return;
+ }
+ /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+ if ($entity->isDefaultRevision()) {
+ return;
+ }
+
+ $component = $display->getComponent('content_moderation_control');
+ if ($component) {
+ $build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity);
+ $build['content_moderation_control']['#weight'] = $component['weight'];
+ }
+ }
+
+ /**
+ * Check if the default revision for the given entity is published.
+ *
+ * The default revision is the same as the entity retrieved by "default" from
+ * the storage handler. If the entity is translated, use the default revision
+ * of the same language as the given entity.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity being saved.
+ *
+ * @return bool
+ * TRUE if the default revision is published. FALSE otherwise.
+ */
+ protected function isDefaultRevisionPublished(EntityInterface $entity) {
+ $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
+ $default_revision = $storage->load($entity->id());
+
+ // Ensure we are comparing the same translation as the current entity.
+ if ($default_revision instanceof TranslatableInterface && $default_revision->isTranslatable()) {
+ // If there is no translation, then there is no default revision and is
+ // therefore not published.
+ if (!$default_revision->hasTranslation($entity->language()->getId())) {
+ return FALSE;
+ }
+
+ $default_revision = $default_revision->getTranslation($entity->language()->getId());
+ }
+
+ return $default_revision && $default_revision->moderation_state->entity->isPublishedState();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/EntityTypeInfo.php b/core/modules/content_moderation/src/EntityTypeInfo.php
new file mode 100644
index 0000000..64c7946
--- /dev/null
+++ b/core/modules/content_moderation/src/EntityTypeInfo.php
@@ -0,0 +1,361 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList;
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Core\Url;
+use Drupal\content_moderation\Entity\Handler\BlockContentModerationHandler;
+use Drupal\content_moderation\Entity\Handler\ModerationHandler;
+use Drupal\content_moderation\Entity\Handler\NodeModerationHandler;
+use Drupal\content_moderation\Form\BundleModerationConfigurationForm;
+use Drupal\content_moderation\Routing\EntityModerationRouteProvider;
+use Drupal\content_moderation\Routing\EntityTypeModerationRouteProvider;
+
+/**
+ * Service class for manipulating entity type information.
+ *
+ * This class contains primarily bridged hooks for compile-time or
+ * cache-clear-time hooks. Runtime hooks should be placed in EntityOperations.
+ */
+class EntityTypeInfo {
+
+ use StringTranslationTrait;
+
+ /**
+ * The moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInfo;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * A keyed array of custom moderation handlers for given entity types.
+ *
+ * Any entity not specified will use a common default.
+ *
+ * @var array
+ */
+ protected $moderationHandlers = [
+ 'node' => NodeModerationHandler::class,
+ 'block_content' => BlockContentModerationHandler::class,
+ ];
+
+ /**
+ * EntityTypeInfo constructor.
+ *
+ * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+ * The translation service. for form alters.
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+ * The moderation information service.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * Entity type manager.
+ */
+ public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager) {
+ $this->stringTranslation = $translation;
+ $this->moderationInfo = $moderation_information;
+ $this->entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * Adds Moderation configuration to appropriate entity types.
+ *
+ * This is an alter hook bridge.
+ *
+ * @param EntityTypeInterface[] $entity_types
+ * The master entity type list to alter.
+ *
+ * @see hook_entity_type_alter()
+ */
+ public function entityTypeAlter(array &$entity_types) {
+ foreach ($this->moderationInfo->selectRevisionableEntityTypes($entity_types) as $type_name => $type) {
+ $entity_types[$type_name] = $this->addModerationToEntityType($type);
+ $entity_types[$type->get('bundle_of')] = $this->addModerationToEntity($entity_types[$type->get('bundle_of')]);
+ }
+ }
+
+ /**
+ * Modifies an entity definition to include moderation support.
+ *
+ * This primarily just means an extra handler. A Generic one is provided,
+ * but individual entity types can provide their own as appropriate.
+ *
+ * @param \Drupal\Core\Entity\ContentEntityTypeInterface $type
+ * The content entity definition to modify.
+ *
+ * @return \Drupal\Core\Entity\ContentEntityTypeInterface
+ * The modified content entity definition.
+ */
+ protected function addModerationToEntity(ContentEntityTypeInterface $type) {
+ if (!$type->hasHandlerClass('moderation')) {
+ $handler_class = !empty($this->moderationHandlers[$type->id()]) ? $this->moderationHandlers[$type->id()] : ModerationHandler::class;
+ $type->setHandlerClass('moderation', $handler_class);
+ }
+
+ if (!$type->hasLinkTemplate('latest-version') && $type->hasLinkTemplate('canonical')) {
+ $type->setLinkTemplate('latest-version', $type->getLinkTemplate('canonical') . '/latest');
+ }
+
+ // @todo Core forgot to add a direct way to manipulate route_provider, so
+ // we have to do it the sloppy way for now.
+ $providers = $type->getRouteProviderClasses() ?: [];
+ if (empty($providers['moderation'])) {
+ $providers['moderation'] = EntityModerationRouteProvider::class;
+ $type->setHandlerClass('route_provider', $providers);
+ }
+
+ return $type;
+ }
+
+ /**
+ * Configures moderation configuration support on a entity type definition.
+ *
+ * That "configuration support" includes a configuration form, a hypermedia
+ * link, and a route provider to tie it all together. There's also a
+ * moderation handler for per-entity-type variation.
+ *
+ * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $type
+ * The config entity definition to modify.
+ *
+ * @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface
+ * The modified config entity definition.
+ */
+ protected function addModerationToEntityType(ConfigEntityTypeInterface $type) {
+ if ($type->hasLinkTemplate('edit-form') && !$type->hasLinkTemplate('moderation-form')) {
+ $type->setLinkTemplate('moderation-form', $type->getLinkTemplate('edit-form') . '/moderation');
+ }
+
+ if (!$type->getFormClass('moderation')) {
+ $type->setFormClass('moderation', BundleModerationConfigurationForm::class);
+ }
+
+ // @todo Core forgot to add a direct way to manipulate route_provider, so
+ // we have to do it the sloppy way for now.
+ $providers = $type->getRouteProviderClasses() ?: [];
+ if (empty($providers['moderation'])) {
+ $providers['moderation'] = EntityTypeModerationRouteProvider::class;
+ $type->setHandlerClass('route_provider', $providers);
+ }
+
+ return $type;
+ }
+
+ /**
+ * Adds an operation on bundles that should have a Moderation form.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity on which to define an operation.
+ *
+ * @return array
+ * An array of operation definitions.
+ *
+ * @see hook_entity_operation()
+ */
+ public function entityOperation(EntityInterface $entity) {
+ $operations = [];
+ $type = $entity->getEntityType();
+
+ if ($this->moderationInfo->isBundleForModeratableEntity($entity)) {
+ $operations['manage-moderation'] = [
+ 'title' => t('Manage moderation'),
+ 'weight' => 27,
+ 'url' => Url::fromRoute("entity.{$type->id()}.moderation", [$entity->getEntityTypeId() => $entity->id()]),
+ ];
+ }
+
+ return $operations;
+ }
+
+ /**
+ * Gets the "extra fields" for a bundle.
+ *
+ * This is a hook bridge.
+ *
+ * @see hook_entity_extra_field_info()
+ *
+ * @return array
+ * A nested array of 'pseudo-field' elements. Each list is nested within the
+ * following keys: entity type, bundle name, context (either 'form' or
+ * 'display'). The keys are the name of the elements as appearing in the
+ * renderable array (either the entity form or the displayed entity). The
+ * value is an associative array:
+ * - label: The human readable name of the element. Make sure you sanitize
+ * this appropriately.
+ * - description: A short description of the element contents.
+ * - weight: The default weight of the element.
+ * - visible: (optional) The default visibility of the element. Defaults to
+ * TRUE.
+ * - edit: (optional) String containing markup (normally a link) used as the
+ * element's 'edit' operation in the administration interface. Only for
+ * 'form' context.
+ * - delete: (optional) String containing markup (normally a link) used as
+ * the element's 'delete' operation in the administration interface. Only
+ * for 'form' context.
+ */
+ public function entityExtraFieldInfo() {
+ $return = [];
+ foreach ($this->getModeratedBundles() as $bundle) {
+ $return[$bundle['entity']][$bundle['bundle']]['display']['content_moderation_control'] = [
+ 'label' => $this->t('Moderation control'),
+ 'description' => $this->t("Status listing and form for the entity's moderation state."),
+ 'weight' => -20,
+ 'visible' => TRUE,
+ ];
+ }
+
+ return $return;
+ }
+
+ /**
+ * Returns an iterable list of entity names and bundle names under moderation.
+ *
+ * That is, this method returns a list of bundles that have Content
+ * Moderation enabled on them.
+ *
+ * @return \Generator
+ * A generator, yielding a 2 element associative array:
+ * - entity: The machine name of an entity type, such as "node" or
+ * "block_content".
+ * - bundle: The machine name of a bundle, such as "page" or "article".
+ */
+ protected function getModeratedBundles() {
+ $revisionable_types = $this->moderationInfo->selectRevisionableEntityTypes($this->entityTypeManager->getDefinitions());
+ /** @var ConfigEntityTypeInterface $type */
+ foreach ($revisionable_types as $type_name => $type) {
+ $result = $this->entityTypeManager
+ ->getStorage($type_name)
+ ->getQuery()
+ ->condition('third_party_settings.content_moderation.enabled', TRUE)
+ ->execute();
+
+ foreach ($result as $bundle_name) {
+ yield ['entity' => $type->getBundleOf(), 'bundle' => $bundle_name];
+ }
+ }
+ }
+
+ /**
+ * Adds base field info to an entity type.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * Entity type for adding base fields to.
+ *
+ * @return \Drupal\Core\Field\BaseFieldDefinition[]
+ * New fields added by moderation state.
+ */
+ public function entityBaseFieldInfo(EntityTypeInterface $entity_type) {
+ if (!$this->moderationInfo->isModeratableEntityType($entity_type)) {
+ return [];
+ }
+
+ $fields = [];
+ $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference')
+ ->setLabel(t('Moderation state'))
+ ->setDescription(t('The moderation state of this piece of content.'))
+ ->setComputed(TRUE)
+ ->setClass(ModerationStateFieldItemList::class)
+ ->setSetting('target_type', 'moderation_state')
+ ->setDisplayOptions('view', [
+ 'label' => 'hidden',
+ 'type' => 'hidden',
+ 'weight' => -5,
+ ])
+ ->setDisplayOptions('form', [
+ 'type' => 'moderation_state_default',
+ 'weight' => 5,
+ 'settings' => [],
+ ])
+ ->addConstraint('ModerationState', [])
+ ->setDisplayConfigurable('form', FALSE)
+ ->setDisplayConfigurable('view', FALSE)
+ ->setTranslatable(TRUE);
+
+ return $fields;
+ }
+
+ /**
+ * Adds the ModerationState constraint to bundles that are moderatable.
+ *
+ * @param \Drupal\Core\Field\FieldDefinitionInterface[] $fields
+ * The array of bundle field definitions.
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type definition.
+ * @param string $bundle
+ * The bundle.
+ *
+ * @see hook_entity_bundle_field_info_alter();
+ */
+ public function entityBundleFieldInfoAlter(&$fields, EntityTypeInterface $entity_type, $bundle) {
+ if (!empty($fields['moderation_state']) && $this->moderationInfo->isModeratableBundle($entity_type, $bundle)) {
+ $fields['moderation_state']->addConstraint('ModerationState', []);
+ }
+ }
+
+ /**
+ * Alters bundle forms to enforce revision handling.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ * @param string $form_id
+ * The form id.
+ *
+ * @see hook_form_alter()
+ */
+ public function bundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+ if ($this->moderationInfo->isRevisionableBundleForm($form_state->getFormObject())) {
+ /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */
+ $bundle = $form_state->getFormObject()->getEntity();
+
+ $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id);
+ }
+ elseif ($this->moderationInfo->isModeratedEntityForm($form_state->getFormObject())) {
+ /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+ $entity = $form_state->getFormObject()->getEntity();
+
+ $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->enforceRevisionsEntityFormAlter($form, $form_state, $form_id);
+
+ // Submit handler to redirect to the latest version, if available.
+ $form['actions']['submit']['#submit'][] = [EntityTypeInfo::class, 'bundleFormRedirect'];
+ }
+ }
+
+ /**
+ * Redirect content entity edit forms on save, if there is a forward revision.
+ *
+ * When saving their changes, editors should see those changes displayed on
+ * the next page.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ public static function bundleFormRedirect(array &$form, FormStateInterface $form_state) {
+ /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+ $entity = $form_state->getFormObject()->getEntity();
+
+ $moderation_info = \Drupal::getContainer()->get('content_moderation.moderation_information');
+ if ($moderation_info->hasForwardRevision($entity) && $entity->hasLinkTemplate('latest-version')) {
+ $entity_type_id = $entity->getEntityTypeId();
+ $form_state->setRedirect("entity.$entity_type_id.latest_version", [$entity_type_id => $entity->id()]);
+ }
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php
new file mode 100644
index 0000000..58dd334
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php
@@ -0,0 +1,195 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\content_moderation\Entity\ModerationState;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Form for configuring moderation usage on a given entity bundle.
+ */
+class BundleModerationConfigurationForm extends EntityForm {
+
+ /**
+ * Entity Type Manager service.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+ $this->entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static($container->get('entity_type.manager'));
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Blank out the base form ID so that form alters that use the base form ID to
+ * target both add and edit forms don't pick up this form.
+ */
+ public function getBaseFormId() {
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */
+ $bundle = $form_state->getFormObject()->getEntity();
+ $form['enable_moderation_state'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Enable moderation states.'),
+ '#description' => $this->t('Content of this type must transition through moderation states in order to be published.'),
+ '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE),
+ ];
+
+ // Add a special message when moderation is being disabled.
+ if ($bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)) {
+ $form['enable_moderation_state_note'] = [
+ '#type' => 'item',
+ '#description' => $this->t('After disabling moderation, any existing forward drafts will be accessible via the "Revisions" tab.'),
+ '#states' => [
+ 'visible' => [
+ ':input[name=enable_moderation_state]' => ['checked' => FALSE],
+ ],
+ ],
+ ];
+ }
+
+ $states = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple();
+ $label = function(ModerationState $state) {
+ return $state->label();
+ };
+
+ $options_published = array_map($label, array_filter($states, function(ModerationState $state) {
+ return $state->isPublishedState();
+ }));
+
+ $options_unpublished = array_map($label, array_filter($states, function(ModerationState $state) {
+ return !$state->isPublishedState();
+ }));
+
+ $form['allowed_moderation_states_unpublished'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Allowed moderation states (Unpublished)'),
+ '#description' => $this->t('The allowed unpublished moderation states this content-type can be assigned.'),
+ '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_unpublished)),
+ '#options' => $options_unpublished,
+ '#required' => TRUE,
+ '#states' => [
+ 'visible' => [
+ ':input[name=enable_moderation_state]' => ['checked' => TRUE],
+ ],
+ ],
+ ];
+
+ $form['allowed_moderation_states_published'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Allowed moderation states (Published)'),
+ '#description' => $this->t('The allowed published moderation states this content-type can be assigned.'),
+ '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_published)),
+ '#options' => $options_published,
+ '#required' => TRUE,
+ '#states' => [
+ 'visible' => [
+ ':input[name=enable_moderation_state]' => ['checked' => TRUE],
+ ],
+ ],
+ ];
+
+ // The key of the array needs to be a user-facing string so we have to fully
+ // render the translatable string to a real string, or else PHP errors on an
+ // object used as an array key.
+ $options = [
+ $this->t('Unpublished')->render() => $options_unpublished,
+ $this->t('Published')->render() => $options_published,
+ ];
+
+ $form['default_moderation_state'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Default moderation state'),
+ '#options' => $options,
+ '#description' => $this->t('Select the moderation state for new content'),
+ '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'),
+ '#states' => [
+ 'visible' => [
+ ':input[name=enable_moderation_state]' => ['checked' => TRUE],
+ ],
+ ],
+ ];
+ $form['#entity_builders'][] = [$this, 'formBuilderCallback'];
+
+ return parent::form($form, $form_state);
+ }
+
+ /**
+ * Form builder callback.
+ *
+ * @todo This should be folded into the form method.
+ *
+ * @param string $entity_type_id
+ * The entity type identifier.
+ * @param \Drupal\Core\Entity\EntityInterface $bundle
+ * The bundle entity updated with the submitted values.
+ * @param array $form
+ * The complete form array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ public function formBuilderCallback($entity_type_id, EntityInterface $bundle, &$form, FormStateInterface $form_state) {
+ // @todo https://www.drupal.org/node/2779933 write a test for this.
+ if ($bundle instanceof ThirdPartySettingsInterface) {
+ $bundle->setThirdPartySetting('content_moderation', 'enabled', $form_state->getValue('enable_moderation_state'));
+ $bundle->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished'))));
+ $bundle->setThirdPartySetting('content_moderation', 'default_moderation_state', $form_state->getValue('default_moderation_state'));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ if ($form_state->getValue('enable_moderation_state')) {
+ $allowed = array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished')));
+
+ if (($default = $form_state->getValue('default_moderation_state')) && !in_array($default, $allowed, TRUE)) {
+ $form_state->setErrorByName('default_moderation_state', $this->t('The default moderation state must be one of the allowed states.'));
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ // If moderation is enabled, revisions MUST be enabled as well. Otherwise we
+ // can't have forward revisions.
+ if ($form_state->getValue('enable_moderation_state')) {
+ /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */
+ $bundle = $form_state->getFormObject()->getEntity();
+
+ $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->onBundleModerationConfigurationFormSubmit($bundle);
+ }
+
+ parent::submitForm($form, $form_state);
+
+ drupal_set_message($this->t('Your settings have been saved.'));
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Form/EntityModerationForm.php b/core/modules/content_moderation/src/Form/EntityModerationForm.php
new file mode 100644
index 0000000..39baec0
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/EntityModerationForm.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Drupal\content_moderation\StateTransitionValidation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * The EntityModerationForm provides a simple UI for changing moderation state.
+ */
+class EntityModerationForm extends FormBase {
+
+ /**
+ * The moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInfo;
+
+ /**
+ * The moderation state transition validation service.
+ *
+ * @var \Drupal\content_moderation\StateTransitionValidation
+ */
+ protected $validation;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * EntityModerationForm constructor.
+ *
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+ * The moderation information service.
+ * @param \Drupal\content_moderation\StateTransitionValidation $validation
+ * The moderation state transition validation service.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ */
+ public function __construct(ModerationInformationInterface $moderation_info, StateTransitionValidation $validation, EntityTypeManagerInterface $entity_type_manager) {
+ $this->moderationInfo = $moderation_info;
+ $this->validation = $validation;
+ $this->entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('content_moderation.moderation_information'),
+ $container->get('content_moderation.state_transition_validation'),
+ $container->get('entity_type.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'content_moderation_entity_moderation_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state, ContentEntityInterface $entity = NULL) {
+ /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
+ $current_state = $entity->moderation_state->entity;
+
+ $transitions = $this->validation->getValidTransitions($entity, $this->currentUser());
+
+ // Exclude self-transitions.
+ $transitions = array_filter($transitions, function(ModerationStateTransition $transition) use ($current_state) {
+ return $transition->getToState() != $current_state->id();
+ });
+
+ $target_states = [];
+ /** @var ModerationStateTransition $transition */
+ foreach ($transitions as $transition) {
+ $target_states[$transition->getToState()] = $transition->label();
+ }
+
+ if (!count($target_states)) {
+ return $form;
+ }
+
+ if ($current_state) {
+ $form['current'] = [
+ '#type' => 'item',
+ '#title' => $this->t('Status'),
+ '#markup' => $current_state->label(),
+ ];
+ }
+
+ // Persist the entity so we can access it in the submit handler.
+ $form_state->set('entity', $entity);
+
+ $form['new_state'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Moderate'),
+ '#options' => $target_states,
+ ];
+
+ $form['revision_log'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Log message'),
+ '#size' => 30,
+ ];
+
+ $form['submit'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Apply'),
+ ];
+
+ $form['#theme'] = ['entity_moderation_form'];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ /** @var ContentEntityInterface $entity */
+ $entity = $form_state->get('entity');
+
+ $new_state = $form_state->getValue('new_state');
+
+ // @todo should we just just be updating the content moderation state
+ // entity? That would prevent setting the revision log.
+ $entity->moderation_state->target_id = $new_state;
+ $entity->revision_log = $form_state->getValue('revision_log');
+
+ $entity->save();
+
+ drupal_set_message($this->t('The moderation state has been updated.'));
+
+ /** @var \Drupal\content_moderation\Entity\ModerationState $state */
+ $state = $this->entityTypeManager->getStorage('moderation_state')->load($new_state);
+
+ // The page we're on likely won't be visible if we just set the entity to
+ // the default state, as we hide that latest-revision tab if there is no
+ // forward revision. Redirect to the canonical URL instead, since that will
+ // still exist.
+ if ($state->isDefaultRevisionState()) {
+ $form_state->setRedirectUrl($entity->toUrl('canonical'));
+ }
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php
new file mode 100644
index 0000000..43e2b36
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Builds the form to delete Moderation state entities.
+ */
+class ModerationStateDeleteForm extends EntityConfirmFormBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getQuestion() {
+ return $this->t('Are you sure you want to delete %name?', array('%name' => $this->entity->label()));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCancelUrl() {
+ return new Url('entity.moderation_state.collection');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfirmText() {
+ return $this->t('Delete');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $this->entity->delete();
+
+ drupal_set_message($this->t(
+ 'Moderation state %label deleted.',
+ ['%label' => $this->entity->label()]
+ ));
+
+ $form_state->setRedirectUrl($this->getCancelUrl());
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Form/ModerationStateForm.php b/core/modules/content_moderation/src/Form/ModerationStateForm.php
new file mode 100644
index 0000000..32d7a48
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateForm.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Class ModerationStateForm.
+ */
+class ModerationStateForm extends EntityForm {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ $form = parent::form($form, $form_state);
+
+ /* @var \Drupal\content_moderation\ModerationStateInterface $moderation_state */
+ $moderation_state = $this->entity;
+ $form['label'] = array(
+ '#type' => 'textfield',
+ '#title' => $this->t('Label'),
+ '#maxlength' => 255,
+ '#default_value' => $moderation_state->label(),
+ '#description' => $this->t('Label for the Moderation state.'),
+ '#required' => TRUE,
+ );
+
+ $form['id'] = array(
+ '#type' => 'machine_name',
+ '#default_value' => $moderation_state->id(),
+ '#machine_name' => array(
+ 'exists' => [ModerationState::class, 'load'],
+ ),
+ '#disabled' => !$moderation_state->isNew(),
+ );
+
+ $form['published'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Published'),
+ '#description' => $this->t('When content reaches this state it should be published.'),
+ '#default_value' => $moderation_state->isPublishedState(),
+ ];
+
+ $form['default_revision'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Default revision'),
+ '#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'),
+ '#default_value' => $moderation_state->isDefaultRevisionState(),
+ // @todo Add form #state to force "make default" on when "published" is
+ // on for a state.
+ // @see https://www.drupal.org/node/2645614
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function save(array $form, FormStateInterface $form_state) {
+ $moderation_state = $this->entity;
+ $status = $moderation_state->save();
+
+ switch ($status) {
+ case SAVED_NEW:
+ drupal_set_message($this->t('Created the %label Moderation state.', [
+ '%label' => $moderation_state->label(),
+ ]));
+ break;
+
+ default:
+ drupal_set_message($this->t('Saved the %label Moderation state.', [
+ '%label' => $moderation_state->label(),
+ ]));
+ }
+ $form_state->setRedirectUrl($moderation_state->toUrl('collection'));
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php
new file mode 100644
index 0000000..f153f1f
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Builds the form to delete Moderation state transition entities.
+ */
+class ModerationStateTransitionDeleteForm extends EntityConfirmFormBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getQuestion() {
+ return $this->t('Are you sure you want to delete %name?', array('%name' => $this->entity->label()));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCancelUrl() {
+ return new Url('entity.moderation_state_transition.collection');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfirmText() {
+ return $this->t('Delete');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $this->entity->delete();
+
+ drupal_set_message($this->t(
+ 'Moderation transition %label deleted.',
+ ['%label' => $this->entity->label()]
+ ));
+
+ $form_state->setRedirectUrl($this->getCancelUrl());
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php
new file mode 100644
index 0000000..8322c18
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryFactory;
+use Drupal\Core\Form\FormStateInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Class ModerationStateTransitionForm.
+ *
+ * @package Drupal\content_moderation\Form
+ */
+class ModerationStateTransitionForm extends EntityForm {
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The entity query factory.
+ *
+ * @var \Drupal\Core\Entity\Query\QueryFactory
+ */
+ protected $queryFactory;
+
+ /**
+ * Constructs a new ModerationStateTransitionForm.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
+ * The entity query factory.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, QueryFactory $query_factory) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->queryFactory = $query_factory;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static($container->get('entity_type.manager'), $container->get('entity.query'));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ $form = parent::form($form, $form_state);
+
+ /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $moderation_state_transition */
+ $moderation_state_transition = $this->entity;
+ $form['label'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Label'),
+ '#maxlength' => 255,
+ '#default_value' => $moderation_state_transition->label(),
+ '#description' => $this->t('Label for the Moderation state transition.'),
+ '#required' => TRUE,
+ ];
+
+ $form['id'] = [
+ '#type' => 'machine_name',
+ '#default_value' => $moderation_state_transition->id(),
+ '#machine_name' => [
+ 'exists' => '\Drupal\content_moderation\Entity\ModerationStateTransition::load',
+ ],
+ '#disabled' => !$moderation_state_transition->isNew(),
+ ];
+
+ $options = [];
+ foreach ($this->entityTypeManager->getStorage('moderation_state')
+ ->loadMultiple() as $moderation_state) {
+ $options[$moderation_state->id()] = $moderation_state->label();
+ }
+
+ $form['container'] = [
+ '#type' => 'container',
+ '#attributes' => [
+ 'class' => ['container-inline'],
+ ],
+ ];
+
+ $form['container']['stateFrom'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Transition from'),
+ '#options' => $options,
+ '#required' => TRUE,
+ '#empty_option' => $this->t('-- Select --'),
+ '#default_value' => $moderation_state_transition->getFromState(),
+ ];
+
+ $form['container']['stateTo'] = [
+ '#type' => 'select',
+ '#options' => $options,
+ '#required' => TRUE,
+ '#title' => $this->t('Transition to'),
+ '#empty_option' => $this->t('-- Select --'),
+ '#default_value' => $moderation_state_transition->getToState(),
+ ];
+
+ // Make sure there's always at least a wide enough delta on weight to cover
+ // the current value or the total number of transitions. That way we
+ // never end up forcing a transition to change its weight needlessly.
+ $num_transitions = $this->queryFactory->get('moderation_state_transition')
+ ->count()
+ ->execute();
+ $delta = max(abs($moderation_state_transition->getWeight()), $num_transitions);
+
+ $form['weight'] = [
+ '#type' => 'weight',
+ '#delta' => $delta,
+ '#options' => $options,
+ '#title' => $this->t('Weight'),
+ '#default_value' => $moderation_state_transition->getWeight(),
+ '#description' => $this->t('Orders the transitions in moderation forms and the administrative listing. Heavier items will sink and the lighter items will be positioned nearer the top.'),
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function save(array $form, FormStateInterface $form_state) {
+ $moderation_state_transition = $this->entity;
+ $status = $moderation_state_transition->save();
+
+ switch ($status) {
+ case SAVED_NEW:
+ drupal_set_message($this->t('Created the %label Moderation state transition.', [
+ '%label' => $moderation_state_transition->label(),
+ ]));
+ break;
+
+ default:
+ drupal_set_message($this->t('Saved the %label Moderation state transition.', [
+ '%label' => $moderation_state_transition->label(),
+ ]));
+ }
+ $form_state->setRedirectUrl($moderation_state_transition->toUrl('collection'));
+ }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php
new file mode 100644
index 0000000..cf79bc1
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationInformation.php
@@ -0,0 +1,208 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\BundleEntityFormBase;
+use Drupal\Core\Entity\ContentEntityFormInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * General service for moderation-related questions about Entity API.
+ */
+class ModerationInformation implements ModerationInformationInterface {
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The current user.
+ *
+ * @var \Drupal\Core\Session\AccountInterface
+ */
+ protected $currentUser;
+
+ /**
+ * Creates a new ModerationInformation instance.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\Session\AccountInterface $current_user
+ * The current user.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->currentUser = $current_user;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isModeratableEntity(EntityInterface $entity) {
+ if (!$entity instanceof ContentEntityInterface) {
+ return FALSE;
+ }
+
+ return $this->isModeratableBundle($entity->getEntityType(), $entity->bundle());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isModeratableEntityType(EntityTypeInterface $entity_type) {
+ return $entity_type->hasHandlerClass('moderation');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function loadBundleEntity($bundle_entity_type_id, $bundle_id) {
+ if ($bundle_entity_type_id) {
+ return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isModeratableBundle(EntityTypeInterface $entity_type, $bundle) {
+ if ($bundle_entity = $this->loadBundleEntity($entity_type->getBundleEntityType(), $bundle)) {
+ return $bundle_entity->getThirdPartySetting('content_moderation', 'enabled', FALSE);
+ }
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function selectRevisionableEntityTypes(array $entity_types) {
+ return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) {
+ return ($type instanceof ConfigEntityTypeInterface)
+ && ($bundle_of = $type->get('bundle_of'))
+ && $entity_types[$bundle_of]->isRevisionable();
+ });
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function selectRevisionableEntities(array $entity_types) {
+ return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) {
+ return ($type instanceof ContentEntityTypeInterface)
+ && $type->isRevisionable()
+ && $type->getBundleEntityType();
+ });
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isBundleForModeratableEntity(EntityInterface $entity) {
+ $type = $entity->getEntityType();
+
+ return
+ $type instanceof ConfigEntityTypeInterface
+ && ($bundle_of = $type->get('bundle_of'))
+ && $this->entityTypeManager->getDefinition($bundle_of)->isRevisionable()
+ && $this->currentUser->hasPermission('administer moderation states');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isModeratedEntityForm(FormInterface $form_object) {
+ return $form_object instanceof ContentEntityFormInterface
+ && $this->isModeratableEntity($form_object->getEntity());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isRevisionableBundleForm(FormInterface $form_object) {
+ if ($form_object instanceof BundleEntityFormBase) {
+ $bundle_of = $form_object->getEntity()->getEntityType()->getBundleOf();
+ $type = $this->entityTypeManager->getDefinition($bundle_of);
+ return $type->isRevisionable();
+ }
+
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getLatestRevision($entity_type_id, $entity_id) {
+ if ($latest_revision_id = $this->getLatestRevisionId($entity_type_id, $entity_id)) {
+ return $this->entityTypeManager->getStorage($entity_type_id)->loadRevision($latest_revision_id);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getLatestRevisionId($entity_type_id, $entity_id) {
+ if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) {
+ $revision_ids = $storage->getQuery()
+ ->allRevisions()
+ ->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id)
+ ->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC')
+ ->range(0, 1)
+ ->execute();
+ if ($revision_ids) {
+ return array_keys($revision_ids)[0];
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDefaultRevisionId($entity_type_id, $entity_id) {
+ if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) {
+ $revision_ids = $storage->getQuery()
+ ->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id)
+ ->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC')
+ ->range(0, 1)
+ ->execute();
+ if ($revision_ids) {
+ return array_keys($revision_ids)[0];
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isLatestRevision(ContentEntityInterface $entity) {
+ return $entity->getRevisionId() == $this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function hasForwardRevision(ContentEntityInterface $entity) {
+ return $this->isModeratableEntity($entity)
+ && !($this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id()) == $this->getDefaultRevisionId($entity->getEntityTypeId(), $entity->id()));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isLiveRevision(ContentEntityInterface $entity) {
+ return $this->isLatestRevision($entity)
+ && $entity->isDefaultRevision()
+ && $entity->moderation_state->entity
+ && $entity->moderation_state->entity->isPublishedState();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationInformationInterface.php b/core/modules/content_moderation/src/ModerationInformationInterface.php
new file mode 100644
index 0000000..b203792
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationInformationInterface.php
@@ -0,0 +1,212 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Form\FormInterface;
+
+/**
+ * Interface for moderation_information service.
+ */
+interface ModerationInformationInterface {
+
+ /**
+ * Loads a specific bundle entity.
+ *
+ * @param string $bundle_entity_type_id
+ * The bundle entity type ID.
+ * @param string $bundle_id
+ * The bundle ID.
+ *
+ * @return \Drupal\Core\Config\Entity\ConfigEntityInterface|null
+ * The bundle entity.
+ */
+ public function loadBundleEntity($bundle_entity_type_id, $bundle_id);
+
+ /**
+ * Determines if an entity is one we should be moderating.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity we may be moderating.
+ *
+ * @return bool
+ * TRUE if this is an entity that we should act upon, FALSE otherwise.
+ */
+ public function isModeratableEntity(EntityInterface $entity);
+
+ /**
+ * Determines if an entity type has been marked as moderatable.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * An entity type object.
+ *
+ * @return bool
+ * TRUE if this entity type has been marked as moderatable, FALSE otherwise.
+ */
+ public function isModeratableEntityType(EntityTypeInterface $entity_type);
+
+ /**
+ * Determines if an entity type/bundle is one that will be moderated.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type definition to check.
+ * @param string $bundle
+ * The bundle to check.
+ *
+ * @return bool
+ * TRUE if this is a bundle we want to moderate, FALSE otherwise.
+ */
+ public function isModeratableBundle(EntityTypeInterface $entity_type, $bundle);
+
+ /**
+ * Filters entity lists to just bundle definitions for revisionable entities.
+ *
+ * @param EntityTypeInterface[] $entity_types
+ * The master entity type list filter.
+ *
+ * @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface[]
+ * An array of only the config entities we want to modify.
+ */
+ public function selectRevisionableEntityTypes(array $entity_types);
+
+ /**
+ * Filters entity lists to just the definitions for moderatable entities.
+ *
+ * An entity type is moderatable only if it is both revisionable and
+ * bundleable.
+ *
+ * @param EntityTypeInterface[] $entity_types
+ * The master entity type list filter.
+ *
+ * @return \Drupal\Core\Entity\ContentEntityTypeInterface[]
+ * An array of only the content entity definitions we want to modify.
+ */
+ public function selectRevisionableEntities(array $entity_types);
+
+ /**
+ * Determines if config entity is a bundle for entities that may be moderated.
+ *
+ * This is the same check as exists in selectRevisionableEntityTypes(), but
+ * that one cannot use the entity manager due to recursion and this one
+ * doesn't have the entity list otherwise so must use the entity manager. The
+ * alternative would be to call getDefinitions() on entityTypeManager and use
+ * that in a sub-call, but that would be unnecessarily memory intensive.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity to check.
+ *
+ * @return bool
+ * TRUE if we want to add a Moderation operation to this entity, FALSE
+ * otherwise.
+ */
+ public function isBundleForModeratableEntity(EntityInterface $entity);
+
+ /**
+ * Determines if this form is for a moderated entity.
+ *
+ * @param \Drupal\Core\Form\FormInterface $form_object
+ * The form definition object for this form.
+ *
+ * @return bool
+ * TRUE if the form is for an entity that is subject to moderation, FALSE
+ * otherwise.
+ */
+ public function isModeratedEntityForm(FormInterface $form_object);
+
+ /**
+ * Determines if the form is the bundle edit of a revisionable entity.
+ *
+ * The logic here is not entirely clear, but seems to work. The form- and
+ * entity-dereference chaining seems excessive but is what works.
+ *
+ * @param \Drupal\Core\Form\FormInterface $form_object
+ * The form definition object for this form.
+ *
+ * @return bool
+ * True if the form is the bundle edit form for an entity type that supports
+ * revisions, false otherwise.
+ */
+ public function isRevisionableBundleForm(FormInterface $form_object);
+
+ /**
+ * Loads the latest revision of a specific entity.
+ *
+ * @param string $entity_type_id
+ * The entity type ID.
+ * @param int $entity_id
+ * The entity ID.
+ *
+ * @return \Drupal\Core\Entity\ContentEntityInterface|null
+ * The latest entity revision or NULL, if the entity type / entity doesn't
+ * exist.
+ */
+ public function getLatestRevision($entity_type_id, $entity_id);
+
+ /**
+ * Returns the revision ID of the latest revision of the given entity.
+ *
+ * @param string $entity_type_id
+ * The entity type ID.
+ * @param int $entity_id
+ * The entity ID.
+ *
+ * @return int
+ * The revision ID of the latest revision for the specified entity, or
+ * NULL if there is no such entity.
+ */
+ public function getLatestRevisionId($entity_type_id, $entity_id);
+
+ /**
+ * Returns the revision ID of the default revision for the specified entity.
+ *
+ * @param string $entity_type_id
+ * The entity type ID.
+ * @param int $entity_id
+ * The entity ID.
+ *
+ * @return int
+ * The revision ID of the default revision, or NULL if the entity was
+ * not found.
+ */
+ public function getDefaultRevisionId($entity_type_id, $entity_id);
+
+ /**
+ * Determines if an entity is a latest revision.
+ *
+ * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+ * A revisionable content entity.
+ *
+ * @return bool
+ * TRUE if the specified object is the latest revision of its entity,
+ * FALSE otherwise.
+ */
+ public function isLatestRevision(ContentEntityInterface $entity);
+
+ /**
+ * Determines if a forward revision exists for the specified entity.
+ *
+ * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+ * The entity which may or may not have a forward revision.
+ *
+ * @return bool
+ * TRUE if this entity has forward revisions available, FALSE otherwise.
+ */
+ public function hasForwardRevision(ContentEntityInterface $entity);
+
+ /**
+ * Determines if an entity is "live".
+ *
+ * A "live" entity revision is one whose latest revision is also the default,
+ * and whose moderation state, if any, is a published state.
+ *
+ * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+ * The entity to check.
+ *
+ * @return bool
+ * TRUE if the specified entity is a live revision, FALSE otherwise.
+ */
+ public function isLiveRevision(ContentEntityInterface $entity);
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php b/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php
new file mode 100644
index 0000000..cc2c977
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\EntityAccessControlHandler;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Access\AccessResult;
+
+/**
+ * Access controller for the Moderation State entity.
+ *
+ * @see \Drupal\workbench_moderation\Entity\ModerationState.
+ */
+class ModerationStateAccessControlHandler extends EntityAccessControlHandler {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+ $admin_access = AccessResult::allowedIfHasPermission($account, 'administer moderation states');
+
+ // Allow view with other permission.
+ if ($operation === 'view') {
+ return AccessResult::allowedIfHasPermission($account, 'view moderation states')->orIf($admin_access);
+ }
+
+ return $admin_access;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
+ return AccessResult::allowedIfHasPermission($account, 'administer moderation states');
+ }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateInterface.php b/core/modules/content_moderation/src/ModerationStateInterface.php
new file mode 100644
index 0000000..99f664f
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+
+/**
+ * Provides an interface for defining Moderation state entities.
+ */
+interface ModerationStateInterface extends ConfigEntityInterface {
+
+ /**
+ * Determines if content updated to this state should be published.
+ *
+ * @return bool
+ * TRUE if content updated to this state should be published.
+ */
+ public function isPublishedState();
+
+ /**
+ * Determines if content updated to this state should be the default revision.
+ *
+ * @return bool
+ * TRUE if content in this state should be the default revision.
+ */
+ public function isDefaultRevisionState();
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateListBuilder.php b/core/modules/content_moderation/src/ModerationStateListBuilder.php
new file mode 100644
index 0000000..05ba513
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateListBuilder.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\DraggableListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Provides a listing of Moderation state entities.
+ */
+class ModerationStateListBuilder extends DraggableListBuilder {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'moderation_state_admin_overview_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildHeader() {
+ $header['label'] = $this->t('Moderation state');
+ $header['id'] = $this->t('Machine name');
+
+ return $header + parent::buildHeader();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildRow(EntityInterface $entity) {
+ $row['label'] = $entity->label();
+ $row['id']['#markup'] = $entity->id();
+
+ return $row + parent::buildRow($entity);
+ }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateTransitionInterface.php b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php
new file mode 100644
index 0000000..91b5b13
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+
+/**
+ * Provides an interface for defining Moderation state transition entities.
+ */
+interface ModerationStateTransitionInterface extends ConfigEntityInterface {
+
+ /**
+ * Gets the from state for the given transition.
+ *
+ * @return string
+ * The moderation state ID for the from state.
+ */
+ public function getFromState();
+
+ /**
+ * Gets the to state for the given transition.
+ *
+ * @return string
+ * The moderation state ID for the to state.
+ */
+ public function getToState();
+
+ /**
+ * Gets the weight for the given transition.
+ *
+ * @return int
+ * The weight of this transition.
+ */
+ public function getWeight();
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php b/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php
new file mode 100644
index 0000000..577283e
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\DraggableListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\user\RoleStorageInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a listing of Moderation state transition entities.
+ */
+class ModerationStateTransitionListBuilder extends DraggableListBuilder {
+
+ /**
+ * Moderation state entity storage.
+ *
+ * @var \Drupal\Core\Entity\EntityStorageInterface
+ */
+ protected $stateStorage;
+
+ /**
+ * The role storage.
+ *
+ * @var \Drupal\user\RoleStorageInterface
+ */
+ protected $roleStorage;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+ return new static(
+ $entity_type,
+ $container->get('entity.manager')->getStorage($entity_type->id()),
+ $container->get('entity.manager')->getStorage('moderation_state'),
+ $container->get('entity.manager')->getStorage('user_role')
+ );
+ }
+
+ /**
+ * Constructs a new ModerationStateTransitionListBuilder.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * Entity Type.
+ * @param \Drupal\Core\Entity\EntityStorageInterface $transition_storage
+ * Moderation state transition entity storage.
+ * @param \Drupal\Core\Entity\EntityStorageInterface $state_storage
+ * Moderation state entity storage.
+ * @param \Drupal\user\RoleStorageInterface $role_storage
+ * The role storage.
+ */
+ public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $transition_storage, EntityStorageInterface $state_storage, RoleStorageInterface $role_storage) {
+ parent::__construct($entity_type, $transition_storage);
+ $this->stateStorage = $state_storage;
+ $this->roleStorage = $role_storage;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'content_moderation_transition_list';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildHeader() {
+ $header['to'] = $this->t('To state');
+ $header['label'] = $this->t('Button label');
+ $header['roles'] = $this->t('Allowed roles');
+
+ return $header + parent::buildHeader();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildRow(EntityInterface $entity) {
+ $row['to']['#markup'] = $this->stateStorage->load($entity->getToState())->label();
+ $row['label'] = $entity->label();
+ $row['roles']['#markup'] = implode(', ', user_role_names(FALSE, 'use ' . $entity->id() . ' transition'));
+
+ return $row + parent::buildRow($entity);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function render() {
+ $build = parent::render();
+
+ $build['item'] = [
+ '#type' => 'item',
+ '#markup' => $this->t('On this screen you can define <em>transitions</em>. Every time an entity is saved, it undergoes a transition. It is not possible to save an entity if it tries do a transition not defined here. Transitions do not necessarily mean a state change, it is possible to transition from a state to the same state but that transition needs to be defined here as well.'),
+ '#weight' => -5,
+ ];
+
+ return $build;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $this->entities = $this->load();
+
+ // Get all the moderation states and sort them by weight.
+ $states = $this->stateStorage->loadMultiple();
+ uasort($states, array($this->entityType->getClass(), 'sort'));
+
+ /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */
+ $groups = array_fill_keys(array_keys($states), []);
+ foreach ($this->entities as $entity) {
+ $groups[$entity->getFromState()][] = $entity;
+ }
+
+ foreach ($groups as $group_name => $entities) {
+ $form[$group_name] = [
+ '#type' => 'details',
+ '#title' => $this->t('From @state to...', ['@state' => $states[$group_name]->label()]),
+ // Make sure that the first group is always open.
+ '#open' => $group_name === array_keys($groups)[0],
+ ];
+
+ $form[$group_name][$this->entitiesKey] = array(
+ '#type' => 'table',
+ '#header' => $this->buildHeader(),
+ '#empty' => t('There is no @label yet.', array('@label' => $this->entityType->getLabel())),
+ '#tabledrag' => array(
+ array(
+ 'action' => 'order',
+ 'relationship' => 'sibling',
+ 'group' => 'weight',
+ ),
+ ),
+ );
+
+ $delta = 10;
+ // Change the delta of the weight field if have more than 20 entities.
+ if (!empty($this->weightKey)) {
+ $count = count($this->entities);
+ if ($count > 20) {
+ $delta = ceil($count / 2);
+ }
+ }
+ foreach ($entities as $entity) {
+ $row = $this->buildRow($entity);
+ if (isset($row['label'])) {
+ $row['label'] = array('#markup' => $row['label']);
+ }
+ if (isset($row['weight'])) {
+ $row['weight']['#delta'] = $delta;
+ }
+ $form[$group_name][$this->entitiesKey][$entity->id()] = $row;
+ }
+ }
+
+ $form['actions']['#type'] = 'actions';
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save order'),
+ '#button_type' => 'primary',
+ );
+
+ return $form;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php
new file mode 100644
index 0000000..263183b
--- /dev/null
+++ b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\content_moderation\ParamConverter;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\ParamConverter\EntityConverter;
+use Drupal\Core\TypedData\TranslatableInterface;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Defines a class for making sure the edit-route loads the current draft.
+ */
+class EntityRevisionConverter extends EntityConverter {
+
+ /**
+ * Moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInformation;
+
+ /**
+ * EntityRevisionConverter constructor.
+ *
+ * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+ * The entity manager, needed by the parent class.
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+ * The moderation info utility service.
+ *
+ * @todo: If the parent class is ever cleaned up to use EntityTypeManager
+ * instead of Entity manager, this method will also need to be adjusted.
+ */
+ public function __construct(EntityManagerInterface $entity_manager, ModerationInformationInterface $moderation_info) {
+ parent::__construct($entity_manager);
+ $this->moderationInformation = $moderation_info;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function applies($definition, $name, Route $route) {
+ return $this->hasForwardRevisionFlag($definition) || $this->isEditFormPage($route);
+ }
+
+ /**
+ * Determines if the route definition includes a forward-revision flag.
+ *
+ * This is a custom flag defined by the Content Moderation module to load
+ * forward revisions rather than the default revision on a given route.
+ *
+ * @param array $definition
+ * The parameter definition provided in the route options.
+ *
+ * @return bool
+ * TRUE if the forward revision flag is set, FALSE otherwise.
+ */
+ protected function hasForwardRevisionFlag(array $definition) {
+ return (isset($definition['load_forward_revision']) && $definition['load_forward_revision']);
+ }
+
+ /**
+ * Determines if a given route is the edit-form for an entity.
+ *
+ * @param \Symfony\Component\Routing\Route $route
+ * The route definition.
+ *
+ * @return bool
+ * Returns TRUE if the route is the edit form of an entity, FALSE otherwise.
+ */
+ protected function isEditFormPage(Route $route) {
+ if ($default = $route->getDefault('_entity_form')) {
+ // If no operation is provided, use 'default'.
+ $default .= '.default';
+ list($entity_type_id, $operation) = explode('.', $default);
+ if (!$this->entityManager->hasDefinition($entity_type_id)) {
+ return FALSE;
+ }
+ $entity_type = $this->entityManager->getDefinition($entity_type_id);
+ return $operation == 'edit' && $entity_type && $entity_type->isRevisionable();
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function convert($value, $definition, $name, array $defaults) {
+ $entity = parent::convert($value, $definition, $name, $defaults);
+
+ if ($entity && $this->moderationInformation->isModeratableEntity($entity) && !$this->moderationInformation->isLatestRevision($entity)) {
+ $entity_type_id = $this->getEntityTypeFromDefaults($definition, $name, $defaults);
+ $latest_revision = $this->moderationInformation->getLatestRevision($entity_type_id, $value);
+
+ // If the entity type is translatable, ensure we return the proper
+ // translation object for the current context.
+ if ($latest_revision instanceof EntityInterface && $entity instanceof TranslatableInterface) {
+ $latest_revision = $this->entityManager->getTranslationFromContext($latest_revision, NULL, array('operation' => 'entity_upcast'));
+ }
+
+ if ($latest_revision->isRevisionTranslationAffected()) {
+ $entity = $latest_revision;
+ }
+ }
+
+ return $entity;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Permissions.php b/core/modules/content_moderation/src/Permissions.php
new file mode 100644
index 0000000..027684c
--- /dev/null
+++ b/core/modules/content_moderation/src/Permissions.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+
+/**
+ * Defines a class for dynamic permissions based on transitions.
+ */
+class Permissions {
+
+ use StringTranslationTrait;
+
+ /**
+ * Returns an array of transition permissions.
+ *
+ * @return array
+ * The transition permissions.
+ */
+ public function transitionPermissions() {
+ // @todo https://www.drupal.org/node/2779933 write a test for this.
+ $perms = [];
+ /* @var \Drupal\content_moderation\ModerationStateInterface[] $states */
+ $states = ModerationState::loadMultiple();
+ /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */
+ foreach (ModerationStateTransition::loadMultiple() as $id => $transition) {
+ $perms['use ' . $id . ' transition'] = [
+ 'title' => $this->t('Use the %transition_name transition', [
+ '%transition_name' => $transition->label(),
+ ]),
+ 'description' => $this->t('Move content from %from state to %to state.', [
+ '%from' => $states[$transition->getFromState()]->label(),
+ '%to' => $states[$transition->getToState()]->label(),
+ ]),
+ ];
+ }
+
+ return $perms;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php
new file mode 100644
index 0000000..a85bac6
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Action;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\node\Plugin\Action\PublishNode;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Alternate action plugin that can opt-out of modifying moderated entities.
+ *
+ * @see \Drupal\node\Plugin\Action\PublishNode
+ */
+class ModerationOptOutPublishNode extends PublishNode implements ContainerFactoryPluginInterface {
+
+ /**
+ * Moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInfo;
+
+ /**
+ * ModerationOptOutPublishNode constructor.
+ *
+ * @param array $configuration
+ * A configuration array containing information about the plugin instance.
+ * @param string $plugin_id
+ * The plugin_id for the plugin instance.
+ * @param mixed $plugin_definition
+ * The plugin implementation definition.
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+ * The moderation information service.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $moderation_info) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+ $this->moderationInfo = $moderation_info;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration, $plugin_id, $plugin_definition,
+ $container->get('content_moderation.moderation_information')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function execute($entity = NULL) {
+ if ($entity && $this->moderationInfo->isModeratableEntity($entity)) {
+ drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.'));
+ return;
+ }
+
+ parent::execute($entity);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) {
+ $result = parent::access($object, $account, TRUE)
+ ->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratableEntity($object))->addCacheableDependency($object));
+
+ return $return_as_object ? $result : $result->isAllowed();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php
new file mode 100644
index 0000000..b0fbd87
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Action;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\node\Plugin\Action\UnpublishNode;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Alternate action plugin that can opt-out of modifying moderated entities.
+ *
+ * @see \Drupal\node\Plugin\Action\UnpublishNode
+ */
+class ModerationOptOutUnpublishNode extends UnpublishNode implements ContainerFactoryPluginInterface {
+
+ /**
+ * Moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInfo;
+
+ /**
+ * ModerationOptOutUnpublishNode constructor.
+ *
+ * @param array $configuration
+ * A configuration array containing information about the plugin instance.
+ * @param string $plugin_id
+ * The plugin_id for the plugin instance.
+ * @param mixed $plugin_definition
+ * The plugin implementation definition.
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+ * The moderation information service.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $moderation_info) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+ $this->moderationInfo = $moderation_info;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration, $plugin_id, $plugin_definition,
+ $container->get('content_moderation.moderation_information')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function execute($entity = NULL) {
+ if ($entity && $this->moderationInfo->isModeratableEntity($entity)) {
+ drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.'));
+ return;
+ }
+
+ parent::execute($entity);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) {
+ $result = parent::access($object, $account, TRUE)
+ ->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratableEntity($object))->addCacheableDependency($object));
+
+ return $return_as_object ? $result : $result->isAllowed();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php
new file mode 100644
index 0000000..39bfe0d
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Derivative;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Generates moderation-related local tasks.
+ */
+class DynamicLocalTasks extends DeriverBase implements ContainerDeriverInterface {
+
+ use StringTranslationTrait;
+
+ /**
+ * The base plugin ID.
+ *
+ * @var string
+ */
+ protected $basePluginId;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInfo;
+
+ /**
+ * Creates an FieldUiLocalTask object.
+ *
+ * @param string $base_plugin_id
+ * The base plugin ID.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+ * The translation manager.
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+ * The moderation information service.
+ */
+ public function __construct($base_plugin_id, EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation, ModerationInformationInterface $moderation_information) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->stringTranslation = $string_translation;
+ $this->basePluginId = $base_plugin_id;
+ $this->moderationInfo = $moderation_information;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, $base_plugin_id) {
+ return new static(
+ $base_plugin_id,
+ $container->get('entity_type.manager'),
+ $container->get('string_translation'),
+ $container->get('content_moderation.moderation_information')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDerivativeDefinitions($base_plugin_definition) {
+ $this->derivatives = [];
+
+ foreach ($this->moderatableEntityTypeDefinitions() as $entity_type_id => $entity_type) {
+ $this->derivatives["$entity_type_id.moderation_tab"] = [
+ 'route_name' => "entity.$entity_type_id.moderation",
+ 'title' => $this->t('Manage moderation'),
+ // @todo - are we sure they all have an edit_form?
+ 'base_route' => "entity.$entity_type_id.edit_form",
+ 'weight' => 30,
+ ] + $base_plugin_definition;
+ }
+
+ $latest_version_entities = array_filter($this->moderatableEntityDefinitions(), function (EntityTypeInterface $type) {
+ return $type->hasLinkTemplate('latest-version');
+ });
+
+ foreach ($latest_version_entities as $entity_type_id => $entity_type) {
+ $this->derivatives["$entity_type_id.latest_version_tab"] = [
+ 'route_name' => "entity.$entity_type_id.latest_version",
+ 'title' => $this->t('Latest version'),
+ 'base_route' => "entity.$entity_type_id.canonical",
+ 'weight' => 1,
+ ] + $base_plugin_definition;
+ }
+
+ return $this->derivatives;
+ }
+
+ /**
+ * Returns an array of content entities that are potentially moderatable.
+ *
+ * @return EntityTypeInterface[]
+ * An array of just those entities we care about.
+ */
+ protected function moderatableEntityDefinitions() {
+ return $this->moderationInfo->selectRevisionableEntities($this->entityTypeManager->getDefinitions());
+ }
+
+ /**
+ * Returns entity types that represent bundles that can be moderated.
+ *
+ * @return EntityTypeInterface[]
+ * An array of entity types that represent bundles that can be moderated.
+ */
+ protected function moderatableEntityTypeDefinitions() {
+ return $this->moderationInfo->selectRevisionableEntityTypes($this->entityTypeManager->getDefinitions());
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php
new file mode 100644
index 0000000..75f3d81
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Field\FieldWidget;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsSelectWidget;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\content_moderation\ModerationInformation;
+use Drupal\content_moderation\StateTransitionValidation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Plugin implementation of the 'moderation_state_default' widget.
+ *
+ * @FieldWidget(
+ * id = "moderation_state_default",
+ * label = @Translation("Moderation state"),
+ * field_types = {
+ * "entity_reference"
+ * }
+ * )
+ */
+class ModerationStateWidget extends OptionsSelectWidget implements ContainerFactoryPluginInterface {
+
+ /**
+ * Current user service.
+ *
+ * @var \Drupal\Core\Session\AccountInterface
+ */
+ protected $currentUser;
+
+ /**
+ * Moderation state transition entity query.
+ *
+ * @var \Drupal\Core\Entity\Query\QueryInterface
+ */
+ protected $moderationStateTransitionEntityQuery;
+
+ /**
+ * Moderation state storage.
+ *
+ * @var \Drupal\Core\Entity\EntityStorageInterface
+ */
+ protected $moderationStateStorage;
+
+ /**
+ * Moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformation
+ */
+ protected $moderationInformation;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * Moderation state transition storage.
+ *
+ * @var \Drupal\Core\Entity\EntityStorageInterface
+ */
+ protected $moderationStateTransitionStorage;
+
+ /**
+ * Moderation state transition validation service.
+ *
+ * @var \Drupal\content_moderation\StateTransitionValidation
+ */
+ protected $validator;
+
+ /**
+ * Constructs a new ModerationStateWidget object.
+ *
+ * @param string $plugin_id
+ * Plugin id.
+ * @param mixed $plugin_definition
+ * Plugin definition.
+ * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+ * Field definition.
+ * @param array $settings
+ * Field settings.
+ * @param array $third_party_settings
+ * Third party settings.
+ * @param \Drupal\Core\Session\AccountInterface $current_user
+ * Current user service.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * Entity type manager.
+ * @param \Drupal\Core\Entity\EntityStorageInterface $moderation_state_storage
+ * Moderation state storage.
+ * @param \Drupal\Core\Entity\EntityStorageInterface $moderation_state_transition_storage
+ * Moderation state transition storage.
+ * @param \Drupal\Core\Entity\Query\QueryInterface $entity_query
+ * Moderation transition entity query service.
+ * @param \Drupal\content_moderation\ModerationInformation $moderation_information
+ * Moderation information service.
+ * @param \Drupal\content_moderation\StateTransitionValidation $validator
+ * Moderation state transition validation service
+ */
+ public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, EntityStorageInterface $moderation_state_storage, EntityStorageInterface $moderation_state_transition_storage, QueryInterface $entity_query, ModerationInformation $moderation_information, StateTransitionValidation $validator) {
+ parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
+ $this->moderationStateTransitionEntityQuery = $entity_query;
+ $this->moderationStateTransitionStorage = $moderation_state_transition_storage;
+ $this->moderationStateStorage = $moderation_state_storage;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->currentUser = $current_user;
+ $this->moderationInformation = $moderation_information;
+ $this->validator = $validator;
+ }
+
+ /**
+ * {@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['third_party_settings'],
+ $container->get('current_user'),
+ $container->get('entity_type.manager'),
+ $container->get('entity_type.manager')->getStorage('moderation_state'),
+ $container->get('entity_type.manager')->getStorage('moderation_state_transition'),
+ $container->get('entity.query')->get('moderation_state_transition', 'AND'),
+ $container->get('content_moderation.moderation_information'),
+ $container->get('content_moderation.state_transition_validation')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
+ /** @var ContentEntityInterface $entity */
+ $entity = $items->getEntity();
+
+ /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle_entity */
+ $bundle_entity = $this->entityTypeManager->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle());
+ if (!$this->moderationInformation->isModeratableEntity($entity)) {
+ // @todo https://www.drupal.org/node/2779933 write a test for this.
+ return $element + ['#access' => FALSE];
+ }
+
+ $default = $items->get($delta)->value ?: $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state', FALSE);
+ /** @var \Drupal\content_moderation\ModerationStateInterface $default_state */
+ $default_state = $this->entityTypeManager->getStorage('moderation_state')->load($default);
+ if (!$default || !$default_state) {
+ throw new \UnexpectedValueException(sprintf('The %s bundle has an invalid moderation state configuration, moderation states are enabled but no default is set.', $bundle_entity->label()));
+ }
+
+ $transitions = $this->validator->getValidTransitions($entity, $this->currentUser);
+
+ $target_states = [];
+ /** @var \Drupal\content_moderation\Entity\ModerationStateTransition $transition */
+ foreach ($transitions as $transition) {
+ $target_states[$transition->getToState()] = $transition->label();
+ }
+
+ // @todo https://www.drupal.org/node/2779933 write a test for this.
+ $element += [
+ '#access' => FALSE,
+ '#type' => 'select',
+ '#options' => $target_states,
+ '#default_value' => $default,
+ '#published' => $default ? $default_state->isPublishedState() : FALSE,
+ '#key_column' => $this->column,
+ ];
+ $element['#element_validate'][] = array(get_class($this), 'validateElement');
+
+ // Use the dropbutton.
+ $element['#process'][] = [get_called_class(), 'processActions'];
+ return $element;
+ }
+
+ /**
+ * Entity builder updating the node moderation state with the submitted value.
+ *
+ * @param string $entity_type_id
+ * The entity type identifier.
+ * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+ * The entity updated with the submitted values.
+ * @param array $form
+ * The complete form array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ public static function updateStatus($entity_type_id, ContentEntityInterface $entity, array $form, FormStateInterface $form_state) {
+ $element = $form_state->getTriggeringElement();
+ if (isset($element['#moderation_state'])) {
+ $entity->moderation_state->target_id = $element['#moderation_state'];
+ }
+ }
+
+ /**
+ * Process callback to alter action buttons.
+ */
+ public static function processActions($element, FormStateInterface $form_state, array &$form) {
+
+ // We'll steal most of the button configuration from the default submit
+ // button. However, NodeForm also hides that button for admins (as it adds
+ // its own, too), so we have to restore it.
+ $default_button = $form['actions']['submit'];
+ $default_button['#access'] = TRUE;
+
+ // Add a custom button for each transition we're allowing. The #dropbutton
+ // property tells FAPI to cluster them all together into a single widget.
+ $options = $element['#options'];
+
+ $entity = $form_state->getFormObject()->getEntity();
+ $translatable = !$entity->isNew() && $entity->isTranslatable();
+ foreach ($options as $id => $label) {
+ $button = [
+ '#dropbutton' => 'save',
+ '#moderation_state' => $id,
+ '#weight' => -10,
+ ];
+
+ $button['#value'] = $translatable
+ ? t('Save and @transition (this translation)', ['@transition' => $label])
+ : t('Save and @transition', ['@transition' => $label]);
+
+ $form['actions']['moderation_state_' . $id] = $button + $default_button;
+ }
+
+ // Hide the default buttons, including the specialty ones added by
+ // NodeForm.
+ foreach (['publish', 'unpublish', 'submit'] as $key) {
+ $form['actions'][$key]['#access'] = FALSE;
+ unset($form['actions'][$key]['#dropbutton']);
+ }
+
+ // Setup a callback to translate the button selection back into field
+ // widget, so that it will get saved properly.
+ $form['#entity_builders']['update_moderation_state'] = [get_called_class(), 'updateStatus'];
+ return $element;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function isApplicable(FieldDefinitionInterface $field_definition) {
+ return parent::isApplicable($field_definition) && $field_definition->getName() === 'moderation_state';
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php
new file mode 100644
index 0000000..644a76b
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Field;
+
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\Core\Field\EntityReferenceFieldItemList;
+
+/**
+ * A computed field that provides a content entity's moderation state.
+ *
+ * It links content entities to a moderation state configuration entity via a
+ * moderation state content entity.
+ */
+class ModerationStateFieldItemList extends EntityReferenceFieldItemList {
+
+ /**
+ * Gets the moderation state entity linked to a content entity revision.
+ *
+ * @return \Drupal\content_moderation\ModerationStateInterface|null
+ * The moderation state configuration entity linked to a content entity
+ * revision.
+ */
+ protected function getModerationState() {
+ $entity = $this->getEntity();
+
+ if ($entity->id() && $entity->getRevisionId()) {
+ $revisions = \Drupal::service('entity.query')->get('content_moderation_state')
+ ->condition('content_entity_type_id', $entity->getEntityTypeId())
+ ->condition('content_entity_id', $entity->id())
+ ->condition('content_entity_revision_id', $entity->getRevisionId())
+ ->allRevisions()
+ ->sort('revision_id', 'DESC')
+ ->execute();
+
+ if ($revision_to_load = key($revisions)) {
+ /** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */
+ $content_moderation_state = \Drupal::entityTypeManager()
+ ->getStorage('content_moderation_state')
+ ->loadRevision($revision_to_load);
+
+ // Return the correct translation.
+ $langcode = $entity->language()->getId();
+ if (!$content_moderation_state->hasTranslation($langcode)) {
+ $content_moderation_state->addTranslation($langcode);
+ }
+ if ($content_moderation_state->language()->getId() !== $langcode) {
+ $content_moderation_state = $content_moderation_state->getTranslation($langcode);
+ }
+
+ return $content_moderation_state->get('moderation_state')->entity;
+ }
+ }
+ // It is possible that the bundle does not exist at this point. For example,
+ // the node type form creates a fake Node entity to get default values.
+ // @see \Drupal\node\NodeTypeForm::form()
+ $bundle_entity = \Drupal::service('content_moderation.moderation_information')
+ ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
+ if ($bundle_entity && ($default = $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state'))) {
+ return ModerationState::load($default);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get($index) {
+ if ($index !== 0) {
+ throw new \InvalidArgumentException('An entity can not have multiple moderation states at the same time.');
+ }
+ // Compute the value of the moderation state.
+ if (!isset($this->list[$index]) || $this->list[$index]->isEmpty()) {
+ $moderation_state = $this->getModerationState();
+ // Do not store NULL values in the static cache.
+ if ($moderation_state) {
+ $this->list[$index] = $this->createItem($index, ['entity' => $moderation_state]);
+ }
+ }
+
+ return isset($this->list[$index]) ? $this->list[$index] : NULL;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Menu/EditTab.php b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php
new file mode 100644
index 0000000..b8ccc2d
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Menu;
+
+use Drupal\Core\Menu\LocalTaskDefault;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\content_moderation\ModerationInformation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class for making the edit tab use 'Edit draft' or 'New draft'.
+ */
+class EditTab extends LocalTaskDefault implements ContainerFactoryPluginInterface {
+
+ use StringTranslationTrait;
+
+ /**
+ * The moderation information service.
+ *
+ * @var \Drupal\content_moderation\ModerationInformation
+ */
+ protected $moderationInfo;
+
+ /**
+ * The entity.
+ *
+ * @var \Drupal\Core\Entity\ContentEntityInterface
+ */
+ protected $entity;
+
+ /**
+ * Constructs a new EditTab object.
+ *
+ * @param array $configuration
+ * Plugin configuration.
+ * @param string $plugin_id
+ * Plugin ID.
+ * @param mixed $plugin_definition
+ * Plugin definition.
+ * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+ * The translation service.
+ * @param \Drupal\content_moderation\ModerationInformation $moderation_information
+ * The moderation information.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, TranslationInterface $string_translation, ModerationInformation $moderation_information) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+ $this->stringTranslation = $string_translation;
+ $this->moderationInfo = $moderation_information;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get('string_translation'),
+ $container->get('content_moderation.moderation_information')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRouteParameters(RouteMatchInterface $route_match) {
+ // Override the node here with the latest revision.
+ $this->entity = $route_match->getParameter($this->pluginDefinition['entity_type_id']);
+ return parent::getRouteParameters($route_match);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getTitle() {
+ if (!$this->moderationInfo->isModeratableEntity($this->entity)) {
+ // Moderation isn't enabled.
+ return parent::getTitle();
+ }
+
+ // @todo https://www.drupal.org/node/2779933 write a test for this.
+ return $this->moderationInfo->isLiveRevision($this->entity)
+ ? $this->t('New draft')
+ : $this->t('Edit draft');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheTags() {
+ // @todo https://www.drupal.org/node/2779933 write a test for this.
+ $tags = parent::getCacheTags();
+ // Tab changes if node or node-type is modified.
+ $tags = array_merge($tags, $this->entity->getCacheTags());
+ $tags[] = $this->entity->getEntityType()->getBundleEntityType() . ':' . $this->entity->bundle();
+ return $tags;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
new file mode 100644
index 0000000..c2c373f
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Verifies that nodes have a valid moderation state.
+ *
+ * @Constraint(
+ * id = "ModerationState",
+ * label = @Translation("Valid moderation state", context = "Validation")
+ * )
+ */
+class ModerationStateConstraint extends Constraint {
+
+ public $message = 'Invalid state transition from %from to %to';
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
new file mode 100644
index 0000000..b401b81
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Validation\Constraint;
+
+use Drupal\content_moderation\Entity\ModerationState as ModerationStateEntity;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Drupal\content_moderation\StateTransitionValidation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Checks if a moderation state transition is valid.
+ */
+class ModerationStateConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+ /**
+ * The state transition validation.
+ *
+ * @var \Drupal\content_moderation\StateTransitionValidation
+ */
+ protected $validation;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ private $entityTypeManager;
+
+ /**
+ * The moderation info.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInformation;
+
+ /**
+ * Creates a new ModerationStateConstraintValidator instance.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\content_moderation\StateTransitionValidation $validation
+ * The state transition validation.
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+ * The moderation information.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, StateTransitionValidation $validation, ModerationInformationInterface $moderation_information) {
+ $this->validation = $validation;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->moderationInformation = $moderation_information;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('content_moderation.state_transition_validation'),
+ $container->get('content_moderation.moderation_information')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validate($value, Constraint $constraint) {
+ /** @var \Drupal\Core\Entity\EntityInterface $entity */
+ $entity = $value->getEntity();
+
+ // Ignore entities that are not subject to moderation anyway.
+ if (!$this->moderationInformation->isModeratableEntity($entity)) {
+ 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());
+ }
+
+ if ($entity->moderation_state->target_id) {
+ $new_state_id = $entity->moderation_state->target_id;
+ }
+ else {
+ $new_state_id = $default = $this->moderationInformation
+ ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle())
+ ->getThirdPartySetting('content_moderation', 'default_moderation_state');
+ }
+ if ($new_state_id) {
+ $new_state = ModerationStateEntity::load($new_state_id);
+ }
+ // @todo - what if $new_state_id references something that does not exist or
+ // is null.
+ if (!$this->validation->isTransitionAllowed($original_entity->moderation_state->entity, $new_state)) {
+ $this->context->addViolation($constraint->message, ['%from' => $original_entity->moderation_state->entity->label(), '%to' => $new_state->label()]);
+ }
+ }
+
+ /**
+ * Determines if this entity is being moderated for the first time.
+ *
+ * If the previous version of the entity has no moderation state, we assume
+ * that means it predates the presence of moderation states.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity being moderated.
+ *
+ * @return bool
+ * TRUE if this is the entity's first time being moderated, FALSE otherwise.
+ */
+ protected function isFirstTimeModeration(EntityInterface $entity) {
+ $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id());
+
+ $original_id = $original_entity->moderation_state->target_id;
+
+ return !($entity->moderation_state->target_id && $original_entity && $original_id);
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php
new file mode 100644
index 0000000..6440019
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\views\filter;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\views\Plugin\views\filter\FilterPluginBase;
+use Drupal\views\Plugin\ViewsHandlerManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Filter to show only the latest revision of an entity.
+ *
+ * @ingroup views_filter_handlers
+ *
+ * @ViewsFilter("latest_revision")
+ */
+class LatestRevision extends FilterPluginBase implements ContainerFactoryPluginInterface {
+
+ /**
+ * Entity Type Manager service.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * Views Handler Plugin Manager.
+ *
+ * @var \Drupal\views\Plugin\ViewsHandlerManager
+ */
+ protected $joinHandler;
+
+ /**
+ * Database Connection.
+ *
+ * @var \Drupal\Core\Database\Connection
+ */
+ protected $connection;
+
+ /**
+ * Constructs a new LatestRevision.
+ *
+ * @param array $configuration
+ * A configuration array containing information about the plugin instance.
+ * @param string $plugin_id
+ * The plugin_id for the plugin instance.
+ * @param mixed $plugin_definition
+ * The plugin implementation definition.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * Entity Type Manager Service.
+ * @param \Drupal\views\Plugin\ViewsHandlerManager $join_handler
+ * Views Handler Plugin Manager.
+ * @param \Drupal\Core\Database\Connection $connection
+ * Database Connection.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, ViewsHandlerManager $join_handler, Connection $connection) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+ $this->entityTypeManager = $entity_type_manager;
+ $this->joinHandler = $join_handler;
+ $this->connection = $connection;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration, $plugin_id, $plugin_definition,
+ $container->get('entity_type.manager'),
+ $container->get('plugin.manager.views.join'),
+ $container->get('database')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function adminSummary() {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function operatorForm(&$form, FormStateInterface $form_state) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function canExpose() {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query() {
+ // The table doesn't exist until a moderated node has been saved at least
+ // once. Just in case, disable this filter until then. Note that this means
+ // the view will still show all revisions, not just latest, but this is
+ // sufficiently edge-case-y that it's probably not worth the time to
+ // handle more robustly.
+ if (!$this->connection->schema()->tableExists('content_revision_tracker')) {
+ return;
+ }
+
+ $table = $this->ensureMyTable();
+
+ /** @var \Drupal\views\Plugin\views\query\Sql $query */
+ $query = $this->query;
+
+ $definition = $this->entityTypeManager->getDefinition($this->getEntityType());
+ $keys = $definition->getKeys();
+
+ $definition = [
+ 'table' => 'content_revision_tracker',
+ 'type' => 'INNER',
+ 'field' => 'entity_id',
+ 'left_table' => $table,
+ 'left_field' => $keys['id'],
+ 'extra' => [
+ ['left_field' => $keys['langcode'], 'field' => 'langcode'],
+ ['left_field' => $keys['revision'], 'field' => 'revision_id'],
+ ['field' => 'entity_type', 'value' => $this->getEntityType()],
+ ],
+ ];
+
+ $join = $this->joinHandler->createInstance('standard', $definition);
+
+ $query->ensureTable('content_revision_tracker', $this->relationship, $join);
+ }
+
+}
diff --git a/core/modules/content_moderation/src/RevisionTracker.php b/core/modules/content_moderation/src/RevisionTracker.php
new file mode 100644
index 0000000..2011237
--- /dev/null
+++ b/core/modules/content_moderation/src/RevisionTracker.php
@@ -0,0 +1,152 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\DatabaseExceptionWrapper;
+use Drupal\Core\Database\SchemaObjectExistsException;
+
+/**
+ * Tracks metadata about revisions across entities.
+ */
+class RevisionTracker implements RevisionTrackerInterface {
+
+ /**
+ * The name of the SQL table we use for tracking.
+ *
+ * @var string
+ */
+ protected $tableName;
+
+ /**
+ * The database connection.
+ *
+ * @var \Drupal\Core\Database\Connection
+ */
+ protected $connection;
+
+ /**
+ * Constructs a new RevisionTracker.
+ *
+ * @param \Drupal\Core\Database\Connection $connection
+ * The database connection.
+ * @param string $table
+ * The table that should be used for tracking.
+ */
+ public function __construct(Connection $connection, $table = 'content_revision_tracker') {
+ $this->connection = $connection;
+ $this->tableName = $table;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id) {
+ try {
+ $this->recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
+ }
+ catch (DatabaseExceptionWrapper $e) {
+ $this->ensureTableExists();
+ $this->recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Records the latest revision of a given entity.
+ *
+ * @param string $entity_type_id
+ * The machine name of the type of entity.
+ * @param string $entity_id
+ * The Entity ID in question.
+ * @param string $langcode
+ * The langcode of the revision we're saving. Each language has its own
+ * effective tree of entity revisions, so in different languages
+ * different revisions will be "latest".
+ * @param int $revision_id
+ * The revision ID that is now the latest revision.
+ *
+ * @return int
+ * One of the valid returns from a merge query's execute method.
+ */
+ protected function recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id) {
+ return $this->connection->merge($this->tableName)
+ ->keys([
+ 'entity_type' => $entity_type_id,
+ 'entity_id' => $entity_id,
+ 'langcode' => $langcode,
+ ])
+ ->fields([
+ 'revision_id' => $revision_id,
+ ])
+ ->execute();
+ }
+
+ /**
+ * Checks if the table exists and create it if not.
+ *
+ * @return bool
+ * TRUE if the table was created, FALSE otherwise.
+ */
+ protected function ensureTableExists() {
+ try {
+ if (!$this->connection->schema()->tableExists($this->tableName)) {
+ $this->connection->schema()->createTable($this->tableName, $this->schemaDefinition());
+ return TRUE;
+ }
+ }
+ catch (SchemaObjectExistsException $e) {
+ // If another process has already created the table, attempting to
+ // recreate it will throw an exception. In this case just catch the
+ // exception and do nothing.
+ return TRUE;
+ }
+ return FALSE;
+ }
+
+ /**
+ * Defines the schema for the tracker table.
+ *
+ * @return array
+ * The schema API definition for the SQL storage table.
+ */
+ protected function schemaDefinition() {
+ $schema = [
+ 'description' => 'Tracks the latest revision for any entity',
+ 'fields' => [
+ 'entity_type' => [
+ 'description' => 'The entity type',
+ 'type' => 'varchar_ascii',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ],
+ 'entity_id' => [
+ 'description' => 'The entity ID',
+ 'type' => 'int',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ],
+ 'langcode' => [
+ 'description' => 'The language of the entity revision',
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ 'default' => '',
+ ],
+ 'revision_id' => [
+ 'description' => 'The latest revision ID for this entity',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ],
+ ],
+ 'primary key' => ['entity_type', 'entity_id', 'langcode'],
+ ];
+
+ return $schema;
+ }
+
+}
diff --git a/core/modules/content_moderation/src/RevisionTrackerInterface.php b/core/modules/content_moderation/src/RevisionTrackerInterface.php
new file mode 100644
index 0000000..2b7cf95
--- /dev/null
+++ b/core/modules/content_moderation/src/RevisionTrackerInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+/**
+ * Tracks metadata about revisions across content entities.
+ */
+interface RevisionTrackerInterface {
+
+ /**
+ * Sets the latest revision of a given entity.
+ *
+ * @param string $entity_type_id
+ * The machine name of the type of entity.
+ * @param string $entity_id
+ * The Entity ID in question.
+ * @param string $langcode
+ * The langcode of the revision we're saving. Each language has its own
+ * effective tree of entity revisions, so in different languages
+ * different revisions will be "latest".
+ * @param int $revision_id
+ * The revision ID that is now the latest revision.
+ *
+ * @return static
+ */
+ public function setLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
+
+}
diff --git a/core/modules/content_moderation/src/Routing/EntityModerationRouteProvider.php b/core/modules/content_moderation/src/Routing/EntityModerationRouteProvider.php
new file mode 100644
index 0000000..f953d80
--- /dev/null
+++ b/core/modules/content_moderation/src/Routing/EntityModerationRouteProvider.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Drupal\content_moderation\Routing;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityHandlerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Dynamic route provider for the Content moderation module.
+ *
+ * Provides the following routes:
+ * - The latest version tab, showing the latest revision of an entity, not the
+ * default one.
+ */
+class EntityModerationRouteProvider implements EntityRouteProviderInterface, EntityHandlerInterface {
+
+ /**
+ * The entity manager.
+ *
+ * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+ */
+ protected $entityFieldManager;
+
+ /**
+ * Constructs a new DefaultHtmlRouteProvider.
+ *
+ * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_manager
+ * The entity manager.
+ */
+ public function __construct(EntityFieldManagerInterface $entity_manager) {
+ $this->entityFieldManager = $entity_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+ return new static(
+ $container->get('entity_field.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRoutes(EntityTypeInterface $entity_type) {
+ $collection = new RouteCollection();
+
+ if ($moderation_route = $this->getLatestVersionRoute($entity_type)) {
+ $entity_type_id = $entity_type->id();
+ $collection->add("entity.{$entity_type_id}.latest_version", $moderation_route);
+ }
+
+ return $collection;
+ }
+
+ /**
+ * Gets the moderation-form route.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type.
+ *
+ * @return \Symfony\Component\Routing\Route|null
+ * The generated route, if available.
+ */
+ protected function getLatestVersionRoute(EntityTypeInterface $entity_type) {
+ if ($entity_type->hasLinkTemplate('latest-version') && $entity_type->hasViewBuilderClass()) {
+ $entity_type_id = $entity_type->id();
+ $route = new Route($entity_type->getLinkTemplate('latest-version'));
+ $route
+ ->addDefaults([
+ '_entity_view' => "{$entity_type_id}.full",
+ '_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::title',
+ ])
+ // If the entity type is a node, unpublished content will be visible
+ // if the user has the "view all unpublished content" permission.
+ ->setRequirement('_entity_access', "{$entity_type_id}.view")
+ ->setRequirement('_permission', 'view latest version,view any unpublished content')
+ ->setRequirement('_content_moderation_latest_version', 'TRUE')
+ ->setOption('_content_moderation_entity_type', $entity_type_id)
+ ->setOption('parameters', [
+ $entity_type_id => [
+ 'type' => 'entity:' . $entity_type_id,
+ 'load_forward_revision' => 1,
+ ],
+ ]);
+
+ // Entity types with serial IDs can specify this in their route
+ // requirements, improving the matching process.
+ if ($this->getEntityTypeIdKeyType($entity_type) === 'integer') {
+ $route->setRequirement($entity_type_id, '\d+');
+ }
+ return $route;
+ }
+ }
+
+ /**
+ * Gets the type of the ID key for a given entity type.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * An entity type.
+ *
+ * @return string|null
+ * The type of the ID key for a given entity type, or NULL if the entity
+ * type does not support fields.
+ */
+ protected function getEntityTypeIdKeyType(EntityTypeInterface $entity_type) {
+ if (!$entity_type->isSubclassOf(FieldableEntityInterface::class)) {
+ return NULL;
+ }
+
+ $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
+ return $field_storage_definitions[$entity_type->getKey('id')]->getType();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php
new file mode 100644
index 0000000..c722a67
--- /dev/null
+++ b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\content_moderation\Routing;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Provides the moderation configuration routes for config entities.
+ */
+class EntityTypeModerationRouteProvider implements EntityRouteProviderInterface {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRoutes(EntityTypeInterface $entity_type) {
+ $collection = new RouteCollection();
+
+ if ($moderation_route = $this->getModerationFormRoute($entity_type)) {
+ $entity_type_id = $entity_type->id();
+ $collection->add("entity.{$entity_type_id}.moderation", $moderation_route);
+ }
+
+ return $collection;
+ }
+
+ /**
+ * Gets the moderation-form route.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type.
+ *
+ * @return \Symfony\Component\Routing\Route|null
+ * The generated route, if available.
+ */
+ protected function getModerationFormRoute(EntityTypeInterface $entity_type) {
+ if ($entity_type->hasLinkTemplate('moderation-form') && $entity_type->getFormClass('moderation')) {
+ $entity_type_id = $entity_type->id();
+
+ $route = new Route($entity_type->getLinkTemplate('moderation-form'));
+
+ // @todo Come up with a new permission.
+ $route
+ ->setDefaults([
+ '_entity_form' => "{$entity_type_id}.moderation",
+ '_title' => 'Moderation',
+ ])
+ ->setRequirement('_permission', 'administer moderation states')
+ ->setOption('parameters', [
+ $entity_type_id => ['type' => 'entity:' . $entity_type_id],
+ ]);
+
+ return $route;
+ }
+ }
+
+}
diff --git a/core/modules/content_moderation/src/StateTransitionValidation.php b/core/modules/content_moderation/src/StateTransitionValidation.php
new file mode 100644
index 0000000..2e2a4e2
--- /dev/null
+++ b/core/modules/content_moderation/src/StateTransitionValidation.php
@@ -0,0 +1,247 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryFactory;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+
+/**
+ * Validates whether a certain state transition is allowed.
+ */
+class StateTransitionValidation implements StateTransitionValidationInterface {
+
+ /**
+ * Entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * Entity query factory.
+ *
+ * @var \Drupal\Core\Entity\Query\QueryFactory
+ */
+ protected $queryFactory;
+
+ /**
+ * Stores the possible state transitions.
+ *
+ * @var array
+ */
+ protected $possibleTransitions = [];
+
+ /**
+ * Constructs a new StateTransitionValidation.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager service.
+ * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
+ * The entity query factory.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, QueryFactory $query_factory) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->queryFactory = $query_factory;
+ }
+
+ /**
+ * Computes a mapping of possible transitions.
+ *
+ * This method is uncached and will recalculate the list on every request.
+ * In most cases you want to use getPossibleTransitions() instead.
+ *
+ * @see static::getPossibleTransitions()
+ *
+ * @return array[]
+ * An array containing all possible transitions. Each entry is keyed by the
+ * "from" state, and the value is an array of all legal "to" states based
+ * on the currently defined transition objects.
+ */
+ protected function calculatePossibleTransitions() {
+ $transitions = $this->transitionStorage()->loadMultiple();
+
+ $possible_transitions = [];
+ /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */
+ foreach ($transitions as $transition) {
+ $possible_transitions[$transition->getFromState()][] = $transition->getToState();
+ }
+ return $possible_transitions;
+ }
+
+ /**
+ * Returns a mapping of possible transitions.
+ *
+ * @return array[]
+ * An array containing all possible transitions. Each entry is keyed by the
+ * "from" state, and the value is an array of all legal "to" states based
+ * on the currently defined transition objects.
+ */
+ protected function getPossibleTransitions() {
+ if (empty($this->possibleTransitions)) {
+ $this->possibleTransitions = $this->calculatePossibleTransitions();
+ }
+ return $this->possibleTransitions;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user) {
+ $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
+
+ $states_for_bundle = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []);
+
+ /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
+ $current_state = $entity->moderation_state->entity;
+
+ $all_transitions = $this->getPossibleTransitions();
+ $destination_ids = $all_transitions[$current_state->id()];
+
+ $destination_ids = array_intersect($states_for_bundle, $destination_ids);
+ $destinations = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple($destination_ids);
+
+ return array_filter($destinations, function(ModerationStateInterface $destination_state) use ($current_state, $user) {
+ return $this->userMayTransition($current_state, $destination_state, $user);
+ });
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) {
+ $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
+
+ /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
+ $current_state = $entity->moderation_state->entity;
+ $current_state_id = $current_state ? $current_state->id() : $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state');
+
+ // Determine the states that are legal on this bundle.
+ $legal_bundle_states = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []);
+
+ // Legal transitions include those that are possible from the current state,
+ // filtered by those whose target is legal on this bundle and that the
+ // user has access to execute.
+ $transitions = array_filter($this->getTransitionsFrom($current_state_id), function(ModerationStateTransition $transition) use ($legal_bundle_states, $user) {
+ return in_array($transition->getToState(), $legal_bundle_states, TRUE)
+ && $user->hasPermission('use ' . $transition->id() . ' transition');
+ });
+
+ return $transitions;
+ }
+
+ /**
+ * Returns a list of possible transitions from a given state.
+ *
+ * This list is based only on those transitions that exist, not what
+ * transitions are legal in a given context.
+ *
+ * @param string $state_name
+ * The machine name of the state from which we are transitioning.
+ *
+ * @return ModerationStateTransition[]
+ * A list of possible transitions from a given state.
+ */
+ protected function getTransitionsFrom($state_name) {
+ $result = $this->transitionStateQuery()
+ ->condition('stateFrom', $state_name)
+ ->sort('weight')
+ ->execute();
+
+ return $this->transitionStorage()->loadMultiple($result);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user) {
+ if ($transition = $this->getTransitionFromStates($from, $to)) {
+ return $user->hasPermission('use ' . $transition->id() . ' transition');
+ }
+ return FALSE;
+ }
+
+ /**
+ * Returns the transition object that transitions from one state to another.
+ *
+ * @param \Drupal\content_moderation\ModerationStateInterface $from
+ * The origin state.
+ * @param \Drupal\content_moderation\ModerationStateInterface $to
+ * The destination state.
+ *
+ * @return ModerationStateTransition|null
+ * A transition object, or NULL if there is no such transition.
+ */
+ protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) {
+ $from = $this->transitionStateQuery()
+ ->condition('stateFrom', $from->id())
+ ->condition('stateTo', $to->id())
+ ->execute();
+
+ $transitions = $this->transitionStorage()->loadMultiple($from);
+
+ if ($transitions) {
+ return current($transitions);
+ }
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to) {
+ $allowed_transitions = $this->calculatePossibleTransitions();
+ if (isset($allowed_transitions[$from->id()])) {
+ return in_array($to->id(), $allowed_transitions[$from->id()], TRUE);
+ }
+ return FALSE;
+ }
+
+ /**
+ * Returns a transition state entity query.
+ *
+ * @return \Drupal\Core\Entity\Query\QueryInterface
+ * A transition state entity query.
+ */
+ protected function transitionStateQuery() {
+ return $this->queryFactory->get('moderation_state_transition', 'AND');
+ }
+
+ /**
+ * Returns the transition entity storage service.
+ *
+ * @return \Drupal\Core\Entity\EntityStorageInterface
+ * The transition state entity storage.
+ */
+ protected function transitionStorage() {
+ return $this->entityTypeManager->getStorage('moderation_state_transition');
+ }
+
+ /**
+ * Returns the state entity storage service.
+ *
+ * @return \Drupal\Core\Entity\EntityStorageInterface
+ * The moderation state entity storage.
+ */
+ protected function stateStorage() {
+ return $this->entityTypeManager->getStorage('moderation_state');
+ }
+
+ /**
+ * Loads a specific bundle entity.
+ *
+ * @param string $bundle_entity_type_id
+ * The bundle entity type ID.
+ * @param string $bundle_id
+ * The bundle ID.
+ *
+ * @return \Drupal\Core\Config\Entity\ConfigEntityInterface|null
+ * The specific bundle entity.
+ */
+ protected function loadBundleEntity($bundle_entity_type_id, $bundle_id) {
+ return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id);
+ }
+
+}
diff --git a/core/modules/content_moderation/src/StateTransitionValidationInterface.php b/core/modules/content_moderation/src/StateTransitionValidationInterface.php
new file mode 100644
index 0000000..5ef0dd1
--- /dev/null
+++ b/core/modules/content_moderation/src/StateTransitionValidationInterface.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Validates whether a certain state transition is allowed.
+ */
+interface StateTransitionValidationInterface {
+
+ /**
+ * Gets a list of states a user may transition an entity to.
+ *
+ * @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\content_moderation\Entity\ModerationState[]
+ * Returns an array of States to which the specified user may transition the
+ * entity.
+ */
+ public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user);
+
+ /**
+ * Gets a list of transitions that are legal 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\content_moderation\Entity\ModerationStateTransition[]
+ * The list of transitions that are legal for this user on this entity.
+ */
+ public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user);
+
+ /**
+ * Determines if a user is allowed to transition from one state to another.
+ *
+ * This method will also return FALSE if there is no transition between the
+ * specified states at all.
+ *
+ * @param \Drupal\content_moderation\ModerationStateInterface $from
+ * The origin state.
+ * @param \Drupal\content_moderation\ModerationStateInterface $to
+ * The destination state.
+ * @param \Drupal\Core\Session\AccountInterface $user
+ * The user to validate.
+ *
+ * @return bool
+ * TRUE if the given user may transition between those two states.
+ */
+ public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user);
+
+ /**
+ * Determines a transition allowed.
+ *
+ * @param \Drupal\content_moderation\ModerationStateInterface $from
+ * The origin state.
+ * @param \Drupal\content_moderation\ModerationStateInterface $to
+ * The destination state.
+ *
+ * @return bool
+ * Is the transition allowed.
+ */
+ public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to);
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationFormTest.php b/core/modules/content_moderation/src/Tests/ModerationFormTest.php
new file mode 100644
index 0000000..d6c92b9
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationFormTest.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Tests the moderation form, specifically on nodes.
+ *
+ * @group content_moderation
+ */
+class ModerationFormTest extends ModerationStateTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->drupalLogin($this->adminUser);
+ $this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE, [
+ 'draft',
+ 'published',
+ ], 'draft');
+ $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
+ }
+
+ /**
+ * Tests the moderation form that shows on the latest version page.
+ *
+ * The latest version page only shows if there is a forward revision. There
+ * is only a forward revision if a draft revision is created on a node where
+ * the default revision is not a published moderation state.
+ *
+ * @see \Drupal\content_moderation\EntityOperations
+ * @see \Drupal\content_moderation\Tests\ModerationStateBlockTest::testCustomBlockModeration
+ */
+ public function testModerationForm() {
+ // 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.',
+ ], t('Save and Create New Draft'));
+
+ $node = $this->drupalGetNodeByTitle('Some moderated content');
+ $canonical_path = sprintf('node/%d', $node->id());
+ $edit_path = sprintf('node/%d/edit', $node->id());
+ $latest_version_path = sprintf('node/%d/latest', $node->id());
+
+ $this->assertTrue($this->adminUser->hasPermission('edit any moderated_content content'));
+
+ // The latest version page should not show, because there is no forward
+ // revision.
+ $this->drupalGet($latest_version_path);
+ $this->assertResponse(403);
+
+ // Update the draft.
+ $this->drupalPostForm($edit_path, [
+ 'body[0][value]' => 'Second version of the content.',
+ ], t('Save and Create New Draft'));
+
+ // The latest version page should not show, because there is still no
+ // forward revision.
+ $this->drupalGet($latest_version_path);
+ $this->assertResponse(403);
+
+ // Publish the draft.
+ $this->drupalPostForm($edit_path, [
+ 'body[0][value]' => 'Third version of the content.',
+ ], t('Save and Publish'));
+
+ // The published view should not have a moderation form, because it is the
+ // default revision.
+ $this->drupalGet($canonical_path);
+ $this->assertResponse(200);
+ $this->assertNoText('Status', 'The node view page has no moderation form.');
+
+ // The latest version page should not show, because there is still no
+ // forward revision.
+ $this->drupalGet($latest_version_path);
+ $this->assertResponse(403);
+
+ // Make a forward revision.
+ $this->drupalPostForm($edit_path, [
+ 'body[0][value]' => 'Fourth version of the content.',
+ ], t('Save and Create New Draft'));
+
+ // The published view should not have a moderation form, because it is the
+ // default revision.
+ $this->drupalGet($canonical_path);
+ $this->assertResponse(200);
+ $this->assertNoText('Status', 'The node view page has no moderation form.');
+
+ // The latest version page should show the moderation form and have "Draft"
+ // status, because the forward revision is in "Draft".
+ $this->drupalGet($latest_version_path);
+ $this->assertResponse(200);
+ $this->assertText('Status', 'Form text found on the latest-version page.');
+ $this->assertText('Draft', 'Correct status found on the latest-version page.');
+
+ // Submit the moderation form to change status to published.
+ $this->drupalPostForm($latest_version_path, [
+ 'new_state' => 'published',
+ ], t('Apply'));
+
+ // The latest version page should not show, because there is no
+ // forward revision.
+ $this->drupalGet($latest_version_path);
+ $this->assertResponse(403);
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php
new file mode 100644
index 0000000..0d40356
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php
@@ -0,0 +1,221 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Test content_moderation functionality with localization and translation.
+ *
+ * @group content_moderation
+ */
+class ModerationLocaleTest extends ModerationStateTestBase {
+
+ /**
+ * Modules to enable.
+ *
+ * @var array
+ */
+ public static $modules = [
+ 'node',
+ 'content_moderation',
+ 'locale',
+ 'content_translation',
+ ];
+
+ /**
+ * Tests article translations can be moderated separately.
+ */
+ public function testTranslateModeratedContent() {
+ $this->drupalLogin($this->rootUser);
+
+ // Enable moderation on Article node type.
+ $this->createContentTypeFromUi(
+ 'Article',
+ 'article',
+ TRUE,
+ ['draft', 'published', 'archived'],
+ 'draft'
+ );
+
+ // Add French language.
+ $edit = [
+ 'predefined_langcode' => 'fr',
+ ];
+ $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Enable content translation on articles.
+ $this->drupalGet('admin/config/regional/content-language');
+ $edit = [
+ 'entity_types[node]' => TRUE,
+ 'settings[node][article][translatable]' => TRUE,
+ 'settings[node][article][settings][language][language_alterable]' => TRUE,
+ ];
+ $this->drupalPostForm(NULL, $edit, t('Save configuration'));
+
+ // Adding languages requires a container rebuild in the test running
+ // environment so that multilingual services are used.
+ $this->rebuildContainer();
+
+ // Create a published article in English.
+ $edit = [
+ 'title[0][value]' => 'Published English node',
+ 'langcode[0][value]' => 'en',
+ ];
+ $this->drupalPostForm('node/add/article', $edit, t('Save and Publish'));
+ $this->assertText(t('Article Published English node has been created.'));
+ $english_node = $this->drupalGetNodeByTitle('Published English node');
+
+ // Add a French translation.
+ $this->drupalGet('node/' . $english_node->id() . '/translations');
+ $this->clickLink(t('Add'));
+ $edit = [
+ 'title[0][value]' => 'French node Draft',
+ ];
+ $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
+ // Here the error has occurred "The website encountered an unexpected error.
+ // Please try again later."
+ // If the translation has got lost.
+ $this->assertText(t('Article French node Draft has been updated.'));
+
+ // Create an article in English.
+ $edit = [
+ 'title[0][value]' => 'English node',
+ 'langcode[0][value]' => 'en',
+ ];
+ $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
+ $this->assertText(t('Article English node has been created.'));
+ $english_node = $this->drupalGetNodeByTitle('English node');
+
+ // Add a French translation.
+ $this->drupalGet('node/' . $english_node->id() . '/translations');
+ $this->clickLink(t('Add'));
+ $edit = [
+ 'title[0][value]' => 'French node',
+ ];
+ $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
+ $this->assertText(t('Article French node has been updated.'));
+ $english_node = $this->drupalGetNodeByTitle('English node', TRUE);
+
+ // Publish the English article and check that the translation stays
+ // unpublished.
+ $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
+ $this->assertText(t('Article English node has been updated.'));
+ $english_node = $this->drupalGetNodeByTitle('English node', TRUE);
+ $french_node = $english_node->getTranslation('fr');
+ $this->assertEqual('French node', $french_node->label());
+
+ $this->assertEqual($english_node->moderation_state->target_id, 'published');
+ $this->assertTrue($english_node->isPublished());
+ $this->assertEqual($french_node->moderation_state->target_id, 'draft');
+ $this->assertFalse($french_node->isPublished());
+
+ // Create another article with its translation. This time we will publish
+ // the translation first.
+ $edit = [
+ 'title[0][value]' => 'Another node',
+ ];
+ $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
+ $this->assertText(t('Article Another node has been created.'));
+ $english_node = $this->drupalGetNodeByTitle('Another node');
+
+ // Add a French translation.
+ $this->drupalGet('node/' . $english_node->id() . '/translations');
+ $this->clickLink(t('Add'));
+ $edit = [
+ 'title[0][value]' => 'Translated node',
+ ];
+ $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
+ $this->assertText(t('Article Translated node has been updated.'));
+ $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+
+ // Publish the translation and check that the source language version stays
+ // unpublished.
+ $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
+ $this->assertText(t('Article Translated node has been updated.'));
+ $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+ $french_node = $english_node->getTranslation('fr');
+ $this->assertEqual($french_node->moderation_state->target_id, 'published');
+ $this->assertTrue($french_node->isPublished());
+ $this->assertEqual($english_node->moderation_state->target_id, 'draft');
+ $this->assertFalse($english_node->isPublished());
+
+ // Now check that we can create a new draft of the translation.
+ $edit = [
+ 'title[0][value]' => 'New draft of translated node',
+ ];
+ $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', $edit, t('Save and Create New Draft (this translation)'));
+ $this->assertText(t('Article New draft of translated node has been updated.'));
+ $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+ $french_node = $english_node->getTranslation('fr');
+ $this->assertEqual($french_node->moderation_state->target_id, 'published');
+ $this->assertTrue($french_node->isPublished());
+ $this->assertEqual($french_node->getTitle(), 'Translated node', 'The default revision of the published translation remains the same.');
+
+ // Publish the draft.
+ $edit = [
+ 'new_state' => 'published',
+ ];
+ $this->drupalPostForm('fr/node/' . $english_node->id() . '/latest', $edit, t('Apply'));
+ $this->assertText(t('The moderation state has been updated.'));
+ $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+ $french_node = $english_node->getTranslation('fr');
+ $this->assertEqual($french_node->moderation_state->target_id, 'published');
+ $this->assertTrue($french_node->isPublished());
+ $this->assertEqual($french_node->getTitle(), 'New draft of translated node', 'The draft has replaced the published revision.');
+
+ // Publish the English article before testing the archive transition.
+ $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
+ $this->assertText(t('Article Another node has been updated.'));
+ $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+ $this->assertEqual($english_node->moderation_state->target_id, 'published');
+
+ // Archive the node and its translation.
+ $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)'));
+ $this->assertText(t('Article Another node has been updated.'));
+ $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)'));
+ $this->assertText(t('Article New draft of translated node has been updated.'));
+ $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+ $french_node = $english_node->getTranslation('fr');
+ $this->assertEqual($english_node->moderation_state->target_id, 'archived');
+ $this->assertFalse($english_node->isPublished());
+ $this->assertEqual($french_node->moderation_state->target_id, 'archived');
+ $this->assertFalse($french_node->isPublished());
+
+ // Create another article with its translation. This time publishing english
+ // after creating a forward french revision.
+ $edit = [
+ 'title[0][value]' => 'An english node',
+ ];
+ $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
+ $this->assertText(t('Article An english node has been created.'));
+ $english_node = $this->drupalGetNodeByTitle('An english node');
+ $this->assertFalse($english_node->isPublished());
+
+ // Add a French translation.
+ $this->drupalGet('node/' . $english_node->id() . '/translations');
+ $this->clickLink(t('Add'));
+ $edit = [
+ 'title[0][value]' => 'A french node',
+ ];
+ $this->drupalPostForm(NULL, $edit, t('Save and Publish (this translation)'));
+ $english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
+ $french_node = $english_node->getTranslation('fr');
+ $this->assertTrue($french_node->isPublished());
+ $this->assertFalse($english_node->isPublished());
+
+ // Create a forward revision
+ $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Create New Draft (this translation)'));
+ $english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
+ $french_node = $english_node->getTranslation('fr');
+ $this->assertTrue($french_node->isPublished());
+ $this->assertFalse($english_node->isPublished());
+
+ // Publish the english node and the default french node not the latest
+ // french node should be used.
+ $this->drupalPostForm('/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
+ $english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
+ $french_node = $english_node->getTranslation('fr');
+ $this->assertTrue($french_node->isPublished());
+ $this->assertTrue($english_node->isPublished());
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php
new file mode 100644
index 0000000..001d6b5
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\block_content\Entity\BlockContentType;
+
+/**
+ * Tests general content moderation workflow for blocks.
+ *
+ * @group content_moderation
+ */
+class ModerationStateBlockTest extends ModerationStateTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ // Create the "basic" block type.
+ $bundle = BlockContentType::create([
+ 'id' => 'basic',
+ 'label' => 'basic',
+ 'revision' => FALSE,
+ ]);
+ $bundle->save();
+
+ // Add the body field to it.
+ block_content_add_body_field($bundle->id());
+ }
+
+ /**
+ * Tests moderating custom blocks.
+ *
+ * Blocks and any non-node-type-entities do not have a concept of
+ * "published". As such, we must use the "default revision" to know what is
+ * going to be "published", i.e. visible to the user.
+ *
+ * The one exception is a block that has never been "published". When a block
+ * is first created, it becomes the "default revision". For each edit of the
+ * block after that, Content Moderation checks the "default revision" to
+ * see if it is set to a published moderation state. If it is not, the entity
+ * being saved will become the "default revision".
+ *
+ * The test below is intended, in part, to make this behavior clear.
+ *
+ * @see \Drupal\content_moderation\EntityOperations::entityPresave
+ * @see \Drupal\content_moderation\Tests\ModerationFormTest::testModerationForm
+ */
+ public function testCustomBlockModeration() {
+ $this->drupalLogin($this->rootUser);
+
+ // Enable moderation for custom blocks at
+ // admin/structure/block/block-content/manage/basic/moderation.
+ $edit = [
+ 'enable_moderation_state' => TRUE,
+ 'allowed_moderation_states_unpublished[draft]' => TRUE,
+ 'allowed_moderation_states_published[published]' => TRUE,
+ 'default_moderation_state' => 'draft',
+ ];
+ $this->drupalPostForm('admin/structure/block/block-content/manage/basic/moderation', $edit, t('Save'));
+ $this->assertText(t('Your settings have been saved.'));
+
+ // Create a custom block at block/add and save it as draft.
+ $body = 'Body of moderated block';
+ $edit = [
+ 'info[0][value]' => 'Moderated block',
+ 'body[0][value]' => $body,
+ ];
+ $this->drupalPostForm('block/add', $edit, t('Save and Create New Draft'));
+ $this->assertText(t('basic Moderated block has been created.'));
+
+ // Place the block in the Sidebar First region.
+ $instance = array(
+ 'id' => 'moderated_block',
+ 'settings[label]' => $edit['info[0][value]'],
+ 'region' => 'sidebar_first',
+ );
+ $block = BlockContent::load(1);
+ $url = 'admin/structure/block/add/block_content:' . $block->uuid() . '/' . $this->config('system.theme')->get('default');
+ $this->drupalPostForm($url, $instance, t('Save block'));
+
+ // Navigate to home page and check that the block is visible. It should be
+ // visible because it is the default revision.
+ $this->drupalGet('');
+ $this->assertText($body);
+
+ // Update the block.
+ $updated_body = 'This is the new body value';
+ $edit = [
+ 'body[0][value]' => $updated_body,
+ ];
+ $this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft'));
+ $this->assertText(t('basic Moderated block has been updated.'));
+
+ // Navigate to the home page and check that the block shows the updated
+ // content. It should show the updated content because the block's default
+ // revision is not a published moderation state.
+ $this->drupalGet('');
+ $this->assertText($updated_body);
+
+ // Publish the block so we can create a forward revision.
+ $this->drupalPostForm('block/' . $block->id(), [], t('Save and Publish'));
+
+ // Create a forward revision.
+ $forward_revision_body = 'This is the forward revision body value';
+ $edit = [
+ 'body[0][value]' => $forward_revision_body,
+ ];
+ $this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft'));
+ $this->assertText(t('basic Moderated block has been updated.'));
+
+ // Navigate to home page and check that the forward revision doesn't show,
+ // since it should not be set as the default revision.
+ $this->drupalGet('');
+ $this->assertText($updated_body);
+
+ // Open the latest tab and publish the new draft.
+ $edit = [
+ 'new_state' => 'published',
+ ];
+ $this->drupalPostForm('block/' . $block->id() . '/latest', $edit, t('Apply'));
+ $this->assertText(t('The moderation state has been updated.'));
+
+ // Navigate to home page and check that the forward revision is now the
+ // default revision and therefore visible.
+ $this->drupalGet('');
+ $this->assertText($forward_revision_body);
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php
new file mode 100644
index 0000000..a819caf
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+use Drupal\Core\Url;
+use Drupal\node\Entity\Node;
+
+/**
+ * Tests general content moderation workflow for nodes.
+ *
+ * @group content_moderation
+ */
+class ModerationStateNodeTest extends ModerationStateTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->drupalLogin($this->adminUser);
+ $this->createContentTypeFromUi(
+ 'Moderated content',
+ 'moderated_content',
+ TRUE,
+ ['draft', 'needs_review', 'published'],
+ 'draft'
+ );
+ $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
+ }
+
+ /**
+ * Tests creating and deleting content.
+ */
+ public function testCreatingContent() {
+ $this->drupalPostForm('node/add/moderated_content', [
+ 'title[0][value]' => 'moderated content',
+ ], t('Save and Create New Draft'));
+ $nodes = \Drupal::entityTypeManager()
+ ->getStorage('node')
+ ->loadByProperties([
+ 'title' => 'moderated content',
+ ]);
+
+ if (!$nodes) {
+ $this->fail('Test node was not saved correctly.');
+ return;
+ }
+
+ $node = reset($nodes);
+
+ $path = 'node/' . $node->id() . '/edit';
+ // Set up published revision.
+ $this->drupalPostForm($path, [], t('Save and Publish'));
+ \Drupal::entityTypeManager()->getStorage('node')->resetCache([$node->id()]);
+ /* @var \Drupal\node\NodeInterface $node */
+ $node = \Drupal::entityTypeManager()->getStorage('node')->load($node->id());
+ $this->assertTrue($node->isPublished());
+
+ // Verify that the state field is not shown.
+ $this->assertNoText('Published');
+
+ // Delete the node.
+ $this->drupalPostForm('node/' . $node->id() . '/delete', array(), t('Delete'));
+ $this->assertText(t('The Moderated content moderated content has been deleted.'));
+ }
+
+ /**
+ * Tests edit form destinations.
+ */
+ public function testFormSaveDestination() {
+ // 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.',
+ ], t('Save and Create New Draft'));
+
+ $node = $this->drupalGetNodeByTitle('Some moderated content');
+ $edit_path = sprintf('node/%d/edit', $node->id());
+
+ // After saving, we should be at the canonical URL and viewing the first
+ // revision.
+ $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
+ $this->assertText('First version of the content.');
+
+ // Create a new draft; after saving, we should still be on the canonical
+ // URL, but viewing the second revision.
+ $this->drupalPostForm($edit_path, [
+ 'body[0][value]' => 'Second version of the content.',
+ ], t('Save and Create New Draft'));
+ $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
+ $this->assertText('Second version of the content.');
+
+ // Make a new published revision; after saving, we should be at the
+ // canonical URL.
+ $this->drupalPostForm($edit_path, [
+ 'body[0][value]' => 'Third version of the content.',
+ ], t('Save and Publish'));
+ $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
+ $this->assertText('Third version of the content.');
+
+ // Make a new forward revision; after saving, we should be on the "Latest
+ // version" tab.
+ $this->drupalPostForm($edit_path, [
+ 'body[0][value]' => 'Fourth version of the content.',
+ ], t('Save and Create New Draft'));
+ $this->assertUrl(Url::fromRoute('entity.node.latest_version', ['node' => $node->id()]));
+ $this->assertText('Fourth version of the content.');
+ }
+
+ /**
+ * Tests pagers aren't broken by content_moderation.
+ */
+ public function testPagers() {
+ // Create 51 nodes to force the pager.
+ foreach (range(1, 51) as $delta) {
+ Node::create([
+ 'type' => 'moderated_content',
+ 'uid' => $this->adminUser->id(),
+ 'title' => 'Node ' . $delta,
+ 'status' => 1,
+ 'moderation_state' => 'published',
+ ])->save();
+ }
+ $this->drupalLogin($this->adminUser);
+ $this->drupalGet('admin/content');
+ $element = $this->cssSelect('nav.pager li.is-active a');
+ $url = (string) $element[0]['href'];
+ $query = [];
+ parse_str(parse_url($url, PHP_URL_QUERY), $query);
+ $this->assertEqual(0, $query['page']);
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php
new file mode 100644
index 0000000..debb32c
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+
+/**
+ * Tests moderation state node type integration.
+ *
+ * @group content_moderation
+ */
+class ModerationStateNodeTypeTest extends ModerationStateTestBase {
+
+ /**
+ * A node type without moderation state disabled.
+ */
+ public function testNotModerated() {
+ $this->drupalLogin($this->adminUser);
+ $this->createContentTypeFromUi('Not moderated', 'not_moderated');
+ $this->assertText('The content type Not moderated has been added.');
+ $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated');
+ $this->drupalGet('node/add/not_moderated');
+ $this->assertRaw('Save as unpublished');
+ $this->drupalPostForm(NULL, [
+ 'title[0][value]' => 'Test',
+ ], t('Save and publish'));
+ $this->assertText('Not moderated Test has been created.');
+ }
+
+ /**
+ * Tests enabling moderation on an existing node-type, with content.
+ */
+ public function testEnablingOnExistingContent() {
+ // Create a node type that is not moderated.
+ $this->drupalLogin($this->adminUser);
+ $this->createContentTypeFromUi('Not moderated', 'not_moderated');
+ $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated');
+
+ // Create content.
+ $this->drupalGet('node/add/not_moderated');
+ $this->drupalPostForm(NULL, [
+ 'title[0][value]' => 'Test',
+ ], t('Save and publish'));
+ $this->assertText('Not moderated Test has been created.');
+
+ // Now enable moderation state.
+ $this->enableModerationThroughUi(
+ 'not_moderated',
+ ['draft', 'needs_review', 'published'],
+ 'draft'
+ );
+
+ // And make sure it works.
+ $nodes = \Drupal::entityTypeManager()->getStorage('node')
+ ->loadByProperties(['title' => 'Test']);
+ if (empty($nodes)) {
+ $this->fail('Could not load node with title Test');
+ return;
+ }
+ $node = reset($nodes);
+ $this->drupalGet('node/' . $node->id());
+ $this->assertResponse(200);
+ $this->assertLinkByHref('node/' . $node->id() . '/edit');
+ $this->drupalGet('node/' . $node->id() . '/edit');
+ $this->assertResponse(200);
+ $this->assertRaw('Save and Create New Draft');
+ $this->assertNoRaw('Save and publish');
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php
new file mode 100644
index 0000000..1b394f9
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Tests moderation state config entity.
+ *
+ * @group content_moderation
+ */
+class ModerationStateStatesTest extends ModerationStateTestBase {
+
+ /**
+ * Tests route access/permissions.
+ */
+ public function testAccess() {
+ $paths = [
+ 'admin/config/workflow/moderation',
+ 'admin/config/workflow/moderation/states',
+ 'admin/config/workflow/moderation/states/add',
+ 'admin/config/workflow/moderation/states/draft',
+ 'admin/config/workflow/moderation/states/draft/delete',
+ ];
+
+ foreach ($paths as $path) {
+ $this->drupalGet($path);
+ // No access.
+ $this->assertResponse(403);
+ }
+ $this->drupalLogin($this->adminUser);
+ foreach ($paths as $path) {
+ $this->drupalGet($path);
+ // User has access.
+ $this->assertResponse(200);
+ }
+ }
+
+ /**
+ * Tests administration of moderation state entity.
+ */
+ public function testStateAdministration() {
+ $this->drupalLogin($this->adminUser);
+ $this->drupalGet('admin/config/workflow/moderation');
+ $this->assertLink('Moderation states');
+ $this->assertLink('Moderation state transitions');
+ $this->clickLink('Moderation states');
+ $this->assertLink('Add Moderation state');
+ $this->assertText('Draft');
+ // Edit the draft.
+ $this->clickLink('Edit', 0);
+ $this->assertFieldByName('label', 'Draft');
+ $this->assertNoFieldChecked('edit-published');
+ $this->drupalPostForm(NULL, [
+ 'label' => 'Drafty',
+ ], t('Save'));
+ $this->assertText('Saved the Drafty Moderation state.');
+ $this->drupalGet('admin/config/workflow/moderation/states/draft');
+ $this->assertFieldByName('label', 'Drafty');
+ $this->drupalPostForm(NULL, [
+ 'label' => 'Draft',
+ ], t('Save'));
+ $this->assertText('Saved the Draft Moderation state.');
+ $this->clickLink(t('Add Moderation state'));
+ $this->drupalPostForm(NULL, [
+ 'label' => 'Expired',
+ 'id' => 'expired',
+ ], t('Save'));
+ $this->assertText('Created the Expired Moderation state.');
+ $this->drupalGet('admin/config/workflow/moderation/states/expired');
+ $this->clickLink('Delete');
+ $this->assertText('Are you sure you want to delete Expired?');
+ $this->drupalPostForm(NULL, [], t('Delete'));
+ $this->assertText('Moderation state Expired deleted');
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php
new file mode 100644
index 0000000..f03de12
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+use Drupal\Core\Session\AccountInterface;
+use Drupal\simpletest\WebTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\content_moderation\Entity\ModerationState;
+
+/**
+ * Defines a base class for moderation state tests.
+ */
+abstract class ModerationStateTestBase extends WebTestBase {
+
+ /**
+ * Profile to use.
+ */
+ protected $profile = 'testing';
+
+ /**
+ * Admin user.
+ *
+ * @var \Drupal\Core\Session\AccountInterface
+ */
+ protected $adminUser;
+
+ /**
+ * Permissions to grant admin user.
+ *
+ * @var array
+ */
+ protected $permissions = [
+ 'administer moderation states',
+ 'administer moderation state transitions',
+ 'use draft_draft transition',
+ 'use draft_published transition',
+ 'use published_draft transition',
+ 'use published_archived transition',
+ 'access administration pages',
+ 'administer content types',
+ 'administer nodes',
+ 'view latest version',
+ 'view any unpublished content',
+ 'access content overview',
+ ];
+
+ /**
+ * Modules to enable.
+ *
+ * @var array
+ */
+ public static $modules = [
+ 'content_moderation',
+ 'block',
+ 'block_content',
+ 'node',
+ ];
+
+ /**
+ * Sets the test up.
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->adminUser = $this->drupalCreateUser($this->permissions);
+ $this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']);
+ $this->drupalPlaceBlock('page_title_block');
+ $this->drupalPlaceBlock('local_actions_block', ['id' => 'actions_block']);
+ }
+
+ /**
+ * Creates a content-type from the UI.
+ *
+ * @param string $content_type_name
+ * Content type human name.
+ * @param string $content_type_id
+ * Machine name.
+ * @param bool $moderated
+ * TRUE if should be moderated.
+ * @param string[] $allowed_states
+ * Array of allowed state IDs.
+ * @param string $default_state
+ * Default state.
+ */
+ protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, array $allowed_states = [], $default_state = NULL) {
+ $this->drupalGet('admin/structure/types');
+ $this->clickLink('Add content type');
+ $edit = [
+ 'name' => $content_type_name,
+ 'type' => $content_type_id,
+ ];
+ $this->drupalPostForm(NULL, $edit, t('Save content type'));
+
+ if ($moderated) {
+ $this->enableModerationThroughUi($content_type_id, $allowed_states, $default_state);
+ }
+ }
+
+ /**
+ * Enable moderation for a specified content type, using the UI.
+ *
+ * @param string $content_type_id
+ * Machine name.
+ * @param string[] $allowed_states
+ * Array of allowed state IDs.
+ * @param string $default_state
+ * Default state.
+ */
+ protected function enableModerationThroughUi($content_type_id, array $allowed_states, $default_state) {
+ $this->drupalGet('admin/structure/types/manage/' . $content_type_id . '/moderation');
+ $this->assertFieldByName('enable_moderation_state');
+ $this->assertNoFieldChecked('edit-enable-moderation-state');
+
+ $edit['enable_moderation_state'] = 1;
+
+ /** @var ModerationState $state */
+ foreach (ModerationState::loadMultiple() as $state) {
+ $key = $state->isPublishedState() ? 'allowed_moderation_states_published[' . $state->id() . ']' : 'allowed_moderation_states_unpublished[' . $state->id() . ']';
+ $edit[$key] = in_array($state->id(), $allowed_states, TRUE) ? $state->id() : FALSE;
+ }
+
+ $edit['default_moderation_state'] = $default_state;
+
+ $this->drupalPostForm(NULL, $edit, t('Save'));
+ }
+
+ /**
+ * Grants given user permission to create content of given type.
+ *
+ * @param \Drupal\Core\Session\AccountInterface $account
+ * User to grant permission to.
+ * @param string $content_type_id
+ * Content type ID.
+ */
+ protected function grantUserPermissionToCreateContentOfType(AccountInterface $account, $content_type_id) {
+ $role_ids = $account->getRoles(TRUE);
+ /* @var \Drupal\user\RoleInterface $role */
+ $role_id = reset($role_ids);
+ $role = Role::load($role_id);
+ $role->grantPermission(sprintf('create %s content', $content_type_id));
+ $role->grantPermission(sprintf('edit any %s content', $content_type_id));
+ $role->grantPermission(sprintf('delete any %s content', $content_type_id));
+ $role->save();
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php
new file mode 100644
index 0000000..0495e48
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Tests moderation state transition config entity.
+ *
+ * @group content_moderation
+ */
+class ModerationStateTransitionsTest extends ModerationStateTestBase {
+
+ /**
+ * Tests route access/permissions.
+ */
+ public function testAccess() {
+ $paths = [
+ 'admin/config/workflow/moderation/transitions',
+ 'admin/config/workflow/moderation/transitions/add',
+ 'admin/config/workflow/moderation/transitions/draft_published',
+ 'admin/config/workflow/moderation/transitions/draft_published/delete',
+ ];
+
+ foreach ($paths as $path) {
+ $this->drupalGet($path);
+ // No access.
+ $this->assertResponse(403);
+ }
+ $this->drupalLogin($this->adminUser);
+ foreach ($paths as $path) {
+ $this->drupalGet($path);
+ // User has access.
+ $this->assertResponse(200);
+ }
+ }
+
+ /**
+ * Tests administration of moderation state transition entity.
+ */
+ public function testTransitionAdministration() {
+ $this->drupalLogin($this->adminUser);
+
+ $this->drupalGet('admin/config/workflow/moderation');
+ $this->clickLink('Moderation state transitions');
+ $this->assertLink('Add Moderation state transition');
+ $this->assertText('Create New Draft');
+
+ // Edit the Draft » Draft review.
+ $this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft');
+ $this->assertFieldByName('label', 'Create New Draft');
+ $this->assertFieldByName('stateFrom', 'draft');
+ $this->assertFieldByName('stateTo', 'draft');
+ $this->drupalPostForm(NULL, [
+ 'label' => 'Create Draft',
+ ], t('Save'));
+ $this->assertText('Saved the Create Draft Moderation state transition.');
+ $this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft');
+ $this->assertFieldByName('label', 'Create Draft');
+ // Now set it back.
+ $this->drupalPostForm(NULL, [
+ 'label' => 'Create New Draft',
+ ], t('Save'));
+ $this->assertText('Saved the Create New Draft Moderation state transition.');
+
+ // Add a new state.
+ $this->drupalGet('admin/config/workflow/moderation/states/add');
+ $this->drupalPostForm(NULL, [
+ 'label' => 'Expired',
+ 'id' => 'expired',
+ ], t('Save'));
+ $this->assertText('Created the Expired Moderation state.');
+
+ // Add a new transition.
+ $this->drupalGet('admin/config/workflow/moderation/transitions');
+ $this->clickLink(t('Add Moderation state transition'));
+ $this->drupalPostForm(NULL, [
+ 'label' => 'Published » Expired',
+ 'id' => 'published_expired',
+ 'stateFrom' => 'published',
+ 'stateTo' => 'expired',
+ ], t('Save'));
+ $this->assertText('Created the Published » Expired Moderation state transition.');
+
+ // Delete the new transition.
+ $this->drupalGet('admin/config/workflow/moderation/transitions/published_expired');
+ $this->clickLink('Delete');
+ $this->assertText('Are you sure you want to delete Published » Expired?');
+ $this->drupalPostForm(NULL, [], t('Delete'));
+ $this->assertText('Moderation transition Published » Expired deleted');
+ }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/NodeAccessTest.php b/core/modules/content_moderation/src/Tests/NodeAccessTest.php
new file mode 100644
index 0000000..1b05406
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/NodeAccessTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Tests permission access control around nodes.
+ *
+ * @group content_moderation
+ */
+class NodeAccessTest extends ModerationStateTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->drupalLogin($this->adminUser);
+ $this->createContentTypeFromUi(
+ 'Moderated content',
+ 'moderated_content',
+ TRUE,
+ ['draft', 'published'],
+ 'draft'
+ );
+ $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
+ }
+
+ /**
+ * Verifies that a non-admin user can still access the appropriate pages.
+ */
+ public function testPageAccess() {
+ $this->drupalLogin($this->adminUser);
+
+ // Create a node to test with.
+ $this->drupalPostForm('node/add/moderated_content', [
+ 'title[0][value]' => 'moderated content',
+ ], t('Save and Create New Draft'));
+ $nodes = \Drupal::entityTypeManager()
+ ->getStorage('node')
+ ->loadByProperties([
+ 'title' => 'moderated content',
+ ]);
+
+ if (!$nodes) {
+ $this->fail('Test node was not saved correctly.');
+ return;
+ }
+
+ /** @var \Drupal\node\NodeInterface $node */
+ $node = reset($nodes);
+
+ $view_path = 'node/' . $node->id();
+ $edit_path = 'node/' . $node->id() . '/edit';
+ $latest_path = 'node/' . $node->id() . '/latest';
+
+ // Publish the node.
+ $this->drupalPostForm($edit_path, [], t('Save and Publish'));
+
+ // Ensure access works correctly for anonymous users.
+ $this->drupalLogout();
+
+ $this->drupalGet($edit_path);
+ $this->assertResponse(403);
+
+ $this->drupalGet($latest_path);
+ $this->assertResponse(403);
+ $this->drupalGet($view_path);
+ $this->assertResponse(200);
+
+ // Create a forward revision for the 'Latest revision' tab.
+ $this->drupalLogin($this->adminUser);
+ $this->drupalPostForm($edit_path, [
+ 'title[0][value]' => 'moderated content revised',
+ ], t('Save and Create New Draft'));
+
+ // Now make a new user and verify that the new user's access is correct.
+ $user = $this->createUser([
+ 'use draft_draft transition',
+ 'use published_draft transition',
+ 'view latest version',
+ 'view any unpublished content',
+ ]);
+ $this->drupalLogin($user);
+
+ $this->drupalGet($edit_path);
+ $this->assertResponse(403);
+
+ $this->drupalGet($latest_path);
+ $this->assertResponse(200);
+ $this->drupalGet($view_path);
+ $this->assertResponse(200);
+
+ // Now make another user, who should not be able to see forward revisions.
+ $user = $this->createUser([
+ 'use published_draft transition',
+ ]);
+ $this->drupalLogin($user);
+
+ $this->drupalGet($edit_path);
+ $this->assertResponse(403);
+
+ $this->drupalGet($latest_path);
+ $this->assertResponse(403);
+ $this->drupalGet($view_path);
+ $this->assertResponse(200);
+ }
+
+}
diff --git a/core/modules/content_moderation/src/ViewsData.php b/core/modules/content_moderation/src/ViewsData.php
new file mode 100644
index 0000000..cad1187
--- /dev/null
+++ b/core/modules/content_moderation/src/ViewsData.php
@@ -0,0 +1,260 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Provides the content_moderation views integration.
+ */
+class ViewsData {
+
+ use StringTranslationTrait;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The moderation information.
+ *
+ * @var \Drupal\content_moderation\ModerationInformationInterface
+ */
+ protected $moderationInformation;
+
+ /**
+ * Creates a new ViewsData instance.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+ * The moderation information.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_information) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->moderationInformation = $moderation_information;
+ }
+
+ /**
+ * Returns the views data.
+ *
+ * @return array
+ * The views data.
+ */
+ public function getViewsData() {
+ $data = [];
+
+ $data['content_revision_tracker']['table']['group'] = $this->t('Content moderation (tracker)');
+
+ $data['content_revision_tracker']['entity_type'] = [
+ 'title' => $this->t('Entity type'),
+ 'field' => [
+ 'id' => 'standard',
+ ],
+ 'filter' => [
+ 'id' => 'string',
+ ],
+ 'argument' => [
+ 'id' => 'string',
+ ],
+ 'sort' => [
+ 'id' => 'standard',
+ ],
+ ];
+
+ $data['content_revision_tracker']['entity_id'] = [
+ 'title' => $this->t('Entity ID'),
+ 'field' => [
+ 'id' => 'standard',
+ ],
+ 'filter' => [
+ 'id' => 'numeric',
+ ],
+ 'argument' => [
+ 'id' => 'numeric',
+ ],
+ 'sort' => [
+ 'id' => 'standard',
+ ],
+ ];
+
+ $data['content_revision_tracker']['langcode'] = [
+ 'title' => $this->t('Entity language'),
+ 'field' => [
+ 'id' => 'standard',
+ ],
+ 'filter' => [
+ 'id' => 'language',
+ ],
+ 'argument' => [
+ 'id' => 'language',
+ ],
+ 'sort' => [
+ 'id' => 'standard',
+ ],
+ ];
+
+ $data['content_revision_tracker']['revision_id'] = [
+ 'title' => $this->t('Latest revision ID'),
+ 'field' => [
+ 'id' => 'standard',
+ ],
+ 'filter' => [
+ 'id' => 'numeric',
+ ],
+ 'argument' => [
+ 'id' => 'numeric',
+ ],
+ 'sort' => [
+ 'id' => 'standard',
+ ],
+ ];
+
+ // Add a join for each entity type to the content_revision_tracker table.
+ foreach ($this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions()) as $entity_type_id => $entity_type) {
+ /** @var \Drupal\views\EntityViewsDataInterface $views_data */
+ // We need the views_data handler in order to get the table name later.
+ if ($this->entityTypeManager->hasHandler($entity_type_id, 'views_data') && $views_data = $this->entityTypeManager->getHandler($entity_type_id, 'views_data')) {
+ // Add a join from the entity base table to the revision tracker table.
+ $base_table = $views_data->getViewsTableForEntityType($entity_type);
+ $data['content_revision_tracker']['table']['join'][$base_table] = [
+ 'left_field' => $entity_type->getKey('id'),
+ 'field' => 'entity_id',
+ 'extra' => [
+ [
+ 'field' => 'entity_type',
+ 'value' => $entity_type_id,
+ ],
+ ],
+ ];
+
+ // Some entity types might not be translatable.
+ if ($entity_type->hasKey('langcode')) {
+ $data['content_revision_tracker']['table']['join'][$base_table]['extra'][] = [
+ 'field' => 'langcode',
+ 'left_field' => $entity_type->getKey('langcode'),
+ 'operation' => '=',
+ ];
+ }
+
+ // Add a relationship between the revision tracker table to the latest
+ // revision on the entity revision table.
+ $data['content_revision_tracker']['latest_revision__' . $entity_type_id] = [
+ 'title' => $this->t('@label latest revision', ['@label' => $entity_type->getLabel()]),
+ 'group' => $this->t('@label revision', ['@label' => $entity_type->getLabel()]),
+ 'relationship' => [
+ 'id' => 'standard',
+ 'label' => $this->t('@label latest revision', ['@label' => $entity_type->getLabel()]),
+ 'base' => $this->getRevisionViewsTableForEntityType($entity_type),
+ 'base field' => $entity_type->getKey('revision'),
+ 'relationship field' => 'revision_id',
+ 'extra' => [
+ [
+ 'left_field' => 'entity_type',
+ 'value' => $entity_type_id,
+ ],
+ ],
+ ],
+ ];
+
+ // Some entity types might not be translatable.
+ if ($entity_type->hasKey('langcode')) {
+ $data['content_revision_tracker']['latest_revision__' . $entity_type_id]['relationship']['extra'][] = [
+ 'left_field' => 'langcode',
+ 'field' => $entity_type->getKey('langcode'),
+ 'operation' => '=',
+ ];
+ }
+ }
+ }
+
+ // Provides a relationship from moderated entity to its moderation state
+ // entity.
+ $content_moderation_state_entity_type = \Drupal::entityTypeManager()->getDefinition('content_moderation_state');
+ $content_moderation_state_entity_base_table = $content_moderation_state_entity_type->getDataTable() ?: $content_moderation_state_entity_type->getBaseTable();
+ $content_moderation_state_entity_revision_base_table = $content_moderation_state_entity_type->getRevisionDataTable() ?: $content_moderation_state_entity_type->getRevisionTable();
+ foreach ($this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions()) as $entity_type_id => $entity_type) {
+ $table = $entity_type->getDataTable() ?: $entity_type->getBaseTable();
+
+ $data[$table]['moderation_state'] = [
+ 'title' => t('Moderation state'),
+ 'relationship' => [
+ 'id' => 'standard',
+ 'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
+ 'base' => $content_moderation_state_entity_base_table,
+ 'base field' => 'content_entity_id',
+ 'relationship field' => $entity_type->getKey('id'),
+ 'join_extra' => [
+ [
+ 'field' => 'content_entity_type_id',
+ 'value' => $entity_type_id,
+ ],
+ [
+ 'field' => 'content_entity_revision_id',
+ 'left_field' => $entity_type->getKey('revision'),
+ ],
+ ],
+ ],
+ ];
+
+ $revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
+ $data[$revision_table]['moderation_state'] = [
+ 'title' => t('Moderation state'),
+ 'relationship' => [
+ 'id' => 'standard',
+ 'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
+ 'base' => $content_moderation_state_entity_revision_base_table,
+ 'base field' => 'content_entity_revision_id',
+ 'relationship field' => $entity_type->getKey('revision'),
+ 'join_extra' => [
+ [
+ 'field' => 'content_entity_type_id',
+ 'value' => $entity_type_id,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ return $data;
+ }
+
+ /**
+ * Alters the table and field information from hook_views_data().
+ *
+ * @param array $data
+ * An array of all information about Views tables and fields, collected from
+ * hook_views_data(), passed by reference.
+ *
+ * @see hook_views_data()
+ */
+ public function alterViewsData(array &$data) {
+ $revisionable_types = $this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions());
+ foreach ($revisionable_types as $type) {
+ $data[$type->getRevisionTable()]['latest_revision'] = [
+ 'title' => t('Is Latest Revision'),
+ 'help' => t('Restrict the view to only revisions that are the latest revision of their entity.'),
+ 'filter' => ['id' => 'latest_revision'],
+ ];
+ }
+ }
+
+ /**
+ * Gets the table of an entity type to be used as revision table in views.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type.
+ *
+ * @return string
+ * The revision base table.
+ */
+ protected function getRevisionViewsTableForEntityType(EntityTypeInterface $entity_type) {
+ return $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
+ }
+
+}
diff --git a/core/modules/content_moderation/templates/entity-moderation-form.html.twig b/core/modules/content_moderation/templates/entity-moderation-form.html.twig
new file mode 100644
index 0000000..403f5f0
--- /dev/null
+++ b/core/modules/content_moderation/templates/entity-moderation-form.html.twig
@@ -0,0 +1,8 @@
+{{ attach_library('content_moderation/entity-moderation-form') }}
+<ul class="entity-moderation-form">
+ <li>{{ form.current }}</li>
+ <li>{{ form.new_state }}</li>
+ <li>{{ form.revision_log }}</li>
+ <li>{{ form.submit }}</li>
+</ul>
+{{ form|without('current', 'new_state', 'revision_log', 'submit') }}
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml
new file mode 100644
index 0000000..46a64ab
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml
@@ -0,0 +1,409 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - system.menu.main
+ module:
+ - content_moderation
+ - user
+id: latest
+label: Latest
+module: views
+description: ''
+tag: ''
+base_table: node_field_revision
+base_field: vid
+core: 8.x
+display:
+ default:
+ display_plugin: default
+ id: default
+ display_title: Master
+ position: 0
+ display_options:
+ access:
+ type: perm
+ options:
+ perm: 'view all revisions'
+ cache:
+ type: tag
+ options: { }
+ query:
+ type: views_query
+ options:
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_comment: ''
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Apply
+ reset_button: false
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: full
+ options:
+ items_per_page: 10
+ offset: 0
+ id: 0
+ total_pages: null
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ tags:
+ previous: '‹ Previous'
+ next: 'Next ›'
+ first: '« First'
+ last: 'Last »'
+ quantity: 9
+ style:
+ type: table
+ row:
+ type: fields
+ fields:
+ nid:
+ id: nid
+ table: node_field_revision
+ field: nid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: 'Node ID'
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: true
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: number_integer
+ settings:
+ thousand_separator: ''
+ prefix_suffix: true
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: node
+ entity_field: nid
+ plugin_id: field
+ vid:
+ id: vid
+ table: node_field_revision
+ field: vid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: 'Revision ID'
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: true
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: number_integer
+ settings:
+ thousand_separator: ''
+ prefix_suffix: true
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: node
+ entity_field: vid
+ plugin_id: field
+ title:
+ id: title
+ table: node_field_revision
+ field: title
+ entity_type: node
+ entity_field: title
+ alter:
+ alter_text: false
+ make_link: false
+ absolute: false
+ trim: false
+ word_boundary: false
+ ellipsis: false
+ strip_tags: false
+ html: false
+ hide_empty: false
+ empty_zero: false
+ settings:
+ link_to_entity: false
+ plugin_id: field
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: Title
+ exclude: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: true
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_alter_empty: true
+ click_sort_column: value
+ type: string
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ moderation_state:
+ id: moderation_state
+ table: content_moderation_state_field_revision
+ field: moderation_state
+ relationship: moderation_state
+ group_type: group
+ admin_label: ''
+ label: 'Moderation state'
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: true
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: target_id
+ type: entity_reference_label
+ settings:
+ link: true
+ group_column: target_id
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: content_moderation_state
+ entity_field: moderation_state
+ plugin_id: field
+ filters:
+ latest_revision:
+ id: latest_revision
+ table: node_revision
+ field: latest_revision
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: '='
+ value: ''
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ entity_type: node
+ plugin_id: latest_revision
+ sorts: { }
+ title: Latest
+ header: { }
+ footer: { }
+ empty: { }
+ relationships:
+ moderation_state:
+ id: moderation_state
+ table: node_field_revision
+ field: moderation_state
+ relationship: none
+ group_type: group
+ admin_label: 'Content moderation state'
+ required: false
+ entity_type: node
+ plugin_id: standard
+ arguments: { }
+ display_extenders: { }
+ cache_metadata:
+ max-age: 0
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url.query_args
+ - 'user.node_grants:view'
+ - user.permissions
+ tags: { }
+ page_1:
+ display_plugin: page
+ id: page_1
+ display_title: Page
+ position: 1
+ display_options:
+ display_extenders: { }
+ path: latest
+ menu:
+ type: normal
+ title: Drafts
+ description: ''
+ expanded: false
+ parent: ''
+ weight: 0
+ context: '0'
+ menu_name: main
+ cache_metadata:
+ max-age: 0
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url.query_args
+ - 'user.node_grants:view'
+ - user.permissions
+ tags: { }
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml
new file mode 100644
index 0000000..6f95251
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml
@@ -0,0 +1,406 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - content_moderation
+ - node
+ - user
+id: test_content_moderation_base_table_test
+label: test_content_moderation_base_table_test
+module: views
+description: ''
+tag: ''
+base_table: node_field_data
+base_field: nid
+core: 8.x
+display:
+ default:
+ display_plugin: default
+ id: default
+ display_title: Master
+ position: 0
+ display_options:
+ access:
+ type: perm
+ options:
+ perm: 'access content'
+ cache:
+ type: tag
+ options: { }
+ query:
+ type: views_query
+ options:
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_comment: ''
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Apply
+ reset_button: false
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: mini
+ options:
+ items_per_page: 10
+ offset: 0
+ id: 0
+ total_pages: null
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ tags:
+ previous: ‹‹
+ next: ››
+ style:
+ type: default
+ options:
+ grouping: { }
+ row_class: ''
+ default_row_class: true
+ uses_fields: false
+ row:
+ type: fields
+ options:
+ inline: { }
+ separator: ''
+ hide_empty: false
+ default_field_elements: true
+ fields:
+ nid:
+ id: nid
+ table: node_field_data
+ field: nid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: number_integer
+ settings:
+ thousand_separator: ''
+ prefix_suffix: true
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: node
+ entity_field: nid
+ plugin_id: field
+ moderation_state:
+ id: moderation_state
+ table: content_moderation_state_field_data
+ field: moderation_state
+ relationship: moderation_state
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: target_id
+ type: entity_reference_label
+ settings:
+ link: false
+ group_column: target_id
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: content_moderation_state
+ entity_field: moderation_state
+ plugin_id: field
+ moderation_state_1:
+ id: moderation_state_1
+ table: content_moderation_state_field_revision
+ field: moderation_state
+ relationship: moderation_state
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: target_id
+ type: entity_reference_label
+ settings:
+ link: false
+ group_column: target_id
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: content_moderation_state
+ entity_field: moderation_state
+ plugin_id: field
+ moderation_state_2:
+ id: moderation_state_2
+ table: content_moderation_state_field_revision
+ field: moderation_state
+ relationship: moderation_state_1
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: target_id
+ type: entity_reference_entity_id
+ settings: { }
+ group_column: target_id
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: content_moderation_state
+ entity_field: moderation_state
+ plugin_id: field
+ filters: { }
+ sorts:
+ created:
+ id: created
+ table: node_field_data
+ field: created
+ order: DESC
+ entity_type: node
+ entity_field: created
+ plugin_id: date
+ relationship: none
+ group_type: group
+ admin_label: ''
+ exposed: false
+ expose:
+ label: ''
+ granularity: second
+ vid:
+ id: vid
+ table: node_field_data
+ field: vid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ order: ASC
+ exposed: false
+ expose:
+ label: ''
+ entity_type: node
+ entity_field: vid
+ plugin_id: standard
+ header: { }
+ footer: { }
+ empty: { }
+ relationships:
+ moderation_state:
+ id: moderation_state
+ table: node_field_data
+ field: moderation_state
+ relationship: none
+ group_type: group
+ admin_label: 'Content moderation state'
+ required: false
+ entity_type: node
+ plugin_id: standard
+ moderation_state_1:
+ id: moderation_state_1
+ table: node_field_revision
+ field: moderation_state
+ relationship: none
+ group_type: group
+ admin_label: 'Content moderation state (revision)'
+ required: false
+ entity_type: node
+ plugin_id: standard
+ arguments: { }
+ display_extenders: { }
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url.query_args
+ - 'user.node_grants:view'
+ - user.permissions
+ tags: { }
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml
new file mode 100644
index 0000000..7673394
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml
@@ -0,0 +1,447 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - node
+ - user
+id: test_content_moderation_latest_revision
+label: test_content_moderation_latest_revision
+module: views
+description: ''
+tag: ''
+base_table: node_field_data
+base_field: nid
+core: 8.x
+display:
+ default:
+ display_plugin: default
+ id: default
+ display_title: Master
+ position: 0
+ display_options:
+ access:
+ type: perm
+ options:
+ perm: 'access content'
+ cache:
+ type: tag
+ options: { }
+ query:
+ type: views_query
+ options:
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_comment: ''
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Apply
+ reset_button: false
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: mini
+ options:
+ items_per_page: 10
+ offset: 0
+ id: 0
+ total_pages: null
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ tags:
+ previous: ‹‹
+ next: ››
+ style:
+ type: default
+ options:
+ grouping: { }
+ row_class: ''
+ default_row_class: true
+ uses_fields: false
+ row:
+ type: fields
+ options:
+ inline: { }
+ separator: ''
+ hide_empty: false
+ default_field_elements: true
+ fields:
+ nid:
+ id: nid
+ table: node_field_data
+ field: nid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: number_integer
+ settings:
+ thousand_separator: ''
+ prefix_suffix: true
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: node
+ entity_field: nid
+ plugin_id: field
+ revision_id:
+ id: revision_id
+ table: content_revision_tracker
+ field: revision_id
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ plugin_id: standard
+ title:
+ id: title
+ table: node_field_revision
+ field: title
+ relationship: latest_revision__node
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: string
+ settings:
+ link_to_entity: false
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: node
+ entity_field: title
+ plugin_id: field
+ moderation_state:
+ id: moderation_state
+ table: content_moderation_state_field_revision
+ field: moderation_state
+ relationship: moderation_state
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: target_id
+ type: entity_reference_entity_id
+ settings: { }
+ group_column: target_id
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: content_moderation_state
+ entity_field: moderation_state
+ plugin_id: field
+ moderation_state_1:
+ id: moderation_state_1
+ table: content_moderation_state_field_revision
+ field: moderation_state
+ relationship: moderation_state_1
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: target_id
+ type: entity_reference_entity_id
+ settings: { }
+ group_column: target_id
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: content_moderation_state
+ entity_field: moderation_state
+ plugin_id: field
+ filters: { }
+ sorts:
+ nid:
+ id: nid
+ table: node_field_data
+ field: nid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ order: ASC
+ exposed: false
+ expose:
+ label: ''
+ entity_type: node
+ entity_field: nid
+ plugin_id: standard
+ header: { }
+ footer: { }
+ empty: { }
+ relationships:
+ latest_revision__node:
+ id: latest_revision__node
+ table: content_revision_tracker
+ field: latest_revision__node
+ relationship: none
+ group_type: group
+ admin_label: 'Content latest revision'
+ required: false
+ plugin_id: standard
+ moderation_state_1:
+ id: moderation_state_1
+ table: node_field_revision
+ field: moderation_state
+ relationship: latest_revision__node
+ group_type: group
+ admin_label: 'Content moderation state (latest revision)'
+ required: false
+ entity_type: node
+ plugin_id: standard
+ moderation_state:
+ id: moderation_state
+ table: node_field_revision
+ field: moderation_state
+ relationship: none
+ group_type: group
+ admin_label: 'Content moderation state'
+ required: false
+ entity_type: node
+ plugin_id: standard
+ arguments: { }
+ display_extenders: { }
+ rendering_language: '***LANGUAGE_entity_default***'
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_interface'
+ - url.query_args
+ - 'user.node_grants:view'
+ - user.permissions
+ tags: { }
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml
new file mode 100644
index 0000000..2362098
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml
@@ -0,0 +1,315 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - user
+id: test_content_moderation_revision_test
+label: test_content_moderation_revision_test
+module: views
+description: ''
+tag: ''
+base_table: node_field_revision
+base_field: vid
+core: 8.x
+display:
+ default:
+ display_plugin: default
+ id: default
+ display_title: Master
+ position: 0
+ display_options:
+ access:
+ type: perm
+ options:
+ perm: 'view all revisions'
+ cache:
+ type: tag
+ options: { }
+ query:
+ type: views_query
+ options:
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_comment: ''
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Apply
+ reset_button: false
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: mini
+ options:
+ items_per_page: 10
+ offset: 0
+ id: 0
+ total_pages: null
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ tags:
+ previous: ‹‹
+ next: ››
+ style:
+ type: default
+ options:
+ grouping: { }
+ row_class: ''
+ default_row_class: true
+ uses_fields: false
+ row:
+ type: fields
+ options:
+ inline: { }
+ separator: ''
+ hide_empty: false
+ default_field_elements: true
+ fields:
+ vid:
+ id: vid
+ table: node_field_revision
+ field: vid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: number_integer
+ settings:
+ thousand_separator: ''
+ prefix_suffix: true
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: node
+ entity_field: vid
+ plugin_id: field
+ moderation_state:
+ id: moderation_state
+ table: content_moderation_state_field_revision
+ field: moderation_state
+ relationship: moderation_state
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: target_id
+ type: entity_reference_entity_id
+ settings: { }
+ group_column: target_id
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: content_moderation_state
+ entity_field: moderation_state
+ plugin_id: field
+ revision_id:
+ id: revision_id
+ table: content_moderation_state_field_revision
+ field: revision_id
+ relationship: moderation_state
+ group_type: group
+ admin_label: ''
+ label: ''
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: number_integer
+ settings:
+ thousand_separator: ''
+ prefix_suffix: true
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ entity_type: content_moderation_state
+ entity_field: revision_id
+ plugin_id: field
+ filters: { }
+ sorts:
+ vid:
+ id: vid
+ table: node_field_revision
+ field: vid
+ relationship: none
+ group_type: group
+ admin_label: ''
+ order: ASC
+ exposed: false
+ expose:
+ label: ''
+ entity_type: node
+ entity_field: vid
+ plugin_id: standard
+ header: { }
+ footer: { }
+ empty: { }
+ relationships:
+ moderation_state:
+ id: moderation_state
+ table: node_field_revision
+ field: moderation_state
+ relationship: none
+ group_type: group
+ admin_label: 'Content moderation state'
+ required: false
+ entity_type: node
+ plugin_id: standard
+ arguments: { }
+ display_extenders: { }
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url.query_args
+ - 'user.node_grants:view'
+ - user.permissions
+ tags: { }
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml
new file mode 100644
index 0000000..b96ef84
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml
@@ -0,0 +1,10 @@
+name: 'Content moderation test views'
+type: module
+description: 'Provides default views for views Content moderation tests.'
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+ - content_moderation
+ - node
+ - views
diff --git a/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php
new file mode 100644
index 0000000..77ae046
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Functional;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests the "Latest Revision" views filter.
+ *
+ * @group content_moderation
+ */
+class LatestRevisionViewsFilterTest extends BrowserTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'content_moderation_test_views',
+ 'content_moderation',
+ ];
+
+ /**
+ * Tests view shows the correct node IDs.
+ */
+ public function testViewShowsCorrectNids() {
+ $node_type = $this->createNodeType('Test', 'test');
+
+ $permissions = [
+ 'access content',
+ 'view all revisions',
+ ];
+ $editor1 = $this->drupalCreateUser($permissions);
+
+ $this->drupalLogin($editor1);
+
+ // Make a pre-moderation node.
+ /** @var Node $node_0 */
+ $node_0 = Node::create([
+ 'type' => 'test',
+ 'title' => 'Node 0 - Rev 1',
+ 'uid' => $editor1->id(),
+ ]);
+ $node_0->save();
+
+ // Now enable moderation for subsequent nodes.
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->save();
+
+ // Make a node that is only ever in Draft.
+ /** @var Node $node_1 */
+ $node_1 = Node::create([
+ 'type' => 'test',
+ 'title' => 'Node 1 - Rev 1',
+ 'uid' => $editor1->id(),
+ ]);
+ $node_1->moderation_state->target_id = 'draft';
+ $node_1->save();
+
+ // Make a node that is in Draft, then Published.
+ /** @var Node $node_2 */
+ $node_2 = Node::create([
+ 'type' => 'test',
+ 'title' => 'Node 2 - Rev 1',
+ 'uid' => $editor1->id(),
+ ]);
+ $node_2->moderation_state->target_id = 'draft';
+ $node_2->save();
+
+ $node_2->setTitle('Node 2 - Rev 2');
+ $node_2->moderation_state->target_id = 'published';
+ $node_2->save();
+
+ // Make a node that is in Draft, then Published, then Draft.
+ /** @var Node $node_3 */
+ $node_3 = Node::create([
+ 'type' => 'test',
+ 'title' => 'Node 3 - Rev 1',
+ 'uid' => $editor1->id(),
+ ]);
+ $node_3->moderation_state->target_id = 'draft';
+ $node_3->save();
+
+ $node_3->setTitle('Node 3 - Rev 2');
+ $node_3->moderation_state->target_id = 'published';
+ $node_3->save();
+
+ $node_3->setTitle('Node 3 - Rev 3');
+ $node_3->moderation_state->target_id = 'draft';
+ $node_3->save();
+
+ // Now show the View, and confirm that only the correct titles are showing.
+ $this->drupalGet('/latest');
+ $page = $this->getSession()->getPage();
+ $this->assertEquals(200, $this->getSession()->getStatusCode());
+ $this->assertTrue($page->hasContent('Node 1 - Rev 1'));
+ $this->assertTrue($page->hasContent('Node 2 - Rev 2'));
+ $this->assertTrue($page->hasContent('Node 3 - Rev 3'));
+ $this->assertFalse($page->hasContent('Node 2 - Rev 1'));
+ $this->assertFalse($page->hasContent('Node 3 - Rev 1'));
+ $this->assertFalse($page->hasContent('Node 3 - Rev 2'));
+ $this->assertFalse($page->hasContent('Node 0 - Rev 1'));
+ }
+
+ /**
+ * Creates a new node type.
+ *
+ * @param string $label
+ * The human-readable label of the type to create.
+ * @param string $machine_name
+ * The machine name of the type to create.
+ *
+ * @return NodeType
+ * The node type just created.
+ */
+ protected function createNodeType($label, $machine_name) {
+ /** @var NodeType $node_type */
+ $node_type = NodeType::create([
+ 'type' => $machine_name,
+ 'label' => $label,
+ ]);
+ $node_type->save();
+
+ return $node_type;
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php
new file mode 100644
index 0000000..799d89a
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Functional;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests the view access control handler for moderation state entities.
+ *
+ * @group content_moderation
+ */
+class ModerationStateAccessTest extends BrowserTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'content_moderation_test_views',
+ 'content_moderation',
+ ];
+
+ /**
+ * Test the view operation access handler with the view permission.
+ */
+ public function testViewShowsCorrectStates() {
+ $node_type_id = 'test';
+ $this->createNodeType('Test', $node_type_id);
+
+ $permissions = [
+ 'access content',
+ 'view all revisions',
+ 'view moderation states',
+ ];
+ $editor1 = $this->drupalCreateUser($permissions);
+ $this->drupalLogin($editor1);
+
+ $node_1 = Node::create([
+ 'type' => $node_type_id,
+ 'title' => 'Draft node',
+ 'uid' => $editor1->id(),
+ ]);
+ $node_1->moderation_state->target_id = 'draft';
+ $node_1->save();
+
+ $node_2 = Node::create([
+ 'type' => $node_type_id,
+ 'title' => 'Published node',
+ 'uid' => $editor1->id(),
+ ]);
+ $node_2->moderation_state->target_id = 'published';
+ $node_2->save();
+
+ // Resave the node with a new state.
+ $node_2->setTitle('Archived node');
+ $node_2->moderation_state->target_id = 'archived';
+ $node_2->save();
+
+ // Now show the View, and confirm that the state labels are showing.
+ $this->drupalGet('/latest');
+ $page = $this->getSession()->getPage();
+ $this->assertTrue($page->hasLink('Draft'));
+ $this->assertTrue($page->hasLink('Archived'));
+ $this->assertFalse($page->hasLink('Published'));
+
+ // Now log in as an admin and test the same thing.
+ $permissions = [
+ 'access content',
+ 'view all revisions',
+ 'administer moderation states',
+ ];
+ $admin1 = $this->drupalCreateUser($permissions);
+ $this->drupalLogin($admin1);
+
+ $this->drupalGet('/latest');
+ $page = $this->getSession()->getPage();
+ $this->assertEquals(200, $this->getSession()->getStatusCode());
+ $this->assertTrue($page->hasLink('Draft'));
+ $this->assertTrue($page->hasLink('Archived'));
+ $this->assertFalse($page->hasLink('Published'));
+ }
+
+ /**
+ * Creates a new node type.
+ *
+ * @param string $label
+ * The human-readable label of the type to create.
+ * @param string $machine_name
+ * The machine name of the type to create.
+ *
+ * @return NodeType
+ * The node type just created.
+ */
+ protected function createNodeType($label, $machine_name) {
+ /** @var NodeType $node_type */
+ $node_type = NodeType::create([
+ 'type' => $machine_name,
+ 'label' => $label,
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->save();
+
+ return $node_type;
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php
new file mode 100644
index 0000000..8b382c1
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\block_content\Entity\BlockContentType;
+use Drupal\config\Tests\SchemaCheckTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\NodeType;
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+
+/**
+ * Ensures that content moderation schema is correct.
+ *
+ * @group content_moderation
+ */
+class ContentModerationSchemaTest extends KernelTestBase {
+
+ use SchemaCheckTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'content_moderation',
+ 'node',
+ 'user',
+ 'block_content',
+ 'system',
+ ];
+
+ /**
+ * Tests content moderation default schema.
+ */
+ public function testContentModerationDefaultConfig() {
+ $this->installConfig(['content_moderation']);
+ $typed_config = \Drupal::service('config.typed');
+ $moderation_states = ModerationState::loadMultiple();
+ foreach ($moderation_states as $moderation_state) {
+ $this->assertConfigSchema($typed_config, $moderation_state->getEntityType()->getConfigPrefix() . '.' . $moderation_state->id(), $moderation_state->toArray());
+ }
+ $moderation_state_transitions = ModerationStateTransition::loadMultiple();
+ foreach ($moderation_state_transitions as $moderation_state_transition) {
+ $this->assertConfigSchema($typed_config, $moderation_state_transition->getEntityType()->getConfigPrefix() . '.' . $moderation_state_transition->id(), $moderation_state_transition->toArray());
+ }
+
+ }
+
+ /**
+ * Tests content moderation third party schema for node types.
+ */
+ public function testContentModerationNodeTypeConfig() {
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('user');
+ $this->installConfig(['content_moderation']);
+ $typed_config = \Drupal::service('config.typed');
+ $moderation_states = ModerationState::loadMultiple();
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states));
+ $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', '');
+ $node_type->save();
+ $this->assertConfigSchema($typed_config, $node_type->getEntityType()->getConfigPrefix() . '.' . $node_type->id(), $node_type->toArray());
+ }
+
+ /**
+ * Tests content moderation third party schema for block content types.
+ */
+ public function testContentModerationBlockContentTypeConfig() {
+ $this->installEntitySchema('block_content');
+ $this->installEntitySchema('user');
+ $this->installConfig(['content_moderation']);
+ $typed_config = \Drupal::service('config.typed');
+ $moderation_states = ModerationState::loadMultiple();
+ $block_content_type = BlockContentType::create([
+ 'id' => 'basic',
+ 'label' => 'basic',
+ 'revision' => TRUE,
+ ]);
+ $block_content_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $block_content_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states));
+ $block_content_type->setThirdPartySetting('content_moderation', 'default_moderation_state', '');
+ $block_content_type->save();
+ $this->assertConfigSchema($typed_config, $block_content_type->getEntityType()->getConfigPrefix() . '.' . $block_content_type->id(), $block_content_type->toArray());
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php
new file mode 100644
index 0000000..c76e651
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php
@@ -0,0 +1,234 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\content_moderation\Entity\ContentModerationState;
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\node\NodeInterface;
+
+/**
+ * Tests links between a content entity and a content_moderation_state entity.
+ *
+ * @group content_moderation
+ */
+class ContentModerationStateTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'node',
+ 'content_moderation',
+ 'user',
+ 'system',
+ 'language',
+ 'content_translation',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->installSchema('node', 'node_access');
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('user');
+ $this->installEntitySchema('content_moderation_state');
+ $this->installConfig('content_moderation');
+ }
+
+ /**
+ * Tests basic monolingual content moderation through the API.
+ */
+ public function testBasicModeration() {
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']);
+ $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft');
+ $node_type->save();
+ $node = Node::create([
+ 'type' => 'example',
+ 'title' => 'Test title',
+ ]);
+ $node->save();
+ $node = $this->reloadNode($node);
+ $this->assertEquals('draft', $node->moderation_state->entity->id());
+
+ $published = ModerationState::load('published');
+ $node->moderation_state->entity = $published;
+ $node->save();
+
+ $node = $this->reloadNode($node);
+ $this->assertEquals('published', $node->moderation_state->entity->id());
+
+ // Change the state without saving the node.
+ $content_moderation_state = ContentModerationState::load(1);
+ $content_moderation_state->set('moderation_state', 'draft');
+ $content_moderation_state->setNewRevision(TRUE);
+ $content_moderation_state->save();
+
+ $node = $this->reloadNode($node, 3);
+ $this->assertEquals('draft', $node->moderation_state->entity->id());
+ $this->assertFalse($node->isPublished());
+
+ // Get the default revision.
+ $node = $this->reloadNode($node);
+ $this->assertTrue($node->isPublished());
+ $this->assertEquals(2, $node->getRevisionId());
+
+ $node->moderation_state->target_id = 'published';
+ $node->save();
+
+ $node = $this->reloadNode($node, 4);
+ $this->assertEquals('published', $node->moderation_state->entity->id());
+
+ // Get the default revision.
+ $node = $this->reloadNode($node);
+ $this->assertTrue($node->isPublished());
+ $this->assertEquals(4, $node->getRevisionId());
+
+ }
+
+ /**
+ * Tests basic multilingual content moderation through the API.
+ */
+ public function testMultilingualModeration() {
+ // Enable French.
+ ConfigurableLanguage::createFromLangcode('fr')->save();
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']);
+ $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft');
+ $node_type->save();
+ $english_node = Node::create([
+ 'type' => 'example',
+ 'title' => 'Test title',
+ ]);
+ // Revision 1 (en).
+ $english_node
+ ->setPublished(FALSE)
+ ->save();
+ $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+ $this->assertFalse($english_node->isPublished());
+
+ // Create a French translation.
+ $french_node = $english_node->addTranslation('fr', ['title' => 'French title']);
+ $french_node->setPublished(FALSE);
+ // Revision 1 (fr).
+ $french_node->save();
+ $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+ $this->assertEquals('draft', $french_node->moderation_state->entity->id());
+ $this->assertFalse($french_node->isPublished());
+
+ // Move English node to create another draft.
+ $english_node = $this->reloadNode($english_node);
+ $english_node->moderation_state->target_id = 'draft';
+ // Revision 2 (en, fr).
+ $english_node->save();
+ $english_node = $this->reloadNode($english_node);
+ $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+
+ // French node should still be in draft.
+ $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+ $this->assertEquals('draft', $french_node->moderation_state->entity->id());
+
+ // Publish the French node.
+ $french_node->moderation_state->target_id = 'published';
+ // Revision 3 (en, fr).
+ $french_node->save();
+ $french_node = $this->reloadNode($french_node)->getTranslation('fr');
+ $this->assertTrue($french_node->isPublished());
+ $this->assertEquals('published', $french_node->moderation_state->entity->id());
+ $this->assertTrue($french_node->isPublished());
+ $english_node = $french_node->getTranslation('en');
+ $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+
+ // Publish the English node.
+ $english_node->moderation_state->target_id = 'published';
+ // Revision 4 (en, fr).
+ $english_node->save();
+ $english_node = $this->reloadNode($english_node);
+ $this->assertTrue($english_node->isPublished());
+
+ // Move the French node back to draft.
+ $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+ $this->assertTrue($french_node->isPublished());
+ $french_node->moderation_state->target_id = 'draft';
+ // Revision 5 (en, fr).
+ $french_node->save();
+ $french_node = $this->reloadNode($english_node, 5)->getTranslation('fr');
+ $this->assertFalse($french_node->isPublished());
+ $this->assertTrue($french_node->getTranslation('en')->isPublished());
+
+ // Republish the French node.
+ $french_node->moderation_state->target_id = 'published';
+ // Revision 6 (en, fr).
+ $french_node->save();
+ $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+ $this->assertTrue($french_node->isPublished());
+
+ // Change the EN state without saving the node.
+ $content_moderation_state = ContentModerationState::load(1);
+ $content_moderation_state->set('moderation_state', 'draft');
+ $content_moderation_state->setNewRevision(TRUE);
+ // Revision 7 (en, fr).
+ $content_moderation_state->save();
+ $english_node = $this->reloadNode($french_node, $french_node->getRevisionId() + 1);
+
+ $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+ $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+ $this->assertEquals('published', $french_node->moderation_state->entity->id());
+
+ // This should unpublish the French node.
+ $content_moderation_state = ContentModerationState::load(1);
+ $content_moderation_state = $content_moderation_state->getTranslation('fr');
+ $content_moderation_state->set('moderation_state', 'draft');
+ $content_moderation_state->setNewRevision(TRUE);
+ // Revision 8 (en, fr).
+ $content_moderation_state->save();
+
+ $english_node = $this->reloadNode($english_node, $english_node->getRevisionId());
+ $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+ $french_node = $this->reloadNode($english_node, '8')->getTranslation('fr');
+ $this->assertEquals('draft', $french_node->moderation_state->entity->id());
+ // Switching the moderation state to an unpublished state should update the
+ // entity.
+ $this->assertFalse($french_node->isPublished());
+
+ // Get the default english node.
+ $english_node = $this->reloadNode($english_node);
+ $this->assertTrue($english_node->isPublished());
+ $this->assertEquals(6, $english_node->getRevisionId());
+ }
+
+ /**
+ * Reloads the node after clearing the static cache.
+ *
+ * @param \Drupal\node\NodeInterface $node
+ * The node to reload.
+ * @param int|FALSE $revision_id
+ * The specific revision ID to load. Defaults FALSE and just loads the
+ * default revision.
+ *
+ * @return \Drupal\node\NodeInterface
+ * The reloaded node.
+ */
+ protected function reloadNode(NodeInterface $node, $revision_id = FALSE) {
+ $storage = \Drupal::entityTypeManager()->getStorage('node');
+ $storage->resetCache([$node->id()]);
+ if ($revision_id) {
+ return $storage->loadRevision($revision_id);
+ }
+ return $storage->load($node->id());
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php
new file mode 100644
index 0000000..99d8f0e
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php
@@ -0,0 +1,197 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\EntityOperations
+ *
+ * @group content_moderation
+ */
+class EntityOperationsTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'content_moderation',
+ 'node',
+ 'user',
+ 'system',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->installEntitySchema('node');
+ $this->installSchema('node', 'node_access');
+ $this->installEntitySchema('user');
+ $this->installEntitySchema('content_moderation_state');
+ $this->installConfig('content_moderation');
+
+ $this->createNodeType();
+ }
+
+ /**
+ * Creates a page node type to test with, ensuring that it's moderatable.
+ */
+ protected function createNodeType() {
+ $node_type = NodeType::create([
+ 'type' => 'page',
+ 'label' => 'Page',
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->save();
+ }
+
+ /**
+ * Verifies that the process of saving forward-revisions works as expected.
+ */
+ public function testForwardRevisions() {
+ // Create a new node in draft.
+ $page = Node::create([
+ 'type' => 'page',
+ 'title' => 'A',
+ ]);
+ $page->moderation_state->target_id = 'draft';
+ $page->save();
+
+ $id = $page->id();
+
+ // Verify the entity saved correctly, and that the presence of forward
+ // revisions doesn't affect the default node load.
+ /** @var Node $page */
+ $page = Node::load($id);
+ $this->assertEquals('A', $page->getTitle());
+ $this->assertTrue($page->isDefaultRevision());
+ $this->assertFalse($page->isPublished());
+
+ // Moderate the entity to published.
+ $page->setTitle('B');
+ $page->moderation_state->target_id = 'published';
+ $page->save();
+
+ // Verify the entity is now published and public.
+ $page = Node::load($id);
+ $this->assertEquals('B', $page->getTitle());
+ $this->assertTrue($page->isDefaultRevision());
+ $this->assertTrue($page->isPublished());
+
+ // Make a new forward-revision in Draft.
+ $page->setTitle('C');
+ $page->moderation_state->target_id = 'draft';
+ $page->save();
+
+ // Verify normal loads return the still-default previous version.
+ $page = Node::load($id);
+ $this->assertEquals('B', $page->getTitle());
+
+ // Verify we can load the forward revision, even if the mechanism is kind
+ // of gross. Note: revisionIds() is only available on NodeStorageInterface,
+ // so this won't work for non-nodes. We'd need to use entity queries. This
+ // is a core bug that should get fixed.
+ $storage = \Drupal::entityTypeManager()->getStorage('node');
+ $revision_ids = $storage->revisionIds($page);
+ sort($revision_ids);
+ $latest = end($revision_ids);
+ $page = $storage->loadRevision($latest);
+ $this->assertEquals('C', $page->getTitle());
+
+ $page->setTitle('D');
+ $page->moderation_state->target_id = 'published';
+ $page->save();
+
+ // Verify normal loads return the still-default previous version.
+ $page = Node::load($id);
+ $this->assertEquals('D', $page->getTitle());
+ $this->assertTrue($page->isDefaultRevision());
+ $this->assertTrue($page->isPublished());
+
+ // Now check that we can immediately add a new published revision over it.
+ $page->setTitle('E');
+ $page->moderation_state->target_id = 'published';
+ $page->save();
+
+ $page = Node::load($id);
+ $this->assertEquals('E', $page->getTitle());
+ $this->assertTrue($page->isDefaultRevision());
+ $this->assertTrue($page->isPublished());
+ }
+
+ /**
+ * Verifies that a newly-created node can go straight to published.
+ */
+ public function testPublishedCreation() {
+ // Create a new node in draft.
+ $page = Node::create([
+ 'type' => 'page',
+ 'title' => 'A',
+ ]);
+ $page->moderation_state->target_id = 'published';
+ $page->save();
+
+ $id = $page->id();
+
+ // Verify the entity saved correctly.
+ /** @var Node $page */
+ $page = Node::load($id);
+ $this->assertEquals('A', $page->getTitle());
+ $this->assertTrue($page->isDefaultRevision());
+ $this->assertTrue($page->isPublished());
+ }
+
+ /**
+ * Verifies that an unpublished state may be made the default revision.
+ */
+ public function testArchive() {
+ $published_id = $this->randomMachineName();
+ $published_state = ModerationState::create([
+ 'id' => $published_id,
+ 'label' => $this->randomString(),
+ 'published' => TRUE,
+ 'default_revision' => TRUE,
+ ]);
+ $published_state->save();
+
+ $archived_id = $this->randomMachineName();
+ $archived_state = ModerationState::create([
+ 'id' => $archived_id,
+ 'label' => $this->randomString(),
+ 'published' => FALSE,
+ 'default_revision' => TRUE,
+ ]);
+ $archived_state->save();
+
+ $page = Node::create([
+ 'type' => 'page',
+ 'title' => $this->randomString(),
+ ]);
+ $page->moderation_state->target_id = $published_id;
+ $page->save();
+
+ $id = $page->id();
+
+ // The newly-created page should already be published.
+ $page = Node::load($id);
+ $this->assertTrue($page->isPublished());
+
+ // When the page is moderated to the archived state, then the latest
+ // revision should be the default revision, and it should be unpublished.
+ $page->moderation_state->target_id = $archived_id;
+ $page->save();
+ $new_revision_id = $page->getRevisionId();
+
+ $storage = \Drupal::entityTypeManager()->getStorage('node');
+ $new_revision = $storage->loadRevision($new_revision_id);
+ $this->assertFalse($new_revision->isPublished());
+ $this->assertTrue($new_revision->isDefaultRevision());
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php
new file mode 100644
index 0000000..89c84f9
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\ParamConverter\EntityRevisionConverter
+ * @group content_moderation
+ */
+class EntityRevisionConverterTest extends KernelTestBase {
+
+ public static $modules = [
+ 'user',
+ 'entity_test',
+ 'system',
+ 'content_moderation',
+ 'node',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->installEntitySchema('entity_test');
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('user');
+ $this->installEntitySchema('content_moderation_state');
+ $this->installSchema('system', 'router');
+ $this->installSchema('system', 'sequences');
+ $this->installSchema('node', 'node_access');
+ \Drupal::service('router.builder')->rebuild();
+ }
+
+ /**
+ * @covers ::convert
+ */
+ public function testConvertNonRevisionableEntityType() {
+ $entity_test = EntityTest::create([
+ 'name' => 'test',
+ ]);
+
+ $entity_test->save();
+
+ /** @var \Symfony\Component\Routing\RouterInterface $router */
+ $router = \Drupal::service('router.no_access_checks');
+ $result = $router->match('/entity_test/' . $entity_test->id());
+
+ $this->assertInstanceOf(EntityTest::class, $result['entity_test']);
+ $this->assertEquals($entity_test->getRevisionId(), $result['entity_test']->getRevisionId());
+ }
+
+ /**
+ * @covers ::convert
+ */
+ public function testConvertWithRevisionableEntityType() {
+ $node_type = NodeType::create([
+ 'type' => 'article',
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->save();
+
+ $revision_ids = [];
+ $node = Node::create([
+ 'title' => 'test',
+ 'type' => 'article',
+ ]);
+ $node->save();
+
+ $revision_ids[] = $node->getRevisionId();
+
+ $node->setNewRevision(TRUE);
+ $node->save();
+ $revision_ids[] = $node->getRevisionId();
+
+ $node->setNewRevision(TRUE);
+ $node->isDefaultRevision(FALSE);
+ $node->save();
+ $revision_ids[] = $node->getRevisionId();
+
+ /** @var \Symfony\Component\Routing\RouterInterface $router */
+ $router = \Drupal::service('router.no_access_checks');
+ $result = $router->match('/node/' . $node->id() . '/edit');
+
+ $this->assertInstanceOf(Node::class, $result['node']);
+ $this->assertEquals($revision_ids[2], $result['node']->getRevisionId());
+ $this->assertFalse($result['node']->isDefaultRevision());
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
new file mode 100644
index 0000000..97e61f1
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\Plugin\Validation\Constraint\ModerationStateConstraintValidator
+ * @group content_moderation
+ */
+class EntityStateChangeValidationTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'node',
+ 'content_moderation',
+ 'user',
+ 'system',
+ 'language',
+ 'content_translation',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->installSchema('node', 'node_access');
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('user');
+ $this->installEntitySchema('content_moderation_state');
+ $this->installConfig('content_moderation');
+ }
+
+ /**
+ * Test valid transitions.
+ *
+ * @covers ::validate
+ */
+ public function testValidTransition() {
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->save();
+ $node = Node::create([
+ 'type' => 'example',
+ 'title' => 'Test title',
+ ]);
+ $node->moderation_state->target_id = 'draft';
+ $node->save();
+
+ $node->moderation_state->target_id = 'published';
+ $this->assertCount(0, $node->validate());
+ $node->save();
+
+ $this->assertEquals('published', $node->moderation_state->entity->id());
+ }
+
+ /**
+ * Test invalid transitions.
+ *
+ * @covers ::validate
+ */
+ public function testInvalidTransition() {
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->save();
+ $node = Node::create([
+ 'type' => 'example',
+ 'title' => 'Test title',
+ ]);
+ $node->moderation_state->target_id = 'draft';
+ $node->save();
+
+ $node->moderation_state->target_id = 'archived';
+ $violations = $node->validate();
+ $this->assertCount(1, $violations);
+
+ $this->assertEquals('Invalid state transition from <em class="placeholder">Draft</em> to <em class="placeholder">Archived</em>', $violations->get(0)->getMessage());
+ }
+
+ /**
+ * Tests that content without prior moderation information can be moderated.
+ */
+ public function testLegacyContent() {
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->save();
+ /** @var \Drupal\node\NodeInterface $node */
+ $node = Node::create([
+ 'type' => 'example',
+ 'title' => 'Test title',
+ ]);
+ $node->save();
+
+ $nid = $node->id();
+
+ // Enable moderation for our node type.
+ /** @var NodeType $node_type */
+ $node_type = NodeType::load('example');
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']);
+ $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft');
+ $node_type->save();
+
+ $node = Node::load($nid);
+
+ // Having no previous state should not break validation.
+ $violations = $node->validate();
+
+ $this->assertCount(0, $violations);
+
+ // Having no previous state should not break saving the node.
+ $node->setTitle('New');
+ $node->save();
+ }
+
+ /**
+ * Tests that content without prior moderation information can be translated.
+ */
+ public function testLegacyMultilingualContent() {
+ // Enable French.
+ ConfigurableLanguage::createFromLangcode('fr')->save();
+
+ $node_type = NodeType::create([
+ 'type' => 'example',
+ ]);
+ $node_type->save();
+ /** @var \Drupal\node\NodeInterface $node */
+ $node = Node::create([
+ 'type' => 'example',
+ 'title' => 'Test title',
+ 'langcode' => 'en',
+ ]);
+ $node->save();
+
+ $nid = $node->id();
+
+ $node = Node::load($nid);
+
+ // Creating a translation shouldn't break, even though there's no previous
+ // moderated revision for the new language.
+ $node_fr = $node->addTranslation('fr');
+ $node_fr->setTitle('Francais');
+ $node_fr->save();
+
+ // Enable moderation for our node type.
+ /** @var NodeType $node_type */
+ $node_type = NodeType::load('example');
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']);
+ $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft');
+ $node_type->save();
+
+ // Reload the French version of the node.
+ $node = Node::load($nid);
+ $node_fr = $node->getTranslation('fr');
+
+ /** @var \Drupal\node\NodeInterface $node_fr */
+ $node_fr->setTitle('Nouveau');
+ $node_fr->save();
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php
new file mode 100644
index 0000000..f312cde
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\content_moderation\Entity\ModerationState;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\Entity\ModerationState
+ *
+ * @group content_moderation
+ */
+class ModerationStateEntityTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['content_moderation'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->installEntitySchema('moderation_state');
+ }
+
+ /**
+ * Verify moderation state methods based on entity properties.
+ *
+ * @covers ::isPublishedState
+ * @covers ::isDefaultRevisionState
+ *
+ * @dataProvider moderationStateProvider
+ */
+ public function testModerationStateProperties($published, $default_revision, $is_published, $is_default) {
+ $moderation_state_id = $this->randomMachineName();
+ $moderation_state = ModerationState::create([
+ 'id' => $moderation_state_id,
+ 'label' => $this->randomString(),
+ 'published' => $published,
+ 'default_revision' => $default_revision,
+ ]);
+ $moderation_state->save();
+
+ $moderation_state = ModerationState::load($moderation_state_id);
+ $this->assertEquals($is_published, $moderation_state->isPublishedState());
+ $this->assertEquals($is_default, $moderation_state->isDefaultRevisionState());
+ }
+
+ /**
+ * Data provider for ::testModerationStateProperties.
+ */
+ public function moderationStateProvider() {
+ return [
+ // Draft, Needs review; should not touch the default revision.
+ [FALSE, FALSE, FALSE, FALSE],
+ // Published; this state should update and publish the default revision.
+ [TRUE, TRUE, TRUE, TRUE],
+ // Archive; this state should update but not publish the default revision.
+ [FALSE, TRUE, FALSE, TRUE],
+ // We try to prevent creating this state via the UI, but when a moderation
+ // state is a published state, it should also become the default revision.
+ [TRUE, FALSE, TRUE, TRUE],
+ ];
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php b/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php
new file mode 100644
index 0000000..c869619
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php
@@ -0,0 +1,159 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\views\Kernel\ViewsKernelTestBase;
+use Drupal\views\Views;
+
+/**
+ * Tests the views integration of content_moderation.
+ *
+ * @group content_moderation
+ */
+class ViewsDataIntegrationTest extends ViewsKernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'content_moderation_test_views',
+ 'node',
+ 'content_moderation',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp($import_test_views = TRUE) {
+ parent::setUp($import_test_views);
+
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('user');
+ $this->installEntitySchema('content_moderation_state');
+ $this->installSchema('node', 'node_access');
+ $this->installConfig('content_moderation_test_views');
+ $this->installConfig('content_moderation');
+
+ $node_type = NodeType::create([
+ 'type' => 'page',
+ ]);
+ $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+ $node_type->save();
+ }
+
+ /**
+ * Tests content_moderation_views_data().
+ *
+ * @see content_moderation_views_data()
+ */
+ public function testViewsData() {
+ $node = Node::create([
+ 'type' => 'page',
+ 'title' => 'Test title first revision',
+ ]);
+ $node->moderation_state->target_id = 'published';
+ $node->save();
+
+ $revision = clone $node;
+ $revision->setNewRevision(TRUE);
+ $revision->isDefaultRevision(FALSE);
+ $revision->title->value = 'Test title second revision';
+ $revision->moderation_state->target_id = 'draft';
+ $revision->save();
+
+ $view = Views::getView('test_content_moderation_latest_revision');
+ $view->execute();
+
+ // Ensure that the content_revision_tracker contains the right latest
+ // revision ID.
+ // Also ensure that the relationship back to the revision table contains the
+ // right latest revision.
+ $expected_result = [
+ [
+ 'nid' => $node->id(),
+ 'revision_id' => $revision->getRevisionId(),
+ 'title' => $revision->label(),
+ 'moderation_state_1' => 'draft',
+ 'moderation_state' => 'published',
+ ],
+ ];
+ $this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid', 'content_revision_tracker_revision_id' => 'revision_id', 'moderation_state' => 'moderation_state', 'moderation_state_1' => 'moderation_state_1']);
+ }
+
+ /**
+ * Tests the join from the revision data table to the moderation state table.
+ */
+ public function testContentModerationStateRevisionJoin() {
+ $node = Node::create([
+ 'type' => 'page',
+ 'title' => 'Test title first revision',
+ ]);
+ $node->moderation_state->target_id = 'published';
+ $node->save();
+
+ $revision = clone $node;
+ $revision->setNewRevision(TRUE);
+ $revision->isDefaultRevision(FALSE);
+ $revision->title->value = 'Test title second revision';
+ $revision->moderation_state->target_id = 'draft';
+ $revision->save();
+
+ $view = Views::getView('test_content_moderation_revision_test');
+ $view->execute();
+
+ $expected_result = [
+ [
+ 'revision_id' => $node->getRevisionId(),
+ 'moderation_state' => 'published',
+ ],
+ [
+ 'revision_id' => $revision->getRevisionId(),
+ 'moderation_state' => 'draft',
+ ],
+ ];
+ $this->assertIdenticalResultset($view, $expected_result, ['revision_id' => 'revision_id', 'moderation_state' => 'moderation_state']);
+ }
+
+ /**
+ * Tests the join from the data table to the moderation state table.
+ */
+ public function testContentModerationStateBaseJoin() {
+ $node = Node::create([
+ 'type' => 'page',
+ 'title' => 'Test title first revision',
+ ]);
+ $node->moderation_state->target_id = 'published';
+ $node->save();
+
+ $revision = clone $node;
+ $revision->setNewRevision(TRUE);
+ $revision->isDefaultRevision(FALSE);
+ $revision->title->value = 'Test title second revision';
+ $revision->moderation_state->target_id = 'draft';
+ $revision->save();
+
+ $view = Views::getView('test_content_moderation_base_table_test');
+ $view->execute();
+
+ $expected_result = [
+ [
+ 'nid' => $node->id(),
+ // @todo I would have expected that the content_moderation_state default
+ // revision is the same one as in the node, but it isn't.
+ // Joins from the base table to the default revision of the
+ // content_moderation.
+ 'moderation_state' => 'draft',
+ // Joins from the revision table to the default revision of the
+ // content_moderation.
+ 'moderation_state_1' => 'draft',
+ // Joins from the revision table to the revision of the
+ // content_moderation.
+ 'moderation_state_2' => 'published',
+ ],
+ ];
+ $this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid', 'moderation_state' => 'moderation_state', 'moderation_state_1' => 'moderation_state_1', 'moderation_state_2' => 'moderation_state_2']);
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php
new file mode 100644
index 0000000..5de0b2a
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\content_moderation\ContentPreprocess;
+use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\node\Entity\Node;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\ContentPreprocess
+ *
+ * @group content_moderation
+ */
+class ContentPreprocessTest extends \PHPUnit_Framework_TestCase {
+
+ /**
+ * @covers ::isLatestVersionPage
+ * @dataProvider routeNodeProvider
+ */
+ public function testIsLatestVersionPage($route_name, $route_nid, $check_nid, $result, $message) {
+ $content_preprocess = new ContentPreprocess($this->setupCurrentRouteMatch($route_name, $route_nid));
+ $node = $this->setupNode($check_nid);
+ $this->assertEquals($result, $content_preprocess->isLatestVersionPage($node), $message);
+ }
+
+ /**
+ * Data provider for self::testIsLatestVersionPage().
+ */
+ public function routeNodeProvider() {
+ return [
+ ['entity.node.canonical', 1, 1, FALSE, 'Not on the latest version tab route.'],
+ ['entity.node.latest_version', 1, 1, TRUE, 'On the latest version tab route, with the route node.'],
+ ['entity.node.latest_version', 1, 2, FALSE, 'On the latest version tab route, with a different node.'],
+ ];
+ }
+
+ /**
+ * Mock the current route matching object.
+ *
+ * @param string $route_name
+ * The route to mock.
+ * @param int $nid
+ * The node ID for mocking.
+ *
+ * @return \Drupal\Core\Routing\CurrentRouteMatch
+ * The mocked current route match object.
+ */
+ protected function setupCurrentRouteMatch($route_name, $nid) {
+ $route_match = $this->prophesize(CurrentRouteMatch::class);
+ $route_match->getRouteName()->willReturn($route_name);
+ $route_match->getParameter('node')->willReturn($this->setupNode($nid));
+
+ return $route_match->reveal();
+ }
+
+ /**
+ * Mock a node object.
+ *
+ * @param int $nid
+ * The node ID to mock.
+ *
+ * @return \Drupal\node\Entity\Node
+ * The mocked node.
+ */
+ protected function setupNode($nid) {
+ $node = $this->prophesize(Node::class);
+ $node->id()->willReturn($nid);
+
+ return $node->reveal();
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php
new file mode 100644
index 0000000..1f8838b
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\Core\Access\AccessResultAllowed;
+use Drupal\Core\Access\AccessResultForbidden;
+use Drupal\Core\Routing\RouteMatch;
+use Drupal\node\Entity\Node;
+use Drupal\content_moderation\Access\LatestRevisionCheck;
+use Drupal\content_moderation\ModerationInformation;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\Access\LatestRevisionCheck
+ * @group content_moderation
+ */
+class LatestRevisionCheckTest extends \PHPUnit_Framework_TestCase {
+
+ /**
+ * Test the access check of the LatestRevisionCheck service.
+ *
+ * @param string $entity_class
+ * The class of the entity to mock.
+ * @param string $entity_type
+ * The machine name of the entity to mock.
+ * @param bool $has_forward
+ * Whether this entity should have a forward revision in the system.
+ * @param string $result_class
+ * The AccessResult class that should result. One of AccessResultAllowed,
+ * AccessResultForbidden, AccessResultNeutral.
+ *
+ * @dataProvider accessSituationProvider
+ */
+ public function testLatestAccessPermissions($entity_class, $entity_type, $has_forward, $result_class) {
+
+ /** @var \Drupal\Core\Entity\EntityInterface $entity */
+ $entity = $this->prophesize($entity_class);
+ $entity->getCacheContexts()->willReturn([]);
+ $entity->getCacheTags()->willReturn([]);
+ $entity->getCacheMaxAge()->willReturn(0);
+
+ /** @var \Drupal\content_moderation\ModerationInformation $mod_info */
+ $mod_info = $this->prophesize(ModerationInformation::class);
+ $mod_info->hasForwardRevision($entity->reveal())->willReturn($has_forward);
+
+ $route = $this->prophesize(Route::class);
+
+ $route->getOption('_content_moderation_entity_type')->willReturn($entity_type);
+
+ $route_match = $this->prophesize(RouteMatch::class);
+ $route_match->getParameter($entity_type)->willReturn($entity->reveal());
+
+ $lrc = new LatestRevisionCheck($mod_info->reveal());
+
+ /** @var \Drupal\Core\Access\AccessResult $result */
+ $result = $lrc->access($route->reveal(), $route_match->reveal());
+
+ $this->assertInstanceOf($result_class, $result);
+
+ }
+
+ /**
+ * Data provider for testLastAccessPermissions().
+ */
+ public function accessSituationProvider() {
+ return [
+ [Node::class, 'node', TRUE, AccessResultAllowed::class],
+ [Node::class, 'node', FALSE, AccessResultForbidden::class],
+ [BlockContent::class, 'block_content', TRUE, AccessResultAllowed::class],
+ [BlockContent::class, 'block_content', FALSE, AccessResultForbidden::class],
+ ];
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php
new file mode 100644
index 0000000..6833cdb
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityFormInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\ContentEntityType;
+use Drupal\Core\Entity\EntityFormInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\content_moderation\ModerationInformation;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\ModerationInformation
+ * @group content_moderation
+ */
+class ModerationInformationTest extends \PHPUnit_Framework_TestCase {
+
+ /**
+ * Builds a mock user.
+ *
+ * @return AccountInterface
+ * The mocked user.
+ */
+ protected function getUser() {
+ return $this->prophesize(AccountInterface::class)->reveal();
+ }
+
+ /**
+ * Returns a mock Entity Type Manager.
+ *
+ * @param \Drupal\Core\Entity\EntityStorageInterface $entity_bundle_storage
+ * Entity bundle storage.
+ *
+ * @return EntityTypeManagerInterface
+ * The mocked entity type manager.
+ */
+ protected function getEntityTypeManager(EntityStorageInterface $entity_bundle_storage) {
+ $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
+ $entity_type_manager->getStorage('entity_test_bundle')->willReturn($entity_bundle_storage);
+ return $entity_type_manager->reveal();
+ }
+
+ /**
+ * Sets up content moderation and entity manager mocking.
+ *
+ * @param bool $status
+ * TRUE if content_moderation should be enabled, FALSE if not.
+ *
+ * @return \Drupal\Core\Entity\EntityTypeManagerInterface
+ * The mocked entity type manager.
+ */
+ public function setupModerationEntityManager($status) {
+ $bundle = $this->prophesize(ConfigEntityInterface::class);
+ $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)->willReturn($status);
+
+ $entity_storage = $this->prophesize(EntityStorageInterface::class);
+ $entity_storage->load('test_bundle')->willReturn($bundle->reveal());
+
+ return $this->getEntityTypeManager($entity_storage->reveal());
+ }
+
+ /**
+ * @dataProvider providerBoolean
+ * @covers ::isModeratableEntity
+ */
+ public function testIsModeratableEntity($status) {
+ $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser());
+
+ $entity_type = new ContentEntityType([
+ 'id' => 'test_entity_type',
+ 'bundle_entity_type' => 'entity_test_bundle',
+ ]);
+ $entity = $this->prophesize(ContentEntityInterface::class);
+ $entity->getEntityType()->willReturn($entity_type);
+ $entity->bundle()->willReturn('test_bundle');
+
+ $this->assertEquals($status, $moderation_information->isModeratableEntity($entity->reveal()));
+ }
+
+ /**
+ * @covers ::isModeratableEntity
+ */
+ public function testIsModeratableEntityForNonBundleEntityType() {
+ $entity_type = new ContentEntityType([
+ 'id' => 'test_entity_type',
+ ]);
+ $entity = $this->prophesize(ContentEntityInterface::class);
+ $entity->getEntityType()->willReturn($entity_type);
+ $entity->bundle()->willReturn('test_entity_type');
+
+ $entity_storage = $this->prophesize(EntityStorageInterface::class);
+ $entity_type_manager = $this->getEntityTypeManager($entity_storage->reveal());
+ $moderation_information = new ModerationInformation($entity_type_manager, $this->getUser());
+
+ $this->assertEquals(FALSE, $moderation_information->isModeratableEntity($entity->reveal()));
+ }
+
+ /**
+ * @dataProvider providerBoolean
+ * @covers ::isModeratableBundle
+ */
+ public function testIsModeratableBundle($status) {
+ $entity_type = new ContentEntityType([
+ 'id' => 'test_entity_type',
+ 'bundle_entity_type' => 'entity_test_bundle',
+ ]);
+
+ $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser());
+
+ $this->assertEquals($status, $moderation_information->isModeratableBundle($entity_type, 'test_bundle'));
+ }
+
+ /**
+ * @dataProvider providerBoolean
+ * @covers ::isModeratedEntityForm
+ */
+ public function testIsModeratedEntityForm($status) {
+ $entity_type = new ContentEntityType([
+ 'id' => 'test_entity_type',
+ 'bundle_entity_type' => 'entity_test_bundle',
+ ]);
+
+ $entity = $this->prophesize(ContentEntityInterface::class);
+ $entity->getEntityType()->willReturn($entity_type);
+ $entity->bundle()->willReturn('test_bundle');
+
+ $form = $this->prophesize(ContentEntityFormInterface::class);
+ $form->getEntity()->willReturn($entity);
+
+ $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser());
+
+ $this->assertEquals($status, $moderation_information->isModeratedEntityForm($form->reveal()));
+ }
+
+ /**
+ * @covers ::isModeratedEntityForm
+ */
+ public function testIsModeratedEntityFormWithNonContentEntityForm() {
+ $form = $this->prophesize(EntityFormInterface::class);
+ $moderation_information = new ModerationInformation($this->setupModerationEntityManager(TRUE), $this->getUser());
+
+ $this->assertFalse($moderation_information->isModeratedEntityForm($form->reveal()));
+ }
+
+ /**
+ * Data provider for several tests.
+ */
+ public function providerBoolean() {
+ return [
+ [FALSE],
+ [TRUE],
+ ];
+ }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php
new file mode 100644
index 0000000..b057478
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php
@@ -0,0 +1,297 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryFactory;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\content_moderation\ModerationStateInterface;
+use Drupal\content_moderation\ModerationStateTransitionInterface;
+use Drupal\content_moderation\StateTransitionValidation;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\StateTransitionValidation
+ * @group content_moderation
+ */
+class StateTransitionValidationTest extends \PHPUnit_Framework_TestCase {
+
+ /**
+ * Builds a mock storage object for Transitions.
+ *
+ * @return EntityStorageInterface
+ * The mocked storage object for Transitions.
+ */
+ protected function setupTransitionStorage() {
+ $entity_storage = $this->prophesize(EntityStorageInterface::class);
+
+ $list = $this->setupTransitionEntityList();
+ $entity_storage->loadMultiple()->willReturn($list);
+ $entity_storage->loadMultiple(Argument::type('array'))->will(function ($args) use ($list) {
+ $keys = $args[0];
+ if (empty($keys)) {
+ return $list;
+ }
+
+ $return = array_map(function($key) use ($list) {
+ return $list[$key];
+ }, $keys);
+
+ return $return;
+ });
+ return $entity_storage->reveal();
+ }
+
+ /**
+ * Builds an array of mocked Transition objects.
+ *
+ * @return ModerationStateTransitionInterface[]
+ * An array of mocked Transition objects.
+ */
+ protected function setupTransitionEntityList() {
+ $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+ $transition->id()->willReturn('draft__needs_review');
+ $transition->getFromState()->willReturn('draft');
+ $transition->getToState()->willReturn('needs_review');
+ $list[$transition->reveal()->id()] = $transition->reveal();
+
+ $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+ $transition->id()->willReturn('needs_review__staging');
+ $transition->getFromState()->willReturn('needs_review');
+ $transition->getToState()->willReturn('staging');
+ $list[$transition->reveal()->id()] = $transition->reveal();
+
+ $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+ $transition->id()->willReturn('staging__published');
+ $transition->getFromState()->willReturn('staging');
+ $transition->getToState()->willReturn('published');
+ $list[$transition->reveal()->id()] = $transition->reveal();
+
+ $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+ $transition->id()->willReturn('needs_review__draft');
+ $transition->getFromState()->willReturn('needs_review');
+ $transition->getToState()->willReturn('draft');
+ $list[$transition->reveal()->id()] = $transition->reveal();
+
+ $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+ $transition->id()->willReturn('draft__draft');
+ $transition->getFromState()->willReturn('draft');
+ $transition->getToState()->willReturn('draft');
+ $list[$transition->reveal()->id()] = $transition->reveal();
+
+ $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+ $transition->id()->willReturn('needs_review__needs_review');
+ $transition->getFromState()->willReturn('needs_review');
+ $transition->getToState()->willReturn('needs_review');
+ $list[$transition->reveal()->id()] = $transition->reveal();
+
+ $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+ $transition->id()->willReturn('published__published');
+ $transition->getFromState()->willReturn('published');
+ $transition->getToState()->willReturn('published');
+ $list[$transition->reveal()->id()] = $transition->reveal();
+
+ return $list;
+ }
+
+ /**
+ * Builds a mock storage object for States.
+ *
+ * @return EntityStorageInterface
+ * The mocked storage object for States.
+ */
+ protected function setupStateStorage() {
+ $entity_storage = $this->prophesize(EntityStorageInterface::class);
+
+ $state = $this->prophesize(ModerationStateInterface::class);
+ $state->id()->willReturn('draft');
+ $state->label()->willReturn('Draft');
+ $state->isPublishedState()->willReturn(FALSE);
+ $state->isDefaultRevisionState()->willReturn(FALSE);
+ $states['draft'] = $state->reveal();
+
+ $state = $this->prophesize(ModerationStateInterface::class);
+ $state->id()->willReturn('needs_review');
+ $state->label()->willReturn('Needs Review');
+ $state->isPublishedState()->willReturn(FALSE);
+ $state->isDefaultRevisionState()->willReturn(FALSE);
+ $states['needs_review'] = $state->reveal();
+
+ $state = $this->prophesize(ModerationStateInterface::class);
+ $state->id()->willReturn('staging');
+ $state->label()->willReturn('Staging');
+ $state->isPublishedState()->willReturn(FALSE);
+ $state->isDefaultRevisionState()->willReturn(FALSE);
+ $states['staging'] = $state->reveal();
+
+ $state = $this->prophesize(ModerationStateInterface::class);
+ $state->id()->willReturn('published');
+ $state->label()->willReturn('Published');
+ $state->isPublishedState()->willReturn(TRUE);
+ $state->isDefaultRevisionState()->willReturn(TRUE);
+ $states['published'] = $state->reveal();
+
+ $state = $this->prophesize(ModerationStateInterface::class);
+ $state->id()->willReturn('archived');
+ $state->label()->willReturn('Archived');
+ $state->isPublishedState()->willReturn(TRUE);
+ $state->isDefaultRevisionState()->willReturn(TRUE);
+ $states['archived'] = $state->reveal();
+
+ $entity_storage->loadMultiple()->willReturn($states);
+
+ foreach ($states as $id => $state) {
+ $entity_storage->load($id)->willReturn($state);
+ }
+
+ return $entity_storage->reveal();
+ }
+
+ /**
+ * Builds a mocked Entity Type Manager.
+ *
+ * @return EntityTypeManagerInterface
+ * The mocked Entity Type Manager.
+ */
+ protected function setupEntityTypeManager(EntityStorageInterface $storage) {
+ $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
+ $entityTypeManager->getStorage('moderation_state')->willReturn($storage);
+ $entityTypeManager->getStorage('moderation_state_transition')->willReturn($this->setupTransitionStorage());
+
+ return $entityTypeManager->reveal();
+ }
+
+ /**
+ * Builds a mocked query factory that does nothing.
+ *
+ * @return QueryFactory
+ * The mocked query factory that does nothing.
+ */
+ protected function setupQueryFactory() {
+ $factory = $this->prophesize(QueryFactory::class);
+
+ return $factory->reveal();
+ }
+
+ /**
+ * @covers ::isTransitionAllowed
+ * @covers ::calculatePossibleTransitions
+ *
+ * @dataProvider providerIsTransitionAllowedWithValidTransition
+ */
+ public function testIsTransitionAllowedWithValidTransition($from_id, $to_id) {
+ $storage = $this->setupStateStorage();
+ $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager($storage), $this->setupQueryFactory());
+ $this->assertTrue($state_transition_validation->isTransitionAllowed($storage->load($from_id), $storage->load($to_id)));
+ }
+
+ /**
+ * Data provider for self::testIsTransitionAllowedWithValidTransition().
+ */
+ public function providerIsTransitionAllowedWithValidTransition() {
+ return [
+ ['draft', 'draft'],
+ ['draft', 'needs_review'],
+ ['needs_review', 'needs_review'],
+ ['needs_review', 'staging'],
+ ['staging', 'published'],
+ ['needs_review', 'draft'],
+ ];
+ }
+
+ /**
+ * @covers ::isTransitionAllowed
+ * @covers ::calculatePossibleTransitions
+ *
+ * @dataProvider providerIsTransitionAllowedWithInValidTransition
+ */
+ public function testIsTransitionAllowedWithInValidTransition($from_id, $to_id) {
+ $storage = $this->setupStateStorage();
+ $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager($storage), $this->setupQueryFactory());
+ $this->assertFalse($state_transition_validation->isTransitionAllowed($storage->load($from_id), $storage->load($to_id)));
+ }
+
+ /**
+ * Data provider for self::testIsTransitionAllowedWithInValidTransition().
+ */
+ public function providerIsTransitionAllowedWithInValidTransition() {
+ return [
+ ['published', 'needs_review'],
+ ['published', 'staging'],
+ ['staging', 'needs_review'],
+ ['staging', 'staging'],
+ ['needs_review', 'published'],
+ ['published', 'archived'],
+ ['archived', 'published'],
+ ];
+ }
+
+ /**
+ * Verifies user-aware transition validation.
+ *
+ * @param string $from_id
+ * The state to transition from.
+ * @param string $to_id
+ * The state to transition to.
+ * @param string $permission
+ * The permission to give the user, or not.
+ * @param bool $allowed
+ * Whether or not to grant a user this permission.
+ * @param bool $result
+ * Whether userMayTransition() is expected to return TRUE or FALSE.
+ *
+ * @dataProvider userTransitionsProvider
+ */
+ public function testUserSensitiveValidTransitions($from_id, $to_id, $permission, $allowed, $result) {
+ $user = $this->prophesize(AccountInterface::class);
+ // The one listed permission will be returned as instructed; Any others are
+ // always denied.
+ $user->hasPermission($permission)->willReturn($allowed);
+ $user->hasPermission(Argument::type('string'))->willReturn(FALSE);
+
+ $storage = $this->setupStateStorage();
+ $validator = new Validator($this->setupEntityTypeManager($storage), $this->setupQueryFactory());
+
+ $this->assertEquals($result, $validator->userMayTransition($storage->load($from_id), $storage->load($to_id), $user->reveal()));
+ }
+
+ /**
+ * Data provider for the user transition test.
+ */
+ public function userTransitionsProvider() {
+ // The user has the right permission, so let it through.
+ $ret[] = ['draft', 'draft', 'use draft__draft transition', TRUE, TRUE];
+
+ // The user doesn't have the right permission, block it.
+ $ret[] = ['draft', 'draft', 'use draft__draft transition', FALSE, FALSE];
+
+ // The user has some other permission that doesn't matter.
+ $ret[] = ['draft', 'draft', 'use draft__needs_review transition', TRUE, FALSE];
+
+ // The user has permission, but the transition isn't allowed anyway.
+ $ret[] = ['published', 'needs_review', 'use published__needs_review transition', TRUE, FALSE];
+
+ return $ret;
+ }
+
+}
+
+/**
+ * Testable subclass for selected tests.
+ *
+ * EntityQuery is beyond untestable, so we have to subclass and override the
+ * method that uses it.
+ */
+class Validator extends StateTransitionValidation {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) {
+ if ($from->id() === 'draft' && $to->id() === 'draft') {
+ return $this->transitionStorage()->loadMultiple(['draft__draft'])[0];
+ }
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
index 5e2265d..f4e242d 100644
--- a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
@@ -24,6 +24,7 @@ class StableTemplateOverrideTest extends KernelTestBase {
*/
protected $templatesToSkip = [
'views-form-views-form',
+ 'entity-moderation-form'
];
/**