first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<?php
/**
* @file
* API documentation for Content Moderation module.
*/
/**
* @defgroup content_moderation_plugin Content Moderation Workflow Type Plugin
* @{
* The Workflow Type plugin implemented by Content Moderation links revisionable
* entities to workflows.
*
* In the Content Moderation Workflow Type Plugin, one method requires the
* entity object to be passed in as a parameter, even though the interface
* defined by Workflows module doesn't require this:
* @code
* $workflow_type_plugin->getInitialState($entity);
* @endcode
* This is used to determine the initial moderation state based on the
* publishing status of the entity.
* @}
*/

View File

@@ -0,0 +1,13 @@
name: 'Content Moderation'
type: module
description: 'Provides additional publication states that can be used by other modules to moderate content.'
# version: VERSION
package: Core
configure: entity.workflow.collection
dependencies:
- drupal:workflows
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,13 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Content Moderation module.
*/
/**
* Implements hook_update_last_removed().
*/
function content_moderation_update_last_removed() {
return 8700;
}

View File

@@ -0,0 +1,7 @@
content_moderation:
version: VERSION
css:
component:
css/content_moderation.module.css: {}
theme:
css/content_moderation.theme.css: {}

View File

@@ -0,0 +1,8 @@
content_moderation.workflows:
deriver: 'Drupal\content_moderation\Plugin\Derivative\DynamicLocalTasks'
weight: 100
content_moderation.content:
title: 'Overview'
route_name: system.admin_content
parent_id: system.admin_content

View File

@@ -0,0 +1,384 @@
<?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\ModerationOptOutPublish;
use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublish;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\Display\EntityFormDisplayInterface;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\views\Plugin\views\filter\Broken;
use Drupal\views\ViewExecutable;
use Drupal\views\Views;
use Drupal\workflows\WorkflowInterface;
use Drupal\Core\Action\Plugin\Action\PublishAction;
use Drupal\Core\Action\Plugin\Action\UnpublishAction;
use Drupal\workflows\Entity\Workflow;
use Drupal\views\Entity\View;
/**
* 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 .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The Content Moderation module allows you to expand on Drupal\'s "unpublished" and "published" states for content. It allows you to have a published version that is live, but have a separate working copy that is undergoing review before it is published. This is achieved by using <a href=":workflows">Workflows</a> to apply different states and transitions to entities as needed. 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', ':workflows' => Url::fromRoute('help.page', ['name' => 'workflows'])->toString()]) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Applying workflows') . '</dt>';
$output .= '<dd>' . t('Content Moderation allows you to apply <a href=":workflows">Workflows</a> to content, content blocks, and other <a href=":field_help" title="Field module help, with background on content entities">content entities</a>, to provide more fine-grained publishing options. For example, a Basic page might have states such as Draft and Published, with allowed transitions such as Draft to Published (making the current revision "live"), and Published to Draft (making a new draft revision of published content).', [':workflows' => Url::fromRoute('help.page', ['name' => 'workflows'])->toString(), ':field_help' => Url::fromRoute('help.page', ['name' => 'field'])->toString()]) . '</dd>';
if (\Drupal::moduleHandler()->moduleExists('views')) {
$moderated_content_view = View::load('moderated_content');
if (isset($moderated_content_view) && $moderated_content_view->status() === TRUE) {
$output .= '<dt>' . t('Moderating content') . '</dt>';
$output .= '<dd>' . t('You can view a list of content awaiting moderation on the <a href=":moderated">moderated content page</a>. This will show any content in an unpublished state, such as Draft or Archived, to help surface content that requires more work from content editors.', [':moderated' => Url::fromRoute('view.moderated_content.moderated_content')->toString()]) . '</dd>';
}
}
$output .= '<dt>' . t('Configure Content Moderation permissions') . '</dt>';
$output .= '<dd>' . t('Each transition is exposed as a permission. If a user has the permission for a transition, they can use the transition to change the state of the content item, from Draft to Published.') . '</dd>';
$output .= '</dl>';
return $output;
}
}
/**
* Implements hook_entity_base_field_info().
*/
function content_moderation_entity_base_field_info(EntityTypeInterface $entity_type) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityBaseFieldInfo($entity_type);
}
/**
* Implements hook_entity_bundle_field_info().
*/
function content_moderation_entity_bundle_field_info(EntityTypeInterface $entity_type, $bundle, array $base_field_definitions) {
if (isset($base_field_definitions['moderation_state'])) {
// Add the target bundle to the moderation state field. Since each bundle
// can be attached to a different moderation workflow, adding this
// information to the field definition allows the associated workflow to be
// derived where a field definition is present.
$base_field_definitions['moderation_state']->setTargetBundle($bundle);
return [
'moderation_state' => $base_field_definitions['moderation_state'],
];
}
}
/**
* Implements hook_entity_type_alter().
*/
function content_moderation_entity_type_alter(array &$entity_types) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityTypeAlter($entity_types);
}
/**
* Implements hook_entity_presave().
*/
function content_moderation_entity_presave(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityPresave($entity);
}
/**
* Implements hook_entity_insert().
*/
function content_moderation_entity_insert(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityInsert($entity);
}
/**
* Implements hook_entity_update().
*/
function content_moderation_entity_update(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityUpdate($entity);
}
/**
* Implements hook_entity_delete().
*/
function content_moderation_entity_delete(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityDelete($entity);
}
/**
* Implements hook_entity_revision_delete().
*/
function content_moderation_entity_revision_delete(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityRevisionDelete($entity);
}
/**
* Implements hook_entity_translation_delete().
*/
function content_moderation_entity_translation_delete(EntityInterface $translation) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityTranslationDelete($translation);
}
/**
* Implements hook_entity_prepare_form().
*/
function content_moderation_entity_prepare_form(EntityInterface $entity, $operation, FormStateInterface $form_state) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityPrepareForm($entity, $operation, $form_state);
}
/**
* Implements hook_form_alter().
*/
function content_moderation_form_alter(&$form, FormStateInterface $form_state, $form_id) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->formAlter($form, $form_state, $form_id);
}
/**
* Implements hook_preprocess_HOOK().
*/
function content_moderation_preprocess_node(&$variables) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(ContentPreprocess::class)
->preprocessNode($variables);
}
/**
* Implements hook_entity_extra_field_info().
*/
function content_moderation_entity_extra_field_info() {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityExtraFieldInfo();
}
/**
* Implements hook_entity_view().
*/
function content_moderation_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityView($build, $entity, $display, $view_mode);
}
/**
* Implements hook_entity_form_display_alter().
*/
function content_moderation_entity_form_display_alter(EntityFormDisplayInterface $form_display, array $context) {
if ($context['form_mode'] === 'layout_builder') {
$form_display->setComponent('moderation_state', [
'type' => 'moderation_state_default',
'weight' => -900,
'settings' => [],
]);
}
}
/**
* Implements hook_entity_access().
*
* Entities 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_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
/** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
$moderation_info = Drupal::service('content_moderation.moderation_information');
$access_result = NULL;
if ($operation === 'view') {
$access_result = (($entity instanceof EntityPublishedInterface) && !$entity->isPublished())
? AccessResult::allowedIfHasPermission($account, 'view any unpublished content')
: AccessResult::neutral();
$access_result->addCacheableDependency($entity);
}
elseif ($operation === 'update' && $moderation_info->isModeratedEntity($entity) && $entity->moderation_state) {
/** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */
$transition_validation = \Drupal::service('content_moderation.state_transition_validation');
$valid_transition_targets = $transition_validation->getValidTransitions($entity, $account);
$access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden('No valid transitions exist for given account.');
$access_result->addCacheableDependency($entity);
$workflow = $moderation_info->getWorkflowForEntity($entity);
$access_result->addCacheableDependency($workflow);
// The state transition validation service returns a list of transitions
// based on the user's permission to use them.
$access_result->cachePerPermissions();
}
// Do not allow users to delete the state that is configured as the default
// state for the workflow.
if ($entity instanceof WorkflowInterface) {
$configuration = $entity->getTypePlugin()->getConfiguration();
if (!empty($configuration['default_moderation_state']) && $operation === sprintf('delete-state:%s', $configuration['default_moderation_state'])) {
return AccessResult::forbidden()->addCacheableDependency($entity);
}
}
return $access_result;
}
/**
* Implements hook_entity_field_access().
*/
function content_moderation_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
if ($items && $operation === 'edit') {
/** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
$moderation_info = Drupal::service('content_moderation.moderation_information');
$entity_type = \Drupal::entityTypeManager()->getDefinition($field_definition->getTargetEntityTypeId());
$entity = $items->getEntity();
// Deny edit access to the published field if the entity is being moderated.
if ($entity_type->hasKey('published') && $moderation_info->isModeratedEntity($entity) && $entity->moderation_state && $field_definition->getName() == $entity_type->getKey('published')) {
return AccessResult::forbidden('Cannot edit the published field of moderated entities.');
}
}
return AccessResult::neutral();
}
/**
* 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 entity. If another module has already swapped out those classes,
// though, we'll be polite and do nothing.
foreach ($definitions as &$definition) {
if ($definition['id'] === 'entity:publish_action' && $definition['class'] == PublishAction::class) {
$definition['class'] = ModerationOptOutPublish::class;
}
if ($definition['id'] === 'entity:unpublish_action' && $definition['class'] == UnpublishAction::class) {
$definition['class'] = ModerationOptOutUnpublish::class;
}
}
}
/**
* Implements hook_entity_bundle_info_alter().
*/
function content_moderation_entity_bundle_info_alter(&$bundles) {
$translatable = FALSE;
/** @var \Drupal\workflows\WorkflowInterface $workflow */
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */
$plugin = $workflow->getTypePlugin();
foreach ($plugin->getEntityTypes() as $entity_type_id) {
foreach ($plugin->getBundlesForEntityType($entity_type_id) as $bundle_id) {
if (isset($bundles[$entity_type_id][$bundle_id])) {
$bundles[$entity_type_id][$bundle_id]['workflow'] = $workflow->id();
// If we have even one moderation-enabled translatable bundle, we need
// to make the moderation state bundle translatable as well, to enable
// the revision translation merge logic also for content moderation
// state revisions.
if (!empty($bundles[$entity_type_id][$bundle_id]['translatable'])) {
$translatable = TRUE;
}
}
}
}
}
$bundles['content_moderation_state']['content_moderation_state']['translatable'] = $translatable;
}
/**
* Implements hook_entity_bundle_delete().
*/
function content_moderation_entity_bundle_delete($entity_type_id, $bundle_id) {
// Remove non-configuration based bundles from content moderation based
// workflows when they are removed.
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
if ($workflow->getTypePlugin()->appliesToEntityTypeAndBundle($entity_type_id, $bundle_id)) {
$workflow->getTypePlugin()->removeEntityTypeAndBundle($entity_type_id, $bundle_id);
$workflow->save();
}
}
}
/**
* Implements hook_ENTITY_TYPE_insert().
*/
function content_moderation_workflow_insert(WorkflowInterface $entity) {
// Clear bundle cache so workflow gets added or removed from the bundle
// information.
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
// Clear field cache so extra field is added or removed.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
// Clear the views data cache so the extra field is available in views.
if (\Drupal::moduleHandler()->moduleExists('views')) {
Views::viewsData()->clear();
}
}
/**
* Implements hook_ENTITY_TYPE_update().
*/
function content_moderation_workflow_update(WorkflowInterface $entity) {
// Clear bundle cache so workflow gets added or removed from the bundle
// information.
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
// Clear field cache so extra field is added or removed.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
// Clear the views data cache so the extra field is available in views.
if (\Drupal::moduleHandler()->moduleExists('views')) {
Views::viewsData()->clear();
}
}
/**
* Implements hook_views_post_execute().
*/
function content_moderation_views_post_execute(ViewExecutable $view) {
// @todo Remove this once broken handlers in views configuration result in
// a view no longer returning results. https://www.drupal.org/node/2907954.
foreach ($view->filter as $id => $filter) {
if (str_starts_with($id, 'moderation_state') && $filter instanceof Broken) {
$view->result = [];
break;
}
}
}

View File

@@ -0,0 +1,9 @@
view any unpublished content:
title: 'View any unpublished content'
view latest version:
title: 'View the latest version'
description: 'Requires the "View any unpublished content" or "View own unpublished content" permission'
permission_callbacks:
- \Drupal\content_moderation\Permissions::transitionPermissions

View File

@@ -0,0 +1,19 @@
<?php
/**
* @file
* Post update functions for the Content Moderation module.
*/
/**
* Implements hook_removed_post_updates().
*/
function content_moderation_removed_post_updates() {
return [
'content_moderation_post_update_update_cms_default_revisions' => '9.0.0',
'content_moderation_post_update_set_default_moderation_state' => '9.0.0',
'content_moderation_post_update_set_views_filter_latest_translation_affected_revision' => '9.0.0',
'content_moderation_post_update_entity_display_dependencies' => '9.0.0',
'content_moderation_post_update_views_field_plugin_id' => '9.0.0',
];
}

View File

@@ -0,0 +1,16 @@
content_moderation.admin_moderated_content:
path: '/admin/content/moderated'
defaults:
_controller: '\Drupal\content_moderation\Controller\ModeratedContentController::nodeListing'
_title: 'Moderated content'
requirements:
_module_dependencies: 'node'
_permission: 'view any unpublished content'
content_moderation.workflow_type_edit_form:
path: '/admin/config/workflow/workflows/manage/{workflow}/type/{entity_type_id}'
defaults:
_form: '\Drupal\content_moderation\Form\ContentModerationConfigureEntityTypesForm'
_title_callback: '\Drupal\content_moderation\Form\ContentModerationConfigureEntityTypesForm::getTitle'
requirements:
_permission: 'administer workflows'

View File

@@ -0,0 +1,25 @@
services:
_defaults:
autoconfigure: true
content_moderation.state_transition_validation:
class: Drupal\content_moderation\StateTransitionValidation
arguments: ['@content_moderation.moderation_information']
Drupal\content_moderation\StateTransitionValidationInterface: '@content_moderation.state_transition_validation'
content_moderation.moderation_information:
class: Drupal\content_moderation\ModerationInformation
arguments: ['@entity_type.manager', '@entity_type.bundle.info']
Drupal\content_moderation\ModerationInformationInterface: '@content_moderation.moderation_information'
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.config_import_subscriber:
class: Drupal\content_moderation\EventSubscriber\ConfigImportSubscriber
arguments: ['@config.manager', '@entity_type.manager']
content_moderation.route_subscriber:
class: Drupal\content_moderation\Routing\ContentModerationRouteSubscriber
arguments: ['@entity_type.manager']
content_moderation.workspace_subscriber:
class: Drupal\content_moderation\EventSubscriber\WorkspaceSubscriber
arguments: ['@entity_type.manager', '@?workspaces.association']

View File

@@ -0,0 +1,30 @@
<?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();
}
/**
* 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')
);
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* @file
* Provide views runtime hooks for content_moderation.module.
*/
use Drupal\views\ViewExecutable;
/**
* Implements hook_views_query_substitutions().
*/
function content_moderation_views_query_substitutions(ViewExecutable $view) {
$account = \Drupal::currentUser();
return [
'***VIEW_ANY_UNPUBLISHED_NODES***' => intval($account->hasPermission('view any unpublished content')),
];
}

View File

@@ -0,0 +1,34 @@
/**
* @file
* Component styles for the content_moderation module.
*/
.entity-moderation-form {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
list-style: none;
}
.entity-moderation-form__item {
display: table;
margin-right: 2em;
}
.entity-moderation-form__item:last-child {
align-self: flex-end;
margin-right: 0;
}
.entity-moderation-form .form-item {
margin-top: 1em;
margin-bottom: 1em;
}
.entity-moderation-form .form-item label {
display: table;
padding-bottom: 0.25em;
}
.entity-moderation-form input[type="submit"] {
margin-bottom: 1.2em;
}

View File

@@ -0,0 +1,10 @@
/**
* @file
* Theme styles for the content_moderation module.
*/
.entity-moderation-form {
margin: 2em 0;
padding-left: 1em;
border: 1px dashed #bbb;
background: #fff;
}

View File

@@ -0,0 +1,23 @@
---
label: 'Moving content between workflow states'
related:
- workflows.overview
- content_moderation.configuring_workflows
- core.content_structure
---
{% set workflows_overview_topic = render_var(help_topic_link('workflows.overview')) %}
{% set content_structure_topic = render_var(help_topic_link('core.content_structure')) %}
{% set content_moderation_permissions_link_text %}{% trans %}Content Moderation{% endtrans %}{% endset %}
{% set content_moderation_permissions_link = render_var(help_route_link(content_moderation_permissions_link_text, 'user.admin_permissions', {}, {'fragment': 'module-content_moderation'})) %}
{% set content_link_text %}{% trans %}Content{% endtrans %}{% endset %}
{% set content_link = render_var(help_route_link(content_link_text, 'system.admin_content')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Change the workflow state of a particular entity. See {{ workflows_overview_topic }} for an overview of workflows, and {{ content_structure_topic }} for an overview of content entities.{% endtrans %}</p>
<h2>{% trans %}Who can change workflow states?{% endtrans %}</h2>
<p>{% trans %}Users with <em>content moderation permissions</em> can change workflow states. There are separate permissions for each transition. See Permissions &gt; <em>{{ content_moderation_permissions_link }}</em> to configure content moderation permissions.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}Find the entity that you want to moderate in either the content moderation view page, if you created one, or the appropriate administrative page for managing that type of entity (such as the administration page for content items; see {{ content_link }}).{% endtrans %}</li>
<li>{% trans %}Click <em>Edit</em> to edit the entity.{% endtrans %}</li>
<li>{% trans %}At the bottom of the page, select the new workflow state under <em>Change to:</em> and click <em>Save</em>.{% endtrans %}</li>
</ol>

View File

@@ -0,0 +1,53 @@
---
label: 'Configuring workflows'
related:
- workflows.overview
- content_moderation.changing_states
- core.content_structure
- views_ui.create
---
{% set content_structure_topic = render_var(help_topic_link('core.content_structure')) %}
{% set user_overview_topic = render_var(help_topic_link('user.overview')) %}
{% set user_permissions_topic = render_var(help_topic_link('user.permissions')) %}
{% set workflows_overview_topic = render_var(help_topic_link('workflows.overview')) %}
{% set content_moderation_permissions_link_text %}{% trans %}Content Moderation{% endtrans %}{% endset %}
{% set content_moderation_permissions_link = render_var(help_route_link(content_moderation_permissions_link_text, 'user.admin_permissions', {}, {'fragment': 'module-content_moderation'})) %}
{% set workflows_permissions_link_text %}{% trans %}Administer workflows{% endtrans %}{% endset %}
{% set workflows_permissions_link = render_var(help_route_link(workflows_permissions_link_text, 'user.admin_permissions', {}, {'fragment': 'module-workflows'})) %}
{% set workflows_link_text %}{% trans %}Workflows{% endtrans %}{% endset %}
{% set workflows_link = render_var(help_route_link(workflows_link_text, 'entity.workflow.collection')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Create or edit a workflow with various workflow states (for example <em>Concept</em>, <em>Archived</em>, etc.) for moderating content. See {{ workflows_overview_topic }} for more information on workflows.{% endtrans %}</p>
<h2>{% trans %}Who can configure a workflow?{% endtrans %}</h2>
<p>{% trans %}Users with <em>workflows permissions</em> (typically administrators) can configure workflows. See Permissions &gt; <em>{{ workflows_permissions_link }}</em> to configure workflows permissions.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}Make a plan for the new workflow:{% endtrans %}
<ul>
<li>{% trans %}Decide which workflow states you need; for example, <em>Concept</em>, <em>Review</em>, and <em>Final</em>.{% endtrans %}</li>
<li>{% trans %}Decide on the settings for each state:{% endtrans %}
<ul>
<li>{% trans %}<em>Label</em>: the state name{% endtrans %}</li>
<li>{% trans %}<em>Published</em>: if checked, when content reaches this state it will be made visible on the site (to users with permission).{% endtrans %}</li>
<li>{% trans %}<em>Default revision</em>: if checked, when content reaches this state it will become the default revision of the content; published content is automatically the default revision.{% endtrans %}</li>
</ul>
</li>
<li>{% trans %}Decide which state content should be created in.{% endtrans %}</li>
<li>{% trans %}Decide on the list of allowed transitions between states. For example, you might want a transition between <em>Concept</em> and <em>Review</em>. Each transition has a label; for example, the Concept to Review transition might be labeled "Review concept".{% endtrans %}</li>
<li>{% trans %}Decide which roles should have permissions to make each transition; see {{ user_overview_topic }} for an overview of roles and permissions.{% endtrans %}</li>
<li>{% trans %}Decide which <em>entity types</em> and subtypes the workflow should apply to. Only entity types that support revisions are possible to define workflows for. See {{ content_structure_topic }} for more information on content entities and fields.{% endtrans %}</li>
</ul>
</li>
<li>{% trans %}To implement your plan, in the <em>Manage</em> administrative menu, navigate to <em>Configuration</em> &gt; <em>Workflow</em> &gt; <em>{{ workflows_link }}</em>. A list of workflows is shown, including the default workflow <em>Editorial</em> that you can adapt.{% endtrans %}</li>
<li>{% trans %}Click <em>Add workflow</em>.{% endtrans %}</li>
<li>{% trans %}Enter a name in the <em>Label</em> field, select <em>Content moderation</em> from the <em>Workflow type</em> field, and click <em>Save</em>.{% endtrans %}</li>
<li>{% trans %}Verify that the <em>States</em> list matches your planned states. You can add missing states by clicking <em>Add a new state</em>. You can edit or delete states by clicking <em>Edit</em> or <em>Delete</em> under <em>Operations</em> (if the <em>Delete</em> option is not available, you will first need to delete any <em>Transitions</em> to or from this state).{% endtrans %}</li>
<li>{% trans %}Verify that the <em>Transitions</em> list matches your plan. You can add missing transitions by clicking <em>Add a new transition</em>. You can edit or delete transitions by clicking <em>Edit</em> or <em>Delete</em> under <em>Operations</em>.{% endtrans %}</li>
<li>{% trans %}Under <em>This workflow applies to:</em>, find the entity type that you want this workflow to apply to, such as Content revisions, Content block revisions, or Taxonomy term revisions. Click <em>Select</em>.{% endtrans %}</li>
<li>{% trans %}Check the entity subtypes that you want to apply the workflow to. For example, you might choose to apply your workflow to the <em>Page</em> content type, but not to <em>Article</em>.{% endtrans %}</li>
<li>{% trans %}Click <em>Save</em>.{% endtrans %}</li>
<li>{% trans %}Under <em>Workflow settings</em>, select the <em>Default moderation state</em> for new content.{% endtrans %}</li>
<li>{% trans %}Click <em>Save</em> to save your workflow.{% endtrans %}</li>
<li>{% trans %}Follow the steps in {{ user_permissions_topic }} to assign permissions for each transition to roles. The permissions are listed under the <em>{{ content_moderation_permissions_link }}</em> section; there is one permission for each transition in each workflow.{% endtrans %}</li>
<li>{% trans %}Optionally (recommended), create a view for your custom workflow, to provide a page for content editors to see what content needs to be moderated. You can do this if the Views UI module is installed, by following the steps in the related <em>Creating a new view</em> topic listed below under <em>Related topics</em>. When creating the view, under <em>View settings</em> &gt; <em>Show</em>, select the revision data type you configured the workflow for, and be sure to display the <em>Workflow State</em> field in your view.{% endtrans %}</li>
</ol>

View File

@@ -0,0 +1,99 @@
<?php
namespace Drupal\content_moderation\Access;
use Drupal\Core\Access\AccessException;
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 Drupal\Core\Session\AccountInterface;
use Drupal\user\EntityOwnerInterface;
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 pending 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.
* @param \Drupal\Core\Session\AccountInterface $account
* The current user account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @see \Drupal\Core\Entity\EntityAccessCheck
*/
public function access(Route $route, RouteMatchInterface $route_match, AccountInterface $account) {
// This tab should not show up unless there's a reason to show it.
$entity = $this->loadEntity($route, $route_match);
if ($this->moderationInfo->hasPendingRevision($entity)) {
// Check the global permissions first.
$access_result = AccessResult::allowedIfHasPermissions($account, ['view latest version', 'view any unpublished content']);
if (!$access_result->isAllowed()) {
// Check entity owner access.
$owner_access = AccessResult::allowedIfHasPermissions($account, ['view latest version', 'view own unpublished content']);
$owner_access = $owner_access->andIf((AccessResult::allowedIf($entity instanceof EntityOwnerInterface && ($entity->getOwnerId() == $account->id()))));
$access_result = $access_result->orIf($owner_access);
}
return $access_result->addCacheableDependency($entity);
}
return AccessResult::forbidden('No pending revision for moderated entity.')->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 \Drupal\Core\Access\AccessException
* An AccessException is thrown if the entity couldn't be loaded.
*/
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 AccessException(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()));
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Drupal\content_moderation;
use Drupal\workflows\StateInterface;
/**
* A value object representing a workflow state for content moderation.
*/
class ContentModerationState implements StateInterface {
/**
* The vanilla state object from the Workflow module.
*
* @var \Drupal\workflows\StateInterface
*/
protected $state;
/**
* If entities should be published if in this state.
*
* @var bool
*/
protected $published;
/**
* If entities should be the default revision if in this state.
*
* @var bool
*/
protected $defaultRevision;
/**
* ContentModerationState constructor.
*
* Decorates state objects to add methods to determine if an entity should be
* published or made the default revision.
*
* @param \Drupal\workflows\StateInterface $state
* The vanilla state object from the Workflow module.
* @param bool $published
* (optional) TRUE if entities should be published if in this state, FALSE
* if not. Defaults to FALSE.
* @param bool $default_revision
* (optional) TRUE if entities should be the default revision if in this
* state, FALSE if not. Defaults to FALSE.
*/
public function __construct(StateInterface $state, $published = FALSE, $default_revision = FALSE) {
$this->state = $state;
$this->published = $published;
$this->defaultRevision = $default_revision;
}
/**
* Determines if entities should be published if in this state.
*
* @return bool
*/
public function isPublishedState() {
return $this->published;
}
/**
* Determines if entities should be the default revision if in this state.
*
* @return bool
*/
public function isDefaultRevisionState() {
return $this->defaultRevision;
}
/**
* {@inheritdoc}
*/
public function id() {
return $this->state->id();
}
/**
* {@inheritdoc}
*/
public function label() {
return $this->state->label();
}
/**
* {@inheritdoc}
*/
public function weight() {
return $this->state->weight();
}
/**
* {@inheritdoc}
*/
public function canTransitionTo($to_state_id) {
return $this->state->canTransitionTo($to_state_id);
}
/**
* {@inheritdoc}
*/
public function getTransitionTo($to_state_id) {
return $this->state->getTransitionTo($to_state_id);
}
/**
* {@inheritdoc}
*/
public function getTransitions() {
return $this->state->getTransitions();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
/**
* The access control handler for the content_moderation_state entity type.
*
* @see \Drupal\content_moderation\Entity\ContentModerationState
*/
class ContentModerationStateAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
public function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
// ContentModerationState is an internal entity type. Access is denied for
// viewing, updating, and deleting. In order to update an entity's
// moderation state use its moderation_state field.
return AccessResult::forbidden('ContentModerationState is an internal entity type.');
}
/**
* {@inheritdoc}
*/
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
// ContentModerationState is an internal entity type. Access is denied for
// creating. In order to update an entity's moderation state use its
// moderation_state field.
return AccessResult::forbidden('ContentModerationState is an internal entity type.');
}
}

View File

@@ -0,0 +1,42 @@
<?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 unique keys to guarantee the integrity of the entity and to make
// the lookup in ModerationStateFieldItemList::getModerationState() fast.
$unique_keys = [
'content_entity_type_id',
'content_entity_id',
'content_entity_revision_id',
'workflow',
'langcode',
];
if ($data_table = $this->storage->getDataTable()) {
$schema[$data_table]['unique keys'] += [
'content_moderation_state__lookup' => $unique_keys,
];
}
if ($revision_data_table = $this->storage->getRevisionDataTable()) {
$schema[$revision_data_table]['unique keys'] += [
'content_moderation_state__lookup' => $unique_keys,
];
}
return $schema;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\node\Entity\Node;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Determines whether a route is the "Latest version" tab of a node.
*
* @internal
*/
class ContentPreprocess implements ContainerInjectionInterface {
/**
* The route match service.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructor.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* Current route match service.
*/
public function __construct(RouteMatchInterface $route_match) {
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_route_match')
);
}
/**
* @param array $variables
* Theme variables to preprocess.
*
* @see hook_preprocess_HOOK()
*/
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();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Drupal\content_moderation\Controller;
use Drupal\content_moderation\ModeratedNodeListBuilder;
use Drupal\Core\Controller\ControllerBase;
/**
* Defines a controller to list moderated nodes.
*/
class ModeratedContentController extends ControllerBase {
/**
* Provides the listing page for moderated nodes.
*
* @return array
* A render array as expected by
* \Drupal\Core\Render\RendererInterface::render().
*/
public function nodeListing() {
$entity_type = $this->entityTypeManager()->getDefinition('node');
return $this->entityTypeManager()->createHandlerInstance(ModeratedNodeListBuilder::class, $entity_type)->render();
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace Drupal\content_moderation\Entity;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\user\EntityOwnerTrait;
/**
* 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",
* "access" = "Drupal\content_moderation\ContentModerationStateAccessControlHandler",
* },
* 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,
* internal = TRUE,
* entity_keys = {
* "id" = "id",
* "revision" = "revision_id",
* "uuid" = "uuid",
* "uid" = "uid",
* "owner" = "uid",
* "langcode" = "langcode",
* }
* )
*
* @internal
* This entity is marked internal because it should not be used directly to
* alter the moderation state of an entity. Instead, the computed
* moderation_state field should be set on the entity directly.
*/
class ContentModerationState extends ContentEntityBase implements ContentModerationStateInterface {
use EntityOwnerTrait;
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields += static::ownerBaseFieldDefinitions($entity_type);
$fields['uid']
->setLabel(t('User'))
->setDescription(t('The username of the entity creator.'))
->setRevisionable(TRUE);
$fields['workflow'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Workflow'))
->setDescription(t('The workflow the moderation state is in.'))
->setSetting('target_type', 'workflow')
->setRequired(TRUE)
->setRevisionable(TRUE);
$fields['moderation_state'] = BaseFieldDefinition::create('string')
->setLabel(t('Moderation state'))
->setDescription(t('The moderation state of the referenced content.'))
->setRequired(TRUE)
->setTranslatable(TRUE)
->setRevisionable(TRUE);
$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)
->setSetting('max_length', EntityTypeInterface::ID_MAX_LENGTH)
->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);
$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;
}
/**
* 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();
}
/**
* Loads a content moderation state entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* A moderated entity object.
*
* @return \Drupal\content_moderation\Entity\ContentModerationStateInterface|null
* The related content moderation state or NULL if none could be found.
*
* @internal
* This method should only be called by code directly handling the
* ContentModerationState entity objects.
*/
public static function loadFromModeratedEntity(EntityInterface $entity) {
$content_moderation_state = NULL;
$moderation_info = \Drupal::service('content_moderation.moderation_information');
if ($moderation_info->isModeratedEntity($entity)) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$storage = \Drupal::entityTypeManager()->getStorage('content_moderation_state');
// New entities may not have a loaded revision ID at this point, but the
// creation of a content moderation state entity may have already been
// triggered elsewhere. In this case we have to match on the revision ID
// (instead of the loaded revision ID).
$revision_id = $entity->getLoadedRevisionId() ?: $entity->getRevisionId();
$ids = $storage->getQuery()
->accessCheck(FALSE)
->condition('content_entity_type_id', $entity->getEntityTypeId())
->condition('content_entity_id', $entity->id())
->condition('workflow', $moderation_info->getWorkflowForEntity($entity)->id())
->condition('content_entity_revision_id', $revision_id)
->allRevisions()
->execute();
if ($ids) {
/** @var \Drupal\content_moderation\Entity\ContentModerationStateInterface $content_moderation_state */
$content_moderation_state = $storage->loadRevision(key($ids));
}
}
return $content_moderation_state;
}
/**
* {@inheritdoc}
*/
public function save() {
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = \Drupal::entityTypeManager()
->getStorage($this->content_entity_type_id->value);
$related_entity = $storage
->loadRevision($this->content_entity_revision_id->value);
if ($related_entity instanceof TranslatableInterface) {
$related_entity = $related_entity->getTranslation($this->activeLangcode);
}
$related_entity->moderation_state = $this->moderation_state;
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();
}
/**
* {@inheritdoc}
*/
protected function getFieldsToSkipFromTranslationChangesCheck() {
$field_names = parent::getFieldsToSkipFromTranslationChangesCheck();
// We need to skip the parent entity revision ID, since that will always
// change on every save, otherwise every translation would be marked as
// affected regardless of actual changes.
$field_names[] = 'content_entity_revision_id';
return $field_names;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Drupal\content_moderation\Entity;
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.
*
* @internal
*/
interface ContentModerationStateInterface extends ContentEntityInterface, EntityOwnerInterface {
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Customizations for block content entities.
*
* @internal
*/
class BlockContentModerationHandler extends ModerationHandler {
/**
* {@inheritdoc}
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
$form['revision']['#default_value'] = TRUE;
$form['revision']['#disabled'] = TRUE;
$form['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.');
}
/**
* {@inheritdoc}
*/
public function isModeratedEntity(ContentEntityInterface $entity) {
// Only reusable blocks can be moderated individually. Non-reusable or
// inline blocks are moderated as part of the entity they are a composite
// of.
return $entity->isReusable();
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
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.
*
* @internal
*/
class ModerationHandler implements ModerationHandlerInterface, EntityHandlerInterface {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static();
}
/**
* {@inheritdoc}
*/
public function isModeratedEntity(ContentEntityInterface $entity) {
// Moderate all entities included in the moderation workflow by default.
return TRUE;
}
/**
* {@inheritdoc}
*/
public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
// When entities are syncing, content moderation should not force a new
// revision to be created and should not update the default status of a
// revision. This is useful if changes are being made to entities or
// revisions which are not part of editorial updates triggered by normal
// content changes.
if (!$entity->isSyncing()) {
$entity->setNewRevision(TRUE);
$entity->isDefaultRevision($default_revision);
}
// Update publishing status if it can be updated and if it needs updating.
if (($entity instanceof EntityPublishedInterface) && $entity->isPublished() !== $published_state) {
$published_state ? $entity->setPublished() : $entity->setUnpublished();
}
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
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.
*
* @internal
*/
interface ModerationHandlerInterface {
/**
* Determines if an entity should be moderated.
*
* At the workflow level, moderation is enabled or disabled for entire entity
* types or bundles. After a bundle has been enabled, there maybe be further
* decisions each entity type may make to evaluate if a given entity is
* appropriate to be included in a moderation workflow. The handler is only
* consulted after the user has configured the associated entity type and
* bundle to be included in a moderation workflow.
*
* Returning FALSE will remove the moderation state field widget from the
* associated entity form and opt out of all moderation related entity
* semantics, such as creating new revisions and changing the publishing
* status of a revision.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity we may be moderating.
*
* @return bool
* TRUE if this entity should be moderated, FALSE otherwise.
*/
public function isModeratedEntity(ContentEntityInterface $entity);
/**
* Operates on moderated 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);
/**
* 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);
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Customizations for node entities.
*
* @internal
*/
class NodeModerationHandler extends ModerationHandler {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* NodeModerationHandler constructor.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
*/
public function __construct(ModerationInformationInterface $moderation_info) {
$this->moderationInfo = $moderation_info;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$container->get('content_moderation.moderation_information')
);
}
/**
* {@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) {
// Force the revision checkbox on.
$form['workflow']['options']['revision']['#value'] = 'revision';
$form['workflow']['options']['revision']['#disabled'] = TRUE;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Form\FormStateInterface;
/**
* Customizations for taxonomy term entities.
*
* @internal
*/
class TaxonomyTermModerationHandler extends ModerationHandler {
/**
* {@inheritdoc}
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id): void {
$form['revision']['#default_value'] = TRUE;
$form['revision']['#disabled'] = TRUE;
$form['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.');
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id): void {
$form['revision']['#default_value'] = TRUE;
$form['revision']['#disabled'] = TRUE;
$form['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.');
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Drupal\content_moderation\Entity\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.
*
* @internal
*/
class EntityModerationRouteProvider implements EntityRouteProviderInterface, EntityHandlerInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected $entityFieldManager;
/**
* Constructs a new DefaultHtmlRouteProvider.
*
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
* The entity field manager.
*/
public function __construct(EntityFieldManagerInterface $entity_field_manager) {
$this->entityFieldManager = $entity_field_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 any unpublished content" permission.
->setRequirement('_entity_access', "{$entity_type_id}.view")
->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_latest_revision' => TRUE,
],
]);
// 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->entityClassImplements(FieldableEntityInterface::class)) {
return NULL;
}
$field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
return $field_storage_definitions[$entity_type->getKey('id')]->getType();
}
}

View File

@@ -0,0 +1,324 @@
<?php
namespace Drupal\content_moderation;
use Drupal\content_moderation\Entity\ContentModerationState as ContentModerationStateEntity;
use Drupal\content_moderation\Entity\ContentModerationStateInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\content_moderation\Form\EntityModerationForm;
use Drupal\Core\Routing\RouteBuilderInterface;
use Drupal\workflows\Entity\Workflow;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for reacting to entity events.
*
* @internal
*/
class EntityOperations implements ContainerInjectionInterface {
/**
* 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 entity bundle information service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* The router builder service.
*
* @var \Drupal\Core\Routing\RouteBuilderInterface
*/
protected $routerBuilder;
/**
* 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\Core\Entity\EntityTypeBundleInfoInterface $bundle_info
* The entity bundle information service.
* @param \Drupal\Core\Routing\RouteBuilderInterface $router_builder
* The router builder service.
*/
public function __construct(ModerationInformationInterface $moderation_info, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, EntityTypeBundleInfoInterface $bundle_info, RouteBuilderInterface $router_builder) {
$this->moderationInfo = $moderation_info;
$this->entityTypeManager = $entity_type_manager;
$this->formBuilder = $form_builder;
$this->bundleInfo = $bundle_info;
$this->routerBuilder = $router_builder;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('content_moderation.moderation_information'),
$container->get('entity_type.manager'),
$container->get('form_builder'),
$container->get('entity_type.bundle.info'),
$container->get('router.builder')
);
}
/**
* Acts on an entity and set published status based on the moderation state.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being saved.
*
* @see hook_entity_presave()
*/
public function entityPresave(EntityInterface $entity) {
if (!$this->moderationInfo->isModeratedEntity($entity)) {
return;
}
if ($entity->moderation_state->value) {
$workflow = $this->moderationInfo->getWorkflowForEntity($entity);
/** @var \Drupal\content_moderation\ContentModerationState $current_state */
$current_state = $workflow->getTypePlugin()
->getState($entity->moderation_state->value);
// This entity is default if it is new, the default revision, or the
// default revision is not published.
$update_default_revision = $entity->isNew()
|| $current_state->isDefaultRevisionState()
|| !$this->moderationInfo->isDefaultRevisionPublished($entity);
// Fire per-entity-type logic for handling the save process.
$this->entityTypeManager
->getHandler($entity->getEntityTypeId(), 'moderation')
->onPresave($entity, $update_default_revision, $current_state->isPublishedState());
}
}
/**
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity that was just saved.
*
* @see hook_entity_insert()
*/
public function entityInsert(EntityInterface $entity) {
if ($this->moderationInfo->isModeratedEntity($entity)) {
$this->updateOrCreateFromEntity($entity);
}
}
/**
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity that was just saved.
*
* @see hook_entity_update()
*/
public function entityUpdate(EntityInterface $entity) {
if ($this->moderationInfo->isModeratedEntity($entity)) {
$this->updateOrCreateFromEntity($entity);
}
// When updating workflow settings for Content Moderation, we need to
// rebuild routes as we may be enabling new entity types and the related
// entity forms.
elseif ($entity instanceof Workflow && $entity->getTypePlugin()->getPluginId() == 'content_moderation') {
$this->routerBuilder->setRebuildNeeded();
}
}
/**
* 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) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity_revision_id = $entity->getRevisionId();
$workflow = $this->moderationInfo->getWorkflowForEntity($entity);
$content_moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($entity);
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage('content_moderation_state');
if (!($content_moderation_state instanceof ContentModerationStateInterface)) {
$content_moderation_state = $storage->create([
'content_entity_type_id' => $entity->getEntityTypeId(),
'content_entity_id' => $entity->id(),
// Make sure that the moderation state entity has the same language code
// as the moderated entity.
'langcode' => $entity->language()->getId(),
]);
$content_moderation_state->workflow->target_id = $workflow->id();
}
// Sync translations.
if ($entity->getEntityType()->hasKey('langcode')) {
$entity_langcode = $entity->language()->getId();
if ($entity->isDefaultTranslation()) {
$content_moderation_state->langcode = $entity_langcode;
}
else {
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);
}
}
}
// If a new revision of the content has been created, add a new content
// moderation state revision.
if (!$content_moderation_state->isNew() && $content_moderation_state->content_entity_revision_id->value != $entity_revision_id) {
$content_moderation_state = $storage->createRevision($content_moderation_state, $entity->isDefaultRevision());
}
// Create the ContentModerationState entity for the inserted entity.
$moderation_state = $entity->moderation_state->value;
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
if (!$moderation_state) {
$moderation_state = $workflow->getTypePlugin()->getInitialState($entity)->id();
}
$content_moderation_state->set('content_entity_revision_id', $entity_revision_id);
$content_moderation_state->set('moderation_state', $moderation_state);
ContentModerationStateEntity::updateOrCreateFromEntity($content_moderation_state);
}
/**
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being deleted.
*
* @see hook_entity_delete()
*/
public function entityDelete(EntityInterface $entity) {
$content_moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($entity);
if ($content_moderation_state) {
$content_moderation_state->delete();
}
}
/**
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity revision being deleted.
*
* @see hook_entity_revision_delete()
*/
public function entityRevisionDelete(EntityInterface $entity) {
if ($content_moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($entity)) {
if ($content_moderation_state->isDefaultRevision()) {
$content_moderation_state->delete();
}
else {
$this->entityTypeManager
->getStorage('content_moderation_state')
->deleteRevision($content_moderation_state->getRevisionId());
}
}
}
/**
* @param \Drupal\Core\Entity\EntityInterface $translation
* The entity translation being deleted.
*
* @see hook_entity_translation_delete()
*/
public function entityTranslationDelete(EntityInterface $translation) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $translation */
if (!$translation->isDefaultTranslation()) {
$langcode = $translation->language()->getId();
$content_moderation_state = ContentModerationStateEntity::loadFromModeratedEntity($translation);
if ($content_moderation_state && $content_moderation_state->hasTranslation($langcode)) {
$content_moderation_state->removeTranslation($langcode);
ContentModerationStateEntity::updateOrCreateFromEntity($content_moderation_state);
}
}
}
/**
* Act on entities being assembled before rendering.
*
* @see hook_entity_view()
* @see EntityFieldManagerInterface::getExtraFields()
*/
public function entityView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
if (!$this->moderationInfo->isModeratedEntity($entity)) {
return;
}
if (isset($entity->in_preview) && $entity->in_preview) {
return;
}
// If the component is not defined for this display, we have nothing to do.
if (!$display->getComponent('content_moderation_control')) {
return;
}
// The moderation form should be displayed only when viewing the latest
// (translation-affecting) revision, unless it was created as published
// default revision.
if (($entity->isDefaultRevision() || $entity->wasDefaultRevision()) && $this->isPublished($entity)) {
return;
}
if (!$entity->isLatestRevision() && !$entity->isLatestTranslationAffectedRevision()) {
return;
}
$build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity);
}
/**
* Checks if the entity is published.
*
* This method is optimized to not have to unnecessarily load the moderation
* state and workflow if it is not required.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to check.
*
* @return bool
* TRUE if the entity is published, FALSE otherwise.
*/
protected function isPublished(ContentEntityInterface $entity) {
// If the entity implements EntityPublishedInterface directly, check that
// first, otherwise fall back to check through the workflow state.
if ($entity instanceof EntityPublishedInterface) {
return $entity->isPublished();
}
if ($moderation_state = $entity->get('moderation_state')->value) {
$workflow = $this->moderationInfo->getWorkflowForEntity($entity);
return $workflow->getTypePlugin()->getState($moderation_state)->isPublishedState();
}
return FALSE;
}
}

View File

@@ -0,0 +1,409 @@
<?php
namespace Drupal\content_moderation;
use Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList;
use Drupal\Core\Entity\BundleEntityFormBase;
use Drupal\Core\Entity\ContentEntityFormInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
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\Entity\Handler\TaxonomyTermModerationHandler;
use Drupal\content_moderation\Entity\Routing\EntityModerationRouteProvider;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Manipulates entity type information.
*
* This class contains primarily bridged hooks for compile-time or
* cache-clear-time hooks. Runtime hooks should be placed in EntityOperations.
*
* @internal
*/
class EntityTypeInfo implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The bundle information service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidationInterface
*/
protected $validator;
/**
* A keyed array of custom moderation handlers for given entity types.
*
* Any entity not specified will use a common default.
*
* @var array
*/
protected $moderationHandlers = [
'node' => NodeModerationHandler::class,
'block_content' => BlockContentModerationHandler::class,
'taxonomy_term' => TaxonomyTermModerationHandler::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.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info
* Bundle information service.
* @param \Drupal\Core\Session\AccountInterface $current_user
* Current user.
* @param \Drupal\content_moderation\StateTransitionValidationInterface $validator
* State transition validator.
*/
public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info, AccountInterface $current_user, StateTransitionValidationInterface $validator) {
$this->stringTranslation = $translation;
$this->moderationInfo = $moderation_information;
$this->entityTypeManager = $entity_type_manager;
$this->bundleInfo = $bundle_info;
$this->currentUser = $current_user;
$this->validator = $validator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('string_translation'),
$container->get('content_moderation.moderation_information'),
$container->get('entity_type.manager'),
$container->get('entity_type.bundle.info'),
$container->get('current_user'),
$container->get('content_moderation.state_transition_validation')
);
}
/**
* Adds Moderation configuration to appropriate entity types.
*
* @param \Drupal\Core\Entity\EntityTypeInterface[] $entity_types
* The master entity type list to alter.
*
* @see hook_entity_type_alter()
*/
public function entityTypeAlter(array &$entity_types) {
foreach ($entity_types as $entity_type_id => $entity_type) {
// Internal entity types should never be moderated, and the 'path_alias'
// entity type needs to be excluded for now.
// @todo Enable moderation for path aliases after they become publishable
// in https://www.drupal.org/project/drupal/issues/3007669.
// Workspace entities can not be moderated because they use string IDs.
// @see \Drupal\content_moderation\Entity\ContentModerationState::baseFieldDefinitions()
// where the target entity ID is defined as an integer.
// @todo Moderation is disabled for taxonomy terms until integration is
// enabled for them.
// @see https://www.drupal.org/project/drupal/issues/3047110
$entity_type_to_exclude = [
'path_alias',
'workspace',
];
if ($entity_type->isRevisionable() && !$entity_type->isInternal() && !in_array($entity_type_id, $entity_type_to_exclude)) {
$entity_types[$entity_type_id] = $this->addModerationToEntityType($entity_type);
}
}
}
/**
* 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 addModerationToEntityType(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');
}
$providers = $type->getRouteProviderClasses() ?: [];
if (empty($providers['moderation'])) {
$providers['moderation'] = EntityModerationRouteProvider::class;
$type->setHandlerClass('route_provider', $providers);
}
return $type;
}
/**
* Gets the "extra fields" for a bundle.
*
* @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.
*
* @see hook_entity_extra_field_info()
*/
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() {
$entity_types = array_filter($this->entityTypeManager->getDefinitions(), [$this->moderationInfo, 'canModerateEntitiesOfEntityType']);
foreach ($entity_types as $type_name => $type) {
foreach ($this->bundleInfo->getBundleInfo($type_name) as $bundle_id => $bundle) {
if ($this->moderationInfo->shouldModerateEntitiesOfBundle($type, $bundle_id)) {
yield ['entity' => $type_name, 'bundle' => $bundle_id];
}
}
}
}
/**
* 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.
*
* @see hook_entity_base_field_info()
*/
public function entityBaseFieldInfo(EntityTypeInterface $entity_type) {
if (!$this->moderationInfo->isModeratedEntityType($entity_type)) {
return [];
}
$fields = [];
$fields['moderation_state'] = BaseFieldDefinition::create('string')
->setLabel(t('Moderation state'))
->setDescription(t('The moderation state of this piece of content.'))
->setComputed(TRUE)
->setClass(ModerationStateFieldItemList::class)
->setDisplayOptions('view', [
'label' => 'hidden',
'region' => 'hidden',
'weight' => -5,
])
->setDisplayOptions('form', [
'type' => 'moderation_state_default',
'weight' => 100,
'settings' => [],
])
->addConstraint('ModerationState', [])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', FALSE)
->setReadOnly(FALSE)
->setTranslatable(TRUE);
return $fields;
}
/**
* Replaces the entity form entity object with a proper revision object.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being edited.
* @param string $operation
* The entity form operation.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @see hook_entity_prepare_form()
*/
public function entityPrepareForm(EntityInterface $entity, $operation, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\EntityFormInterface $form_object */
$form_object = $form_state->getFormObject();
if ($this->isModeratedEntityEditForm($form_object) && !$entity->isNew()) {
// Generate a proper revision object for the current entity. This allows
// to correctly handle translatable entities having pending revisions.
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
/** @var \Drupal\Core\Entity\ContentEntityInterface $new_revision */
$new_revision = $storage->createRevision($entity, FALSE);
// Restore the revision ID as other modules may expect to find it still
// populated. This will reset the "new revision" flag, however the entity
// object will be marked as a new revision again on submit.
// @see \Drupal\Core\Entity\ContentEntityForm::buildEntity()
$revision_key = $new_revision->getEntityType()->getKey('revision');
$new_revision->set($revision_key, $new_revision->getLoadedRevisionId());
$form_object->setEntity($new_revision);
}
}
/**
* 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 formAlter(array &$form, FormStateInterface $form_state, $form_id) {
$form_object = $form_state->getFormObject();
if ($form_object instanceof BundleEntityFormBase) {
$config_entity = $form_object->getEntity();
$bundle_of = $config_entity->getEntityType()->getBundleOf();
if ($bundle_of
&& ($bundle_of_entity_type = $this->entityTypeManager->getDefinition($bundle_of))
&& $this->moderationInfo->shouldModerateEntitiesOfBundle($bundle_of_entity_type, $config_entity->id())) {
$this->entityTypeManager->getHandler($bundle_of, 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id);
}
}
elseif ($this->isModeratedEntityEditForm($form_object)) {
/** @var \Drupal\Core\Entity\ContentEntityFormInterface $form_object */
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $form_object->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'];
// Move the 'moderation_state' field widget to the footer region, if
// available.
if (isset($form['footer']) && in_array($form_object->getOperation(), ['edit', 'default'], TRUE)) {
$form['moderation_state']['#group'] = 'footer';
}
// If the publishing status exists in the meta region, replace it with
// the current state instead.
if (isset($form['meta']['published'])) {
$form['meta']['published']['#markup'] = $this->moderationInfo->getWorkflowForEntity($entity)->getTypePlugin()->getState($entity->moderation_state->value)->label();
}
}
}
/**
* Checks whether the specified form allows to edit a moderated entity.
*
* @param \Drupal\Core\Form\FormInterface $form_object
* The form object.
*
* @return bool
* TRUE if the form should get form moderation, FALSE otherwise.
*/
protected function isModeratedEntityEditForm(FormInterface $form_object) {
return $form_object instanceof ContentEntityFormInterface &&
in_array($form_object->getOperation(), ['edit', 'default', 'layout_builder'], TRUE) &&
$this->moderationInfo->isModeratedEntity($form_object->getEntity());
}
/**
* Redirect content entity edit forms on save, if there is a pending 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->hasPendingRevision($entity) && $entity->hasLinkTemplate('latest-version')) {
$entity_type_id = $entity->getEntityTypeId();
$form_state->setRedirect("entity.$entity_type_id.latest_version", [$entity_type_id => $entity->id()]);
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Drupal\content_moderation\EventSubscriber;
use Drupal\Core\Config\ConfigImporterEvent;
use Drupal\Core\Config\ConfigImportValidateEventSubscriberBase;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Config\Entity\ConfigEntityStorage;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Check moderation states are not being used before updating workflow config.
*/
class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase {
/**
* The config manager.
*
* @var \Drupal\Core\Config\ConfigManagerInterface
*/
protected $configManager;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs the event subscriber.
*
* @param \Drupal\Core\Config\ConfigManagerInterface $config_manager
* The config manager
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(ConfigManagerInterface $config_manager, EntityTypeManagerInterface $entity_type_manager) {
$this->configManager = $config_manager;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public function onConfigImporterValidate(ConfigImporterEvent $event) {
foreach (['update', 'delete'] as $op) {
$unprocessed_configurations = $event->getConfigImporter()->getUnprocessedConfiguration($op);
foreach ($unprocessed_configurations as $unprocessed_configuration) {
if (($workflow = $this->getWorkflow($unprocessed_configuration))
&& $workflow->getTypePlugin()->getPluginId() === 'content_moderation') {
if ($op === 'update') {
$original_workflow_config = $event->getConfigImporter()
->getStorageComparer()
->getSourceStorage()
->read($unprocessed_configuration);
$workflow_config = $event->getConfigImporter()
->getStorageComparer()
->getTargetStorage()
->read($unprocessed_configuration);
$diff = array_diff_key($workflow_config['type_settings']['states'], $original_workflow_config['type_settings']['states']);
foreach (array_keys($diff) as $state_id) {
$state = $workflow->getTypePlugin()->getState($state_id);
if ($workflow->getTypePlugin()->workflowStateHasData($workflow, $state)) {
$event->getConfigImporter()->logError($this->t('The moderation state @state_label is being used, but is not in the source storage.', ['@state_label' => $state->label()]));
}
}
}
if ($op === 'delete') {
if ($workflow->getTypePlugin()->workflowHasData($workflow)) {
$event->getConfigImporter()->logError($this->t('The workflow @workflow_label is being used, and cannot be deleted.', ['@workflow_label' => $workflow->label()]));
}
}
}
}
}
}
/**
* Get the workflow entity object from the configuration name.
*
* @param string $config_name
* The configuration object name.
*
* @return \Drupal\workflows\WorkflowInterface|null
* A workflow entity object. NULL if no matching entity is found.
*/
protected function getWorkflow($config_name) {
$entity_type_id = $this->configManager->getEntityTypeIdByName($config_name);
if ($entity_type_id !== 'workflow') {
return;
}
/** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
$entity_id = ConfigEntityStorage::getIDFromConfigName($config_name, $entity_type->getConfigPrefix());
return $this->entityTypeManager->getStorage($entity_type_id)->load($entity_id);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Drupal\content_moderation\EventSubscriber;
use Drupal\content_moderation\ContentModerationState;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\workspaces\Event\WorkspacePrePublishEvent;
use Drupal\workspaces\Event\WorkspacePublishEvent;
use Drupal\workspaces\WorkspaceAssociationInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks whether a workspace is publishable, and prevents publishing if needed.
*/
class WorkspaceSubscriber implements EventSubscriberInterface {
/**
* Constructs a new WorkspaceSubscriber instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager service.
* @param \Drupal\workspaces\WorkspaceAssociationInterface|null $workspaceAssociation
* The workspace association service.
*/
public function __construct(
protected readonly EntityTypeManagerInterface $entityTypeManager,
protected readonly ?WorkspaceAssociationInterface $workspaceAssociation,
) {}
/**
* Prevents a workspace from being published based on certain conditions.
*
* @param \Drupal\workspaces\Event\WorkspacePublishEvent $event
* The workspace publish event.
*/
public function onWorkspacePrePublish(WorkspacePublishEvent $event): void {
// Prevent a workspace from being published if there are any pending
// revisions in a moderation state that doesn't create default revisions.
$workspace = $event->getWorkspace();
$tracked_revisions = $this->workspaceAssociation->getTrackedEntities($workspace->id());
// Extract all the second-level keys (revision IDs) of the two-dimensional
// array.
$tracked_revision_ids = array_reduce(array_map('array_keys', $tracked_revisions), 'array_merge', []);
// Gather a list of moderation states that don't create a default revision.
$workflow_non_default_states = [];
foreach ($this->entityTypeManager->getStorage('workflow')->loadByProperties(['type' => 'content_moderation']) as $workflow) {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface $workflow_type */
$workflow_type = $workflow->getTypePlugin();
// Find all workflows which are moderating entity types of the same type
// to those that are tracked by the workspace.
if (array_intersect($workflow_type->getEntityTypes(), array_keys($tracked_revisions))) {
$workflow_non_default_states[$workflow->id()] = array_filter(array_map(function (ContentModerationState $state) {
return !$state->isDefaultRevisionState() ? $state->id() : NULL;
}, $workflow_type->getStates()));
}
}
// Check if any revisions that are about to be published are in a
// non-default revision moderation state.
$query = $this->entityTypeManager->getStorage('content_moderation_state')->getQuery()
->allRevisions()
->accessCheck(FALSE);
$query->condition('content_entity_revision_id', $tracked_revision_ids, 'IN');
$workflow_condition_group = $query->orConditionGroup();
foreach ($workflow_non_default_states as $workflow_id => $non_default_states) {
$group = $query->andConditionGroup()
->condition('workflow', $workflow_id, '=')
->condition('moderation_state', $non_default_states, 'IN');
$workflow_condition_group->condition($group);
}
$query->condition($workflow_condition_group);
if ($count = $query->count()->execute()) {
$message = \Drupal::translation()->formatPlural($count, 'The @label workspace can not be published because it contains 1 item in an unpublished moderation state.', 'The @label workspace can not be published because it contains @count items in an unpublished moderation state.', [
'@label' => $workspace->label(),
]);
$event->stopPublishing();
$event->setPublishingStoppedReason((string) $message);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$events[WorkspacePrePublishEvent::class][] = ['onWorkspacePrePublish'];
return $events;
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseDialogCommand;
use Drupal\Core\Ajax\HtmlCommand;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\workflows\WorkflowInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* The form for editing entity types associated with a workflow.
*
* @internal
*/
class ContentModerationConfigureEntityTypesForm extends FormBase {
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity type bundle information service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* The workflow entity object.
*
* @var \Drupal\workflows\WorkflowInterface
*/
protected $workflow;
/**
* The entity type definition object.
*
* @var \Drupal\Core\Entity\EntityTypeInterface
*/
protected $entityType;
/**
* The Messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('entity_type.bundle.info'),
$container->get('content_moderation.moderation_information'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info, ModerationInformationInterface $moderation_information, MessengerInterface $messenger) {
$this->entityTypeManager = $entity_type_manager;
$this->bundleInfo = $bundle_info;
$this->moderationInformation = $moderation_information;
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workflow_type_edit_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?WorkflowInterface $workflow = NULL, $entity_type_id = NULL) {
$this->workflow = $workflow;
try {
$this->entityType = $this->entityTypeManager->getDefinition($entity_type_id);
}
catch (PluginNotFoundException $e) {
throw new NotFoundHttpException();
}
$options = $defaults = [];
foreach ($this->bundleInfo->getBundleInfo($this->entityType->id()) as $bundle_id => $bundle) {
// Check if moderation is enabled for this bundle on any workflow.
$moderation_enabled = $this->moderationInformation->shouldModerateEntitiesOfBundle($this->entityType, $bundle_id);
// Check if moderation is enabled for this bundle on this workflow.
$workflow_moderation_enabled = $this->workflow->getTypePlugin()->appliesToEntityTypeAndBundle($this->entityType->id(), $bundle_id);
// Only show bundles that are not enabled anywhere, or enabled on this
// workflow.
if (!$moderation_enabled || $workflow_moderation_enabled) {
// Add the bundle to the options if it's not enabled on a workflow,
// unless the workflow it's enabled on is this one.
$options[$bundle_id] = [
'title' => ['data' => ['#title' => $bundle['label']]],
'type' => $bundle['label'],
];
// Add the bundle to the list of default values if it's enabled on this
// workflow.
$defaults[$bundle_id] = $workflow_moderation_enabled;
}
}
if (!empty($options)) {
$bundles_header = $this->t('All @entity_type types', ['@entity_type' => $this->entityType->getLabel()]);
if ($bundle_entity_type_id = $this->entityType->getBundleEntityType()) {
$bundles_header = $this->t('All @entity_type_plural_label', ['@entity_type_plural_label' => $this->entityTypeManager->getDefinition($bundle_entity_type_id)->getPluralLabel()]);
}
$form['bundles'] = [
'#type' => 'tableselect',
'#header' => [
'type' => $bundles_header,
],
'#options' => $options,
'#default_value' => $defaults,
'#attributes' => ['class' => ['no-highlight']],
];
}
// Get unsupported features for this entity type.
$warnings = $this->moderationInformation->getUnsupportedFeatures($this->entityType);
// Display message into the Ajax form returned.
if ($this->getRequest()->get(MainContentViewSubscriber::WRAPPER_FORMAT) == 'drupal_modal' && !empty($warnings)) {
$form['warnings'] = ['#type' => 'status_messages', '#weight' => -1];
}
// Set warning message.
foreach ($warnings as $warning) {
$this->messenger->addWarning($warning);
}
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#button_type' => 'primary',
'#value' => $this->t('Save'),
'#ajax' => [
'callback' => [$this, 'ajaxCallback'],
],
];
$form['actions']['cancel'] = [
'#type' => 'button',
'#value' => $this->t('Cancel'),
'#ajax' => [
'callback' => [$this, 'ajaxCallback'],
],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
foreach ($form_state->getValue('bundles') as $bundle_id => $checked) {
if ($checked) {
$this->workflow->getTypePlugin()->addEntityTypeAndBundle($this->entityType->id(), $bundle_id);
}
else {
$this->workflow->getTypePlugin()->removeEntityTypeAndBundle($this->entityType->id(), $bundle_id);
}
}
$this->workflow->save();
}
/**
* Ajax callback to close the modal and update the selected text.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An ajax response object.
*/
public function ajaxCallback() {
$selected_bundles = [];
foreach ($this->bundleInfo->getBundleInfo($this->entityType->id()) as $bundle_id => $bundle) {
if ($this->workflow->getTypePlugin()->appliesToEntityTypeAndBundle($this->entityType->id(), $bundle_id)) {
$selected_bundles[$bundle_id] = $bundle['label'];
}
}
$selected_bundles_list = [
'#theme' => 'item_list',
'#items' => $selected_bundles,
'#context' => ['list_style' => 'comma-list'],
'#empty' => $this->t('none'),
];
$response = new AjaxResponse();
$response->addCommand(new CloseDialogCommand());
$response->addCommand(new HtmlCommand('#selected-' . $this->entityType->id(), $selected_bundles_list));
return $response;
}
/**
* Route title callback.
*/
public function getTitle(WorkflowInterface $workflow, $entity_type_id) {
$this->entityType = $this->entityTypeManager->getDefinition($entity_type_id);
$title = $this->t('Select the @entity_type types for the @workflow workflow', ['@entity_type' => $this->entityType->getLabel(), '@workflow' => $workflow->label()]);
if ($bundle_entity_type_id = $this->entityType->getBundleEntityType()) {
$title = $this->t('Select the @entity_type_plural_label for the @workflow workflow', ['@entity_type_plural_label' => $this->entityTypeManager->getDefinition($bundle_entity_type_id)->getPluralLabel(), '@workflow' => $workflow->label()]);
}
return $title;
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Component\Serialization\Json;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\workflows\Plugin\WorkflowTypeConfigureFormBase;
use Drupal\workflows\State;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The content moderation WorkflowType configuration form.
*
* @see \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration
*/
class ContentModerationConfigureForm extends WorkflowTypeConfigureFormBase implements ContainerInjectionInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The moderation info service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* The entity type bundle info service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $entityTypeBundleInfo;
/**
* Create an instance of ContentModerationConfigureForm.
*/
public function __construct(EntityTypeManagerInterface $entityTypeManager, ModerationInformationInterface $moderationInformation, EntityTypeBundleInfoInterface $entityTypeBundleInfo) {
$this->entityTypeManager = $entityTypeManager;
$this->moderationInfo = $moderationInformation;
$this->entityTypeBundleInfo = $entityTypeBundleInfo;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('content_moderation.moderation_information'),
$container->get('entity_type.bundle.info')
);
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$workflow = $form_state->getFormObject()->getEntity();
$header = [
'type' => $this->t('Items'),
'operations' => $this->t('Operations'),
];
$form['entity_types_container'] = [
'#type' => 'details',
'#title' => $this->t('This workflow applies to:'),
'#open' => TRUE,
];
$form['entity_types_container']['entity_types'] = [
'#type' => 'table',
'#header' => $header,
'#empty' => $this->t('There are no entity types.'),
];
$entity_types = $this->entityTypeManager->getDefinitions();
foreach ($entity_types as $entity_type) {
if (!$this->moderationInfo->canModerateEntitiesOfEntityType($entity_type)) {
continue;
}
$selected_bundles = [];
foreach ($this->entityTypeBundleInfo->getBundleInfo($entity_type->id()) as $bundle_id => $bundle) {
if ($this->workflowType->appliesToEntityTypeAndBundle($entity_type->id(), $bundle_id)) {
$selected_bundles[$bundle_id] = $bundle['label'];
}
}
$selected_bundles_list = [
'#theme' => 'item_list',
'#items' => $selected_bundles,
'#context' => ['list_style' => 'comma-list'],
'#empty' => $this->t('none'),
];
$form['entity_types_container']['entity_types'][$entity_type->id()] = [
'type' => [
'#type' => 'inline_template',
'#template' => '<strong>{{ label }}</strong><br><span id="selected-{{ entity_type_id }}">{{ selected_bundles }}</span>',
'#context' => [
'label' => $this->t('@bundle types', ['@bundle' => $entity_type->getLabel()]),
'entity_type_id' => $entity_type->id(),
'selected_bundles' => $selected_bundles_list,
],
],
'operations' => [
'#type' => 'operations',
'#links' => [
'select' => [
'title' => $this->t('Select'),
'url' => Url::fromRoute('content_moderation.workflow_type_edit_form', ['workflow' => $workflow->id(), 'entity_type_id' => $entity_type->id()]),
'attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'modal',
'data-dialog-options' => Json::encode([
'width' => 880,
]),
],
],
],
],
];
}
$workflow_type_configuration = $this->workflowType->getConfiguration();
$form['workflow_settings'] = [
'#type' => 'details',
'#title' => $this->t('Workflow Settings'),
'#open' => TRUE,
];
$form['workflow_settings']['default_moderation_state'] = [
'#title' => $this->t('Default moderation state'),
'#type' => 'select',
'#required' => TRUE,
'#options' => array_map([State::class, 'labelCallback'], $this->workflowType->getStates()),
'#description' => $this->t('Select the state that new content will be assigned. This state will appear as the default in content forms and the available target states will be based on the transitions available from this state.'),
'#default_value' => $workflow_type_configuration['default_moderation_state'] ?? 'draft',
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$configuration = $this->workflowType->getConfiguration();
$configuration['default_moderation_state'] = $form_state->getValue(['workflow_settings', 'default_moderation_state']);
$this->workflowType->setConfiguration($configuration);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Core\Form\FormStateInterface;
use Drupal\workflows\Plugin\WorkflowTypeStateFormBase;
use Drupal\workflows\StateInterface;
/**
* The content moderation state form.
*
* @see \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration
*/
class ContentModerationStateForm extends WorkflowTypeStateFormBase {
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state, ?StateInterface $state = NULL) {
/** @var \Drupal\content_moderation\ContentModerationState $state */
$state = $form_state->get('state');
$is_required_state = isset($state) ? in_array($state->id(), $this->workflowType->getRequiredStates(), TRUE) : FALSE;
$form = [];
$form['published'] = [
'#type' => 'checkbox',
'#title' => $this->t('Published'),
'#description' => $this->t('When content reaches this state it should be published.'),
'#default_value' => isset($state) ? $state->isPublishedState() : FALSE,
'#disabled' => $is_required_state,
];
$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' => isset($state) ? $state->isDefaultRevisionState() : FALSE,
'#disabled' => $is_required_state,
// @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;
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\content_moderation\StateTransitionValidationInterface;
use Drupal\workflows\Transition;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The EntityModerationForm provides a simple UI for changing moderation state.
*
* @internal
*/
class EntityModerationForm extends FormBase {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* The moderation state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidationInterface
*/
protected $validation;
/**
* EntityModerationForm constructor.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
* @param \Drupal\content_moderation\StateTransitionValidationInterface $validation
* The moderation state transition validation service.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(ModerationInformationInterface $moderation_info, StateTransitionValidationInterface $validation, TimeInterface $time) {
$this->moderationInfo = $moderation_info;
$this->validation = $validation;
$this->time = $time;
}
/**
* {@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('datetime.time')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'content_moderation_entity_moderation_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?ContentEntityInterface $entity = NULL) {
$current_state = $entity->moderation_state->value;
$workflow = $this->moderationInfo->getWorkflowForEntity($entity);
/** @var \Drupal\workflows\Transition[] $transitions */
$transitions = $this->validation->getValidTransitions($entity, $this->currentUser());
// Exclude self-transitions.
$transitions = array_filter($transitions, function (Transition $transition) use ($current_state) {
return $transition->to()->id() != $current_state;
});
$target_states = [];
foreach ($transitions as $transition) {
$target_states[$transition->to()->id()] = $transition->to()->label();
}
if (!count($target_states)) {
return $form;
}
if ($current_state) {
$form['current'] = [
'#type' => 'item',
'#title' => $this->t('Moderation state'),
'#markup' => $workflow->getTypePlugin()->getState($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('Change to'),
'#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'];
$form['#attached']['library'][] = 'content_moderation/content_moderation';
// Moderating an entity is allowed in a workspace.
$form_state->set('workspace_safe', TRUE);
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $form_state->get('entity');
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId());
$entity = $storage->createRevision($entity, $entity->isDefaultRevision());
$new_state = $form_state->getValue('new_state');
$entity->set('moderation_state', $new_state);
if ($entity instanceof RevisionLogInterface) {
$entity->setRevisionCreationTime($this->time->getRequestTime());
$entity->setRevisionLogMessage($form_state->getValue('revision_log'));
$entity->setRevisionUserId($this->currentUser()->id());
}
$entity->save();
$this->messenger()->addStatus($this->t('The moderation state has been updated.'));
$new_state = $this->moderationInfo->getWorkflowForEntity($entity)->getTypePlugin()->getState($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
// pending revision. Redirect to the canonical URL instead, since that will
// still exist.
if ($new_state->isDefaultRevisionState()) {
$form_state->setRedirectUrl($entity->toUrl('canonical'));
}
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RedirectDestinationInterface;
use Drupal\node\NodeListBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class to build a listing of moderated node entities.
*/
class ModeratedNodeListBuilder extends NodeListBuilder {
/**
* The entity storage class.
*
* @var \Drupal\Core\Entity\RevisionableStorageInterface
*/
protected $storage;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new ModeratedNodeListBuilder object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\Core\Entity\EntityStorageInterface $storage
* The entity storage class.
* @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
* The date formatter service.
* @param \Drupal\Core\Routing\RedirectDestinationInterface $redirect_destination
* The redirect destination service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, DateFormatterInterface $date_formatter, RedirectDestinationInterface $redirect_destination, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($entity_type, $storage, $date_formatter, $redirect_destination);
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
$entity_type_manager = $container->get('entity_type.manager');
return new static(
$entity_type,
$entity_type_manager->getStorage($entity_type->id()),
$container->get('date.formatter'),
$container->get('redirect.destination'),
$entity_type_manager
);
}
/**
* {@inheritdoc}
*/
public function load() {
$revision_ids = $this->getEntityRevisionIds();
return $this->storage->loadMultipleRevisions($revision_ids);
}
/**
* Loads entity revision IDs using a pager sorted by the entity revision ID.
*
* @return array
* An array of entity revision IDs.
*/
protected function getEntityRevisionIds() {
$query = $this->entityTypeManager->getStorage('content_moderation_state')->getAggregateQuery()
->accessCheck(TRUE)
->aggregate('content_entity_id', 'MAX')
->groupBy('content_entity_revision_id')
->condition('content_entity_type_id', $this->entityTypeId)
->condition('moderation_state', 'published', '<>')
->sort('content_entity_revision_id', 'DESC');
// Only add the pager if a limit is specified.
if ($this->limit) {
$query->pager($this->limit);
}
$result = $query->execute();
return $result ? array_column($result, 'content_entity_revision_id') : [];
}
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header = parent::buildHeader();
$header['status'] = $this->t('Moderation state');
return $header;
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
$row = parent::buildRow($entity);
$row['status'] = $entity->moderation_state->value;
return $row;
}
/**
* {@inheritdoc}
*/
public function render() {
$build = parent::render();
$build['table']['#empty'] = $this->t('There is no moderated @label yet. Only pending versions of @label, such as drafts, are listed here.', ['@label' => $this->entityType->getLabel()]);
return $build;
}
}

View File

@@ -0,0 +1,256 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* General service for moderation-related questions about Entity API.
*/
class ModerationInformation implements ModerationInformationInterface {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The bundle information service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* Creates a new ModerationInformation instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info
* The bundle information service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info) {
$this->entityTypeManager = $entity_type_manager;
$this->bundleInfo = $bundle_info;
}
/**
* {@inheritdoc}
*/
public function isModeratedEntity(EntityInterface $entity) {
if (!$entity instanceof ContentEntityInterface) {
return FALSE;
}
if (!$this->shouldModerateEntitiesOfBundle($entity->getEntityType(), $entity->bundle())) {
return FALSE;
}
return $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->isModeratedEntity($entity);
}
/**
* {@inheritdoc}
*/
public function isModeratedEntityType(EntityTypeInterface $entity_type) {
$bundles = $this->bundleInfo->getBundleInfo($entity_type->id());
return !empty(array_column($bundles, 'workflow'));
}
/**
* {@inheritdoc}
*/
public function canModerateEntitiesOfEntityType(EntityTypeInterface $entity_type) {
return $entity_type->hasHandlerClass('moderation');
}
/**
* {@inheritdoc}
*/
public function shouldModerateEntitiesOfBundle(EntityTypeInterface $entity_type, $bundle) {
if ($this->canModerateEntitiesOfEntityType($entity_type)) {
$bundles = $this->bundleInfo->getBundleInfo($entity_type->id());
return isset($bundles[$bundle]['workflow']);
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getDefaultRevisionId($entity_type_id, $entity_id) {
if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) {
$result = $storage->getQuery()
->currentRevision()
->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id)
// No access check is performed here since this is an API function and
// should return the same ID regardless of the current user.
->accessCheck(FALSE)
->execute();
if ($result) {
return key($result);
}
}
}
/**
* {@inheritdoc}
*/
public function getAffectedRevisionTranslation(ContentEntityInterface $entity) {
foreach ($entity->getTranslationLanguages() as $language) {
$translation = $entity->getTranslation($language->getId());
if (!$translation->isDefaultRevision() && $translation->isRevisionTranslationAffected()) {
return $translation;
}
}
}
/**
* {@inheritdoc}
*/
public function hasPendingRevision(ContentEntityInterface $entity) {
$result = FALSE;
if ($this->isModeratedEntity($entity)) {
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
$latest_revision_id = $storage->getLatestTranslationAffectedRevisionId($entity->id(), $entity->language()->getId());
$default_revision_id = $entity->isDefaultRevision() && !$entity->isNewRevision() && ($revision_id = $entity->getRevisionId()) ?
$revision_id : $this->getDefaultRevisionId($entity->getEntityTypeId(), $entity->id());
if ($latest_revision_id !== NULL && $latest_revision_id != $default_revision_id) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $latest_revision */
$latest_revision = $storage->loadRevision($latest_revision_id);
$result = !$latest_revision->wasDefaultRevision();
}
}
return $result;
}
/**
* {@inheritdoc}
*/
public function isLiveRevision(ContentEntityInterface $entity) {
$workflow = $this->getWorkflowForEntity($entity);
return $entity->isLatestRevision()
&& $entity->isDefaultRevision()
&& $entity->moderation_state->value
&& $workflow->getTypePlugin()->getState($entity->moderation_state->value)->isPublishedState();
}
/**
* {@inheritdoc}
*/
public function isDefaultRevisionPublished(ContentEntityInterface $entity) {
$workflow = $this->getWorkflowForEntity($entity);
$default_revision = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->load($entity->id());
// If no default revision could be loaded, the entity has not yet been
// saved. In this case the moderation_state of the unsaved entity can be
// used, since once saved it will become the default.
$default_revision = $default_revision ?: $entity;
// Ensure we are checking all translations of the default revision.
if ($default_revision instanceof TranslatableInterface && $default_revision->isTranslatable()) {
// Loop through each language that has a translation.
foreach ($default_revision->getTranslationLanguages() as $language) {
// Load the translated revision.
$translation = $default_revision->getTranslation($language->getId());
// If the moderation state is empty, it was not stored yet so no point
// in doing further work.
$moderation_state = $translation->moderation_state->value;
if (!$moderation_state) {
continue;
}
// Return TRUE if a translation with a published state is found.
if ($workflow->getTypePlugin()->getState($moderation_state)->isPublishedState()) {
return TRUE;
}
}
}
return $workflow->getTypePlugin()->getState($default_revision->moderation_state->value)->isPublishedState();
}
/**
* {@inheritdoc}
*/
public function getWorkflowForEntity(ContentEntityInterface $entity) {
return $this->getWorkflowForEntityTypeAndBundle($entity->getEntityTypeId(), $entity->bundle());
}
/**
* {@inheritdoc}
*/
public function getWorkflowForEntityTypeAndBundle($entity_type_id, $bundle_id) {
$bundles = $this->bundleInfo->getBundleInfo($entity_type_id);
if (isset($bundles[$bundle_id]['workflow'])) {
return $this->entityTypeManager->getStorage('workflow')->load($bundles[$bundle_id]['workflow']);
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function getUnsupportedFeatures(EntityTypeInterface $entity_type) {
$features = [];
// Test if entity is publishable.
if (!$entity_type->entityClassImplements(EntityPublishedInterface::class)) {
$features['publishing'] = $this->t("@entity_type_plural_label do not support publishing statuses. For example, even after transitioning from a published workflow state to an unpublished workflow state they will still be visible to site visitors.", ['@entity_type_plural_label' => $entity_type->getCollectionLabel()]);
}
return $features;
}
/**
* {@inheritdoc}
*/
public function getOriginalState(ContentEntityInterface $entity) {
$state = NULL;
$workflow_type = $this->getWorkflowForEntity($entity)->getTypePlugin();
if (!$entity->isNew() && !$this->isFirstTimeModeration($entity)) {
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
/** @var \Drupal\Core\Entity\ContentEntityInterface $original_entity */
$original_entity = $storage->loadRevision($entity->getLoadedRevisionId());
if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
$original_entity = $original_entity->getTranslation($entity->language()->getId());
}
if ($workflow_type->hasState($original_entity->moderation_state->value)) {
$state = $workflow_type->getState($original_entity->moderation_state->value);
}
}
return $state ?: $workflow_type->getInitialState($entity);
}
/**
* 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\ContentEntityInterface $entity
* The entity being moderated.
*
* @return bool
* TRUE if this is the entity's first time being moderated, FALSE otherwise.
*/
protected function isFirstTimeModeration(ContentEntityInterface $entity) {
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
$original_entity = $storage->loadRevision($storage->getLatestRevisionId($entity->id()));
if ($original_entity) {
$original_id = $original_entity->moderation_state;
}
return !($entity->moderation_state && $original_entity && $original_id);
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Interface for moderation_information service.
*/
interface ModerationInformationInterface {
/**
* Determines if an entity is moderated.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity we may be moderating.
*
* @return bool
* TRUE if this entity is moderated, FALSE otherwise.
*/
public function isModeratedEntity(EntityInterface $entity);
/**
* Determines if an entity type can have moderated entities.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* An entity type object.
*
* @return bool
* TRUE if this entity type can have moderated entities, FALSE otherwise.
*/
public function canModerateEntitiesOfEntityType(EntityTypeInterface $entity_type);
/**
* Determines if an entity type/bundle entities should 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 an entity type/bundle entities should be moderated, FALSE
* otherwise.
*/
public function shouldModerateEntitiesOfBundle(EntityTypeInterface $entity_type, $bundle);
/**
* Determines if an entity type has at least one moderated bundle.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition to check.
*
* @return bool
* TRUE if an entity type has a moderated bundle, FALSE otherwise.
*/
public function isModeratedEntityType(EntityTypeInterface $entity_type);
/**
* 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);
/**
* Returns the revision translation affected translation of a revision.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The content entity.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* The revision translation affected translation.
*/
public function getAffectedRevisionTranslation(ContentEntityInterface $entity);
/**
* Determines if a pending revision exists for the specified entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity which may or may not have a pending revision.
*
* @return bool
* TRUE if this entity has pending revisions available, FALSE otherwise.
*/
public function hasPendingRevision(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);
/**
* Determines 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, check if any of the
* translations are published.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being saved.
*
* @return bool
* TRUE if the default revision is published. FALSE otherwise.
*/
public function isDefaultRevisionPublished(ContentEntityInterface $entity);
/**
* Gets the workflow for the given content entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The content entity to get the workflow for.
*
* @return \Drupal\workflows\WorkflowInterface|null
* The workflow entity. NULL if there is no workflow.
*/
public function getWorkflowForEntity(ContentEntityInterface $entity);
/**
* Gets the workflow for the given entity type and bundle.
*
* @param string $entity_type_id
* The entity type ID.
* @param string $bundle_id
* The entity bundle ID.
*
* @return \Drupal\workflows\WorkflowInterface|null
* The associated workflow. NULL if there is no workflow.
*/
public function getWorkflowForEntityTypeAndBundle($entity_type_id, $bundle_id);
/**
* Gets unsupported features for a given entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type to get the unsupported features for.
*
* @return array
* An array of unsupported features for this entity type.
*/
public function getUnsupportedFeatures(EntityTypeInterface $entity_type);
/**
* Gets the original or initial state of the given entity.
*
* When a state is being validated, the original state is used to validate
* that a valid transition exists for target state and the user has access
* to the transition between those two states. If the entity has been
* moderated before, we can load the original unmodified revision and
* translation for this state.
*
* If the entity is new we need to load the initial state from the workflow.
* Even if a value was assigned to the moderation_state field, the initial
* state is used to compute an appropriate transition for the purposes of
* validation.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The content entity to get the workflow for.
*
* @return \Drupal\content_moderation\ContentModerationState
* The original or default moderation state.
*/
public function getOriginalState(ContentEntityInterface $entity);
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\workflows\Entity\Workflow;
use Drupal\workflows\State;
/**
* Defines a class for dynamic permissions based on transitions.
*
* @internal
*/
class Permissions {
use StringTranslationTrait;
/**
* Returns an array of transition permissions.
*
* @return array
* The transition permissions.
*/
public function transitionPermissions() {
$permissions = [];
/** @var \Drupal\workflows\WorkflowInterface $workflow */
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
foreach ($workflow->getTypePlugin()->getTransitions() as $transition) {
$permissions['use ' . $workflow->id() . ' transition ' . $transition->id()] = [
'title' => $this->t('%workflow workflow: Use %transition transition.', [
'%workflow' => $workflow->label(),
'%transition' => $transition->label(),
]),
'description' => $this->formatPlural(
count($transition->from()),
'Move content from %from state to %to state.',
'Move content from %from states to %to state.', [
'%from' => implode(', ', array_map([State::class, 'labelCallback'], $transition->from())),
'%to' => $transition->to()->label(),
]
),
'dependencies' => [
$workflow->getConfigDependencyKey() => [$workflow->getConfigDependencyName()],
],
];
}
}
return $permissions;
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Drupal\content_moderation\Plugin\Action;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Action\Plugin\Action\PublishAction;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Alternate action plugin that can opt-out of modifying moderated entities.
*
* @see \Drupal\Core\Action\Plugin\Action\PublishAction
*/
class ModerationOptOutPublish extends PublishAction implements ContainerFactoryPluginInterface {
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* Bundle info service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* Messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* ModerationOptOutPublish 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\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info
* Bundle info service.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* Messenger service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_info, EntityTypeBundleInfoInterface $bundle_info, MessengerInterface $messenger) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager);
$this->moderationInfo = $moderation_info;
$this->bundleInfo = $bundle_info;
$this->messenger = $messenger;
}
/**
* {@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('content_moderation.moderation_information'),
$container->get('entity_type.bundle.info'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function access($entity, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
if ($entity && $this->moderationInfo->isModeratedEntity($entity)) {
$bundle_info = $this->bundleInfo->getBundleInfo($entity->getEntityTypeId());
$bundle_label = $bundle_info[$entity->bundle()]['label'];
$this->messenger->addWarning($this->t("@bundle @label were skipped as they are under moderation and may not be directly published.", [
'@bundle' => $bundle_label,
'@label' => $entity->getEntityType()->getPluralLabel(),
]));
$result = AccessResult::forbidden('Cannot directly publish moderated entities.');
return $return_as_object ? $result : $result->isAllowed();
}
return parent::access($entity, $account, $return_as_object);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace Drupal\content_moderation\Plugin\Action;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Action\Plugin\Action\UnpublishAction;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Alternate action plugin that can opt-out of modifying moderated entities.
*
* @see \Drupal\Core\Action\Plugin\Action\UnpublishAction
*/
class ModerationOptOutUnpublish extends UnpublishAction {
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* Bundle info service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* Messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* ModerationOptOutUnpublish 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\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info
* Bundle info service.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* Messenger service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_info, EntityTypeBundleInfoInterface $bundle_info, MessengerInterface $messenger) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager);
$this->moderationInfo = $moderation_info;
$this->bundleInfo = $bundle_info;
$this->messenger = $messenger;
}
/**
* {@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('content_moderation.moderation_information'),
$container->get('entity_type.bundle.info'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function access($entity, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
if ($entity && $this->moderationInfo->isModeratedEntity($entity)) {
$bundle_info = $this->bundleInfo->getBundleInfo($entity->getEntityTypeId());
$bundle_label = $bundle_info[$entity->bundle()]['label'];
$this->messenger->addWarning($this->t("@bundle @label were skipped as they are under moderation and may not be directly unpublished.", [
'@bundle' => $bundle_label,
'@label' => $entity->getEntityType()->getPluralLabel(),
]));
$result = AccessResult::forbidden('Cannot directly unpublish moderated entities.');
return $return_as_object ? $result : $result->isAllowed();
}
return parent::access($entity, $account, $return_as_object);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Drupal\content_moderation\Plugin\ConfigAction;
use Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface;
use Drupal\Core\Config\Action\Attribute\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Config\Action\ConfigActionPluginInterface;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\workflows\WorkflowInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
#[ConfigAction(
id: 'add_moderation',
entity_types: ['workflow'],
deriver: AddModerationDeriver::class,
)]
final class AddModeration implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
public function __construct(
private readonly ConfigManagerInterface $configManager,
private readonly EntityTypeManagerInterface $entityTypeManager,
private readonly string $pluginId,
private readonly string $targetEntityType,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
assert(is_array($plugin_definition));
$target_entity_type = $plugin_definition['target_entity_type'];
return new static(
$container->get(ConfigManagerInterface::class),
$container->get(EntityTypeManagerInterface::class),
$plugin_id,
$target_entity_type,
);
}
/**
* {@inheritdoc}
*/
public function apply(string $configName, mixed $value): void {
$workflow = $this->configManager->loadConfigEntityByName($configName);
assert($workflow instanceof WorkflowInterface);
$plugin = $workflow->getTypePlugin();
if (!$plugin instanceof ContentModerationInterface) {
throw new ConfigActionException("The $this->pluginId config action only works with Content Moderation workflows.");
}
assert($value === '*' || is_array($value));
if ($value === '*') {
/** @var \Drupal\Core\Entity\EntityTypeInterface $definition */
$definition = $this->entityTypeManager->getDefinition($this->targetEntityType);
/** @var string $bundle_entity_type */
$bundle_entity_type = $definition->getBundleEntityType();
$value = $this->entityTypeManager->getStorage($bundle_entity_type)
->getQuery()
->accessCheck(FALSE)
->execute();
}
foreach ($value as $bundle) {
$plugin->addEntityTypeAndBundle($this->targetEntityType, $bundle);
}
$workflow->save();
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Drupal\content_moderation\Plugin\ConfigAction;
// cspell:ignore inflector
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\String\Inflector\EnglishInflector;
final class AddModerationDeriver extends DeriverBase implements ContainerDeriverInterface {
use StringTranslationTrait;
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id): static {
return new static(
$container->get(EntityTypeManagerInterface::class),
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
$inflector = new EnglishInflector();
foreach ($this->entityTypeManager->getDefinitions() as $id => $entity_type) {
if ($bundle_entity_type = $entity_type->getBundleEntityType()) {
/** @var \Drupal\Core\Entity\EntityTypeInterface $bundle_entity_type */
$bundle_entity_type = $this->entityTypeManager->getDefinition($bundle_entity_type);
// Convert unique plugin IDs, like `taxonomy_vocabulary`, into strings
// like `TaxonomyVocabulary`.
$suffix = Container::camelize($bundle_entity_type->id());
[$suffix] = $inflector->pluralize($suffix);
$this->derivatives["add{$suffix}"] = [
'target_entity_type' => $id,
'admin_label' => $this->t('Add moderation to all @bundles', [
'@bundles' => $bundle_entity_type->getPluralLabel() ?: $bundle_entity_type->id(),
]),
] + $base_plugin_definition;
}
}
return parent::getDerivativeDefinitions($base_plugin_definition);
}
}

View File

@@ -0,0 +1,118 @@
<?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;
use Symfony\Component\Routing\RouterInterface;
/**
* 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;
/**
* The router.
*
* @var \Symfony\Component\Routing\RouterInterface
*/
protected $router;
/**
* Creates a 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.
* @param \Symfony\Component\Routing\RouterInterface $router
* The router.
*/
public function __construct($base_plugin_id, EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation, ModerationInformationInterface $moderation_information, RouterInterface $router) {
$this->entityTypeManager = $entity_type_manager;
$this->stringTranslation = $string_translation;
$this->basePluginId = $base_plugin_id;
$this->moderationInfo = $moderation_information;
$this->router = $router;
}
/**
* {@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'),
$container->get('router')
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
$this->derivatives = [];
// Add the moderated content task if the route exists.
if ($this->router->getRouteCollection()->get('content_moderation.admin_moderated_content') !== NULL) {
$this->derivatives['content_moderation.moderated_content'] = [
'route_name' => 'content_moderation.admin_moderated_content',
'title' => $this->t('Moderated content'),
'parent_id' => 'system.admin_content',
'weight' => 1,
];
}
// Add the latest version tab to entities.
$latest_version_entities = array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $type) {
return $this->moderationInfo->canModerateEntitiesOfEntityType($type) && $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;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Drupal\content_moderation\Plugin\Field\FieldFormatter;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'content_moderation_state' formatter.
*/
#[FieldFormatter(
id: 'content_moderation_state',
label: new TranslatableMarkup('Content moderation state'),
field_types: [
'string',
],
)]
class ContentModerationStateFormatter extends FormatterBase {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* Create an instance of ContentModerationStateFormatter.
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, ModerationInformationInterface $moderation_information) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings);
$this->moderationInformation = $moderation_information;
}
/**
* {@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['label'],
$configuration['view_mode'],
$configuration['third_party_settings'],
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
$workflow = $this->moderationInformation->getWorkflowForEntity($items->getEntity());
foreach ($items as $delta => $item) {
$elements[$delta] = [
'#markup' => $workflow->getTypePlugin()->getState($item->value)->label(),
];
}
return $elements;
}
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
return $field_definition->getName() === 'moderation_state' && $field_definition->getTargetEntityTypeId() !== 'content_moderation_state';
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace Drupal\content_moderation\Plugin\Field\FieldWidget;
use Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\Attribute\FieldWidget;
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\Session\AccountInterface;
use Drupal\content_moderation\ModerationInformation;
use Drupal\content_moderation\StateTransitionValidationInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'moderation_state_default' widget.
*/
#[FieldWidget(
id: 'moderation_state_default',
label: new TranslatableMarkup('Moderation state'),
field_types: ['string'],
)]
class ModerationStateWidget extends OptionsSelectWidget {
/**
* Current user service.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformation
*/
protected $moderationInformation;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Moderation state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidationInterface
*/
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\content_moderation\ModerationInformation $moderation_information
* Moderation information service.
* @param \Drupal\content_moderation\StateTransitionValidationInterface $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, ModerationInformation $moderation_information, StateTransitionValidationInterface $validator) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
$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('content_moderation.moderation_information'),
$container->get('content_moderation.state_transition_validation')
);
}
/**
* {@inheritdoc}
*/
public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) {
$entity = $items->getEntity();
if (!$this->moderationInformation->isModeratedEntity($entity)) {
return [];
}
return parent::form($items, $form, $form_state, $get_delta);
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $original_entity = $items->getEntity();
$default = $this->moderationInformation->getOriginalState($entity);
// If the entity already exists, grab the most recent revision and load it.
// The moderation state of the saved revision will be used to display the
// current state as well determine the appropriate transitions.
if (!$entity->isNew()) {
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
/** @var \Drupal\Core\Entity\ContentEntityInterface $original_entity */
$original_entity = $storage->loadRevision($entity->getLoadedRevisionId());
if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
$original_entity = $original_entity->getTranslation($entity->language()->getId());
}
}
// For a new entity, ensure the moderation state of the original entity is
// always the default state. Despite the entity being unsaved, it may have
// previously been set to a new target state, for example previewed entities
// are retrieved from temporary storage with field values set.
else {
$original_entity->set('moderation_state', $default->id());
}
/** @var \Drupal\workflows\Transition[] $transitions */
$transitions = $this->validator->getValidTransitions($original_entity, $this->currentUser);
$transition_labels = [];
$default_value = $items->value;
foreach ($transitions as $transition) {
$transition_to_state = $transition->to();
$transition_labels[$transition_to_state->id()] = $transition_to_state->label();
if ($default->id() === $transition_to_state->id()) {
$default_value = $default->id();
}
}
$element += [
'#type' => 'container',
'current' => [
'#type' => 'item',
'#title' => $this->t('Current state'),
'#markup' => $default->label(),
'#access' => !$entity->isNew(),
'#wrapper_attributes' => [
'class' => ['container-inline'],
],
],
'state' => [
'#type' => 'select',
'#title' => $entity->isNew() ? $this->t('Save as') : $this->t('Change to'),
'#key_column' => $this->column,
'#options' => $transition_labels,
'#default_value' => $default_value,
'#access' => !empty($transition_labels),
'#wrapper_attributes' => [
'class' => ['container-inline'],
],
],
];
$element['#element_validate'][] = [static::class, 'validateElement'];
return $element;
}
/**
* {@inheritdoc}
*/
public static function validateElement(array $element, FormStateInterface $form_state) {
$form_state->setValueForElement($element, [$element['state']['#key_column'] => $element['state']['#value']]);
}
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
return is_a($field_definition->getClass(), ModerationStateFieldItemList::class, TRUE);
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$dependencies = parent::calculateDependencies();
if ($workflow = $this->moderationInformation->getWorkflowForEntityTypeAndBundle($this->fieldDefinition->getTargetEntityTypeId(), $this->fieldDefinition->getTargetBundle())) {
$dependencies[$workflow->getConfigDependencyKey()][] = $workflow->getConfigDependencyName();
}
return $dependencies;
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace Drupal\content_moderation\Plugin\Field;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Field\FieldItemList;
use Drupal\Core\TypedData\ComputedItemListTrait;
/**
* 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 FieldItemList {
use ComputedItemListTrait {
get as traitGet;
}
/**
* {@inheritdoc}
*/
protected function computeValue() {
$moderation_state = $this->getModerationStateId();
// Do not store NULL values, in the case where an entity does not have a
// moderation workflow associated with it, we do not create list items for
// the computed field.
if ($moderation_state) {
// An entity can only have a single moderation state.
$this->list[0] = $this->createItem(0, $moderation_state);
}
}
/**
* Gets the moderation state ID linked to a content entity revision.
*
* @return string|null
* The moderation state ID linked to a content entity revision.
*/
protected function getModerationStateId() {
$entity = $this->getEntity();
/** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
$moderation_info = \Drupal::service('content_moderation.moderation_information');
if (!$moderation_info->shouldModerateEntitiesOfBundle($entity->getEntityType(), $entity->bundle())) {
return NULL;
}
// Existing entities will have a corresponding content_moderation_state
// entity associated with them.
if (!$entity->isNew() && $content_moderation_state = $this->loadContentModerationStateRevision($entity)) {
return $content_moderation_state->moderation_state->value;
}
// 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()
$workflow = $moderation_info->getWorkFlowForEntity($entity);
return $workflow ? $workflow->getTypePlugin()->getInitialState($entity)->id() : NULL;
}
/**
* Load the content moderation state revision associated with an entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity the content moderation state entity will be loaded from.
*
* @return \Drupal\content_moderation\Entity\ContentModerationStateInterface|null
* The content_moderation_state revision or FALSE if none exists.
*/
protected function loadContentModerationStateRevision(ContentEntityInterface $entity) {
$moderation_info = \Drupal::service('content_moderation.moderation_information');
$content_moderation_storage = \Drupal::entityTypeManager()->getStorage('content_moderation_state');
$revisions = $content_moderation_storage->getQuery()
->accessCheck(FALSE)
->condition('content_entity_type_id', $entity->getEntityTypeId())
->condition('content_entity_id', $entity->id())
// Ensure the correct revision is loaded in scenarios where a revision is
// being reverted.
->condition('content_entity_revision_id', $entity->isNewRevision() ? $entity->getLoadedRevisionId() : $entity->getRevisionId())
->condition('workflow', $moderation_info->getWorkflowForEntity($entity)->id())
->condition('langcode', $entity->language()->getId())
->allRevisions()
->sort('revision_id', 'DESC')
->execute();
if (empty($revisions)) {
return NULL;
}
/** @var \Drupal\content_moderation\Entity\ContentModerationStateInterface $content_moderation_state */
$content_moderation_state = $content_moderation_storage->loadRevision(key($revisions));
if ($entity->getEntityType()->hasKey('langcode')) {
$langcode = $entity->language()->getId();
if (!$content_moderation_state->hasTranslation($langcode)) {
$content_moderation_state->addTranslation($langcode, $content_moderation_state->toArray());
}
if ($content_moderation_state->language()->getId() !== $langcode) {
$content_moderation_state = $content_moderation_state->getTranslation($langcode);
}
}
return $content_moderation_state;
}
/**
* {@inheritdoc}
*/
public function get($index) {
if ($index !== 0) {
throw new \InvalidArgumentException('An entity can not have multiple moderation states at the same time.');
}
return $this->traitGet($index);
}
/**
* {@inheritdoc}
*/
public function onChange($delta) {
$this->updateModeratedEntity($this->list[$delta]->value);
parent::onChange($delta);
}
/**
* {@inheritdoc}
*/
public function setValue($values, $notify = TRUE) {
parent::setValue($values, $notify);
$this->valueComputed = TRUE;
// If the parent created a field item and if the parent should be notified
// about the change (e.g. this is not initialized with the current value),
// update the moderated entity.
if (isset($this->list[0]) && $notify) {
$this->updateModeratedEntity($this->list[0]->value);
}
}
/**
* Updates the default revision flag and the publishing status of the entity.
*
* @param string $moderation_state_id
* The ID of the new moderation state.
*/
protected function updateModeratedEntity($moderation_state_id) {
$entity = $this->getEntity();
/** @var \Drupal\content_moderation\ModerationInformationInterface $content_moderation_info */
$content_moderation_info = \Drupal::service('content_moderation.moderation_information');
$workflow = $content_moderation_info->getWorkflowForEntity($entity);
// Change the entity's default revision flag and the publishing status only
// if the new workflow state is a valid one.
if ($workflow && $workflow->getTypePlugin()->hasState($moderation_state_id)) {
/** @var \Drupal\content_moderation\ContentModerationState $current_state */
$current_state = $workflow->getTypePlugin()->getState($moderation_state_id);
// This entity is default if it is new, the default revision state, or the
// default revision is not published.
if (!$entity->isSyncing()) {
$update_default_revision = $entity->isNew()
|| $current_state->isDefaultRevisionState()
|| !$content_moderation_info->isDefaultRevisionPublished($entity);
$entity->isDefaultRevision($update_default_revision);
}
// Update publishing status if it can be updated and if it needs updating.
$published_state = $current_state->isPublishedState();
if (($entity instanceof EntityPublishedInterface) && $entity->isPublished() !== $published_state) {
$published_state ? $entity->setPublished() : $entity->setUnpublished();
}
}
}
/**
* {@inheritdoc}
*/
public function generateSampleItems($count = 1) {
// No sample items generated since the starting moderation state is always
// computed based on the default state of the associated workflow.
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\content_moderation\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Verifies that nodes have a valid moderation state.
*/
#[Constraint(
id: 'ModerationState',
label: new TranslatableMarkup('Valid moderation state', [], ['context' => 'Validation'])
)]
class ModerationStateConstraint extends SymfonyConstraint {
public $message = 'Invalid state transition from %from to %to';
public $invalidStateMessage = 'State %state does not exist on %workflow workflow';
public $invalidTransitionAccess = 'You do not have access to transition from %original_state to %new_state';
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Drupal\content_moderation\Plugin\Validation\Constraint;
use Drupal\content_moderation\StateTransitionValidationInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Validation\Plugin\Validation\Constraint\NotNullConstraint;
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 entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
private $entityTypeManager;
/**
* The moderation info.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidationInterface
*/
protected $stateTransitionValidation;
/**
* Creates a new ModerationStateConstraintValidator instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Drupal\content_moderation\StateTransitionValidationInterface $state_transition_validation
* The state transition validation service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_information, AccountInterface $current_user, StateTransitionValidationInterface $state_transition_validation) {
$this->entityTypeManager = $entity_type_manager;
$this->moderationInformation = $moderation_information;
$this->currentUser = $current_user;
$this->stateTransitionValidation = $state_transition_validation;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('content_moderation.moderation_information'),
$container->get('current_user'),
$container->get('content_moderation.state_transition_validation')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $value->getEntity();
// Ignore entities that are not subject to moderation anyway.
if (!$this->moderationInformation->isModeratedEntity($entity)) {
return;
}
// If the entity is moderated and the item list is empty, ensure users see
// the same required message as typical NotNull constraints.
if ($value->isEmpty()) {
$this->context->addViolation((new NotNullConstraint())->message);
return;
}
$workflow = $this->moderationInformation->getWorkflowForEntity($entity);
if (!$workflow->getTypePlugin()->hasState($entity->moderation_state->value)) {
// If the state we are transitioning to doesn't exist, we can't validate
// the transitions for this entity further.
$this->context->addViolation($constraint->invalidStateMessage, [
'%state' => $entity->moderation_state->value,
'%workflow' => $workflow->label(),
]);
return;
}
$new_state = $workflow->getTypePlugin()->getState($entity->moderation_state->value);
$original_state = $this->moderationInformation->getOriginalState($entity);
// If a new state is being set and there is an existing state, validate
// there is a valid transition between them.
if (!$original_state->canTransitionTo($new_state->id())) {
$this->context->addViolation($constraint->message, [
'%from' => $original_state->label(),
'%to' => $new_state->label(),
]);
}
else {
// If we're sure the transition exists, make sure the user has permission
// to use it.
if (!$this->stateTransitionValidation->isTransitionValid($workflow, $original_state, $new_state, $this->currentUser, $entity)) {
$this->context->addViolation($constraint->invalidTransitionAccess, [
'%original_state' => $original_state->label(),
'%new_state' => $new_state->label(),
]);
}
}
}
}

View File

@@ -0,0 +1,319 @@
<?php
namespace Drupal\content_moderation\Plugin\WorkflowType;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\content_moderation\ContentModerationState;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workflows\Attribute\WorkflowType;
use Drupal\workflows\Plugin\WorkflowTypeBase;
use Drupal\workflows\StateInterface;
use Drupal\workflows\WorkflowInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Attaches workflows to content entity types and their bundles.
*/
#[WorkflowType(
id: 'content_moderation',
label: new TranslatableMarkup('Content moderation'),
forms: [
'configure' => '\Drupal\content_moderation\Form\ContentModerationConfigureForm',
'state' => '\Drupal\content_moderation\Form\ContentModerationStateForm',
],
required_states: [
'draft',
'published',
]
)]
class ContentModeration extends WorkflowTypeBase implements ContentModerationInterface, ContainerFactoryPluginInterface {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity type bundle info service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $entityTypeBundleInfo;
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* Constructs a ContentModeration object.
*
* @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
* The entity type manager.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
* The entity type bundle info.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* Moderation information service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, ModerationInformationInterface $moderation_info) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->entityTypeBundleInfo = $entity_type_bundle_info;
$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('entity_type.manager'),
$container->get('entity_type.bundle.info'),
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function getState($state_id) {
$state = parent::getState($state_id);
if (isset($this->configuration['states'][$state->id()]['published']) && isset($this->configuration['states'][$state->id()]['default_revision'])) {
$state = new ContentModerationState($state, $this->configuration['states'][$state->id()]['published'], $this->configuration['states'][$state->id()]['default_revision']);
}
else {
$state = new ContentModerationState($state);
}
return $state;
}
/**
* {@inheritdoc}
*/
public function workflowHasData(WorkflowInterface $workflow) {
return (bool) $this->entityTypeManager
->getStorage('content_moderation_state')
->getQuery()
->condition('workflow', $workflow->id())
->count()
->accessCheck(FALSE)
->range(0, 1)
->execute();
}
/**
* {@inheritdoc}
*/
public function workflowStateHasData(WorkflowInterface $workflow, StateInterface $state) {
return (bool) $this->entityTypeManager
->getStorage('content_moderation_state')
->getQuery()
->condition('workflow', $workflow->id())
->condition('moderation_state', $state->id())
->count()
->accessCheck(FALSE)
->range(0, 1)
->execute();
}
/**
* {@inheritdoc}
*/
public function getEntityTypes() {
return array_keys($this->configuration['entity_types']);
}
/**
* {@inheritdoc}
*/
public function getBundlesForEntityType($entity_type_id) {
return $this->configuration['entity_types'][$entity_type_id] ?? [];
}
/**
* {@inheritdoc}
*/
public function appliesToEntityTypeAndBundle($entity_type_id, $bundle_id) {
return in_array($bundle_id, $this->getBundlesForEntityType($entity_type_id), TRUE);
}
/**
* {@inheritdoc}
*/
public function removeEntityTypeAndBundle($entity_type_id, $bundle_id) {
if (!isset($this->configuration['entity_types'][$entity_type_id])) {
return;
}
$key = array_search($bundle_id, $this->configuration['entity_types'][$entity_type_id], TRUE);
if ($key !== FALSE) {
unset($this->configuration['entity_types'][$entity_type_id][$key]);
if (empty($this->configuration['entity_types'][$entity_type_id])) {
unset($this->configuration['entity_types'][$entity_type_id]);
}
else {
$this->configuration['entity_types'][$entity_type_id] = array_values($this->configuration['entity_types'][$entity_type_id]);
}
}
}
/**
* {@inheritdoc}
*/
public function addEntityTypeAndBundle($entity_type_id, $bundle_id) {
if (!$this->appliesToEntityTypeAndBundle($entity_type_id, $bundle_id)) {
$this->configuration['entity_types'][$entity_type_id][] = $bundle_id;
sort($this->configuration['entity_types'][$entity_type_id]);
ksort($this->configuration['entity_types']);
}
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'states' => [
'draft' => [
'label' => 'Draft',
'published' => FALSE,
'default_revision' => FALSE,
'weight' => 0,
],
'published' => [
'label' => 'Published',
'published' => TRUE,
'default_revision' => TRUE,
'weight' => 1,
],
],
'transitions' => [
'create_new_draft' => [
'label' => 'Create New Draft',
'to' => 'draft',
'weight' => 0,
'from' => [
'draft',
'published',
],
],
'publish' => [
'label' => 'Publish',
'to' => 'published',
'weight' => 1,
'from' => [
'draft',
'published',
],
],
],
'entity_types' => [],
];
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$dependencies = parent::calculateDependencies();
foreach ($this->getEntityTypes() as $entity_type_id) {
$entity_definition = $this->entityTypeManager->getDefinition($entity_type_id);
foreach ($this->getBundlesForEntityType($entity_type_id) as $bundle) {
$dependency = $entity_definition->getBundleConfigDependency($bundle);
$dependencies[$dependency['type']][] = $dependency['name'];
}
}
return $dependencies;
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
$changed = parent::onDependencyRemoval($dependencies);
// When bundle config entities are removed, ensure they are cleaned up from
// the workflow.
foreach ($dependencies['config'] as $removed_config) {
if ($entity_type_id = $removed_config->getEntityType()->getBundleOf()) {
$bundle_id = $removed_config->id();
$this->removeEntityTypeAndBundle($entity_type_id, $bundle_id);
$changed = TRUE;
}
}
// When modules that provide entity types are removed, ensure they are also
// removed from the workflow.
if (!empty($dependencies['module'])) {
// Gather all entity definitions provided by the dependent modules which
// are being removed.
$module_entity_definitions = [];
foreach ($this->entityTypeManager->getDefinitions() as $entity_definition) {
if (in_array($entity_definition->getProvider(), $dependencies['module'])) {
$module_entity_definitions[] = $entity_definition;
}
}
// For all entity types provided by the uninstalled modules, remove any
// configuration for those types.
foreach ($module_entity_definitions as $module_entity_definition) {
foreach ($this->getBundlesForEntityType($module_entity_definition->id()) as $bundle) {
$this->removeEntityTypeAndBundle($module_entity_definition->id(), $bundle);
$changed = TRUE;
}
}
}
return $changed;
}
/**
* {@inheritdoc}
*/
public function getConfiguration() {
$configuration = parent::getConfiguration();
// Ensure that states and entity types are ordered consistently.
ksort($configuration['states']);
ksort($configuration['entity_types']);
return $configuration;
}
/**
* {@inheritdoc}
*/
public function getInitialState($entity = NULL) {
// Workflows are not tied to entities, but Content Moderation adds the
// relationship between Workflows and entities. Content Moderation needs the
// entity object to be able to determine the initial state based on
// publishing status.
if (!($entity instanceof ContentEntityInterface)) {
throw new \InvalidArgumentException('A content entity object must be supplied.');
}
if ($entity instanceof EntityPublishedInterface && !$entity->isNew()) {
return $this->getState($entity->isPublished() ? 'published' : 'draft');
}
return $this->getState(!empty($this->configuration['default_moderation_state']) ? $this->configuration['default_moderation_state'] : 'draft');
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Drupal\content_moderation\Plugin\WorkflowType;
use Drupal\workflows\WorkflowTypeInterface;
/**
* Interface for ContentModeration WorkflowType plugin.
*/
interface ContentModerationInterface extends WorkflowTypeInterface {
/**
* Gets the entity types the workflow is applied to.
*
* @return string[]
* The entity types the workflow is applied to.
*/
public function getEntityTypes();
/**
* Gets any bundles the workflow is applied to for the given entity type.
*
* @param string $entity_type_id
* The entity type ID to get the bundles for.
*
* @return string[]
* The bundles of the entity type the workflow is applied to or an empty
* array if the entity type is not applied to the workflow.
*/
public function getBundlesForEntityType($entity_type_id);
/**
* Checks if the workflow applies to the supplied entity type and bundle.
*
* @param string $entity_type_id
* The entity type ID to check.
* @param string $bundle_id
* The bundle ID to check.
*
* @return bool
* TRUE if the workflow applies to the supplied entity type ID and bundle
* ID. FALSE if not.
*/
public function appliesToEntityTypeAndBundle($entity_type_id, $bundle_id);
/**
* Removes an entity type ID / bundle ID from the workflow.
*
* @param string $entity_type_id
* The entity type ID to remove.
* @param string $bundle_id
* The bundle ID to remove.
*/
public function removeEntityTypeAndBundle($entity_type_id, $bundle_id);
/**
* Add an entity type ID / bundle ID to the workflow.
*
* @param string $entity_type_id
* The entity type ID to add. It is responsibility of the caller to provide
* a valid entity type ID.
* @param string $bundle_id
* The bundle ID to add. It is responsibility of the caller to provide a
* valid bundle ID.
*/
public function addEntityTypeAndBundle($entity_type_id, $bundle_id);
/**
* {@inheritdoc}
*
* @param $entity
* Content Moderation uses this parameter to determine the initial state
* based on publishing status.
*/
public function getInitialState($entity = NULL);
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Drupal\content_moderation\Plugin\views;
use Drupal\views\Views;
/**
* Assist views handler plugins to join to the content_moderation_state entity.
*
* @internal
*/
trait ModerationStateJoinViewsHandlerTrait {
/**
* {@inheritdoc}
*/
public function ensureMyTable() {
if (!isset($this->tableAlias)) {
$table_alias = $this->query->ensureTable($this->table, $this->relationship);
// Join the moderation states of the content via the
// ContentModerationState field revision table, joining either the entity
// field data or revision table. This allows filtering states against
// either the default or latest revision, depending on the relationship of
// the filter.
$left_entity_type = $this->entityTypeManager->getDefinition($this->getEntityType());
$entity_type = $this->entityTypeManager->getDefinition('content_moderation_state');
$configuration = [
'table' => $entity_type->getRevisionDataTable(),
'field' => 'content_entity_revision_id',
'left_table' => $table_alias,
'left_field' => $left_entity_type->getKey('revision'),
'extra' => [
[
'field' => 'content_entity_type_id',
'value' => $left_entity_type->id(),
],
[
'field' => 'content_entity_id',
'left_field' => $left_entity_type->getKey('id'),
],
],
];
if ($left_entity_type->isTranslatable()) {
$configuration['extra'][] = [
'field' => $entity_type->getKey('langcode'),
'left_field' => $left_entity_type->getKey('langcode'),
];
}
$join = Views::pluginManager('join')->createInstance('standard', $configuration);
$this->tableAlias = $this->query->addRelationship('content_moderation_state', $join, 'content_moderation_state_field_revision');
}
return $this->tableAlias;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Drupal\content_moderation\Plugin\views\field;
use Drupal\content_moderation\Plugin\views\ModerationStateJoinViewsHandlerTrait;
use Drupal\views\Attribute\ViewsField;
use Drupal\views\Plugin\views\field\EntityField;
/**
* A field handler for the computed moderation_state field.
*
* @ingroup views_field_handlers
*/
#[ViewsField("moderation_state_field")]
class ModerationStateField extends EntityField {
use ModerationStateJoinViewsHandlerTrait;
/**
* {@inheritdoc}
*/
public function clickSort($order) {
$this->ensureMyTable();
// This could be derived from the content_moderation_state entity table
// mapping, however this is an internal entity type whose storage should
// remain constant.
$storage = $this->entityTypeManager->getStorage('content_moderation_state');
$storage_definition = $this->entityFieldManager->getActiveFieldStorageDefinitions('content_moderation_state')['moderation_state'];
$column_name = $storage->getTableMapping()->getFieldColumnName($storage_definition, 'value');
$this->aliases[$column_name] = $this->tableAlias . '.' . $column_name;
$this->query->addOrderBy(NULL, NULL, $order, $this->aliases[$column_name]);
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace Drupal\content_moderation\Plugin\views\filter;
use Drupal\content_moderation\Plugin\views\ModerationStateJoinViewsHandlerTrait;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\views\Attribute\ViewsFilter;
use Drupal\views\Plugin\DependentWithRemovalPluginInterface;
use Drupal\views\Plugin\views\filter\InOperator;
use Drupal\views\Views;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a filter for the moderation state of an entity.
*
* @ingroup views_filter_handlers
*/
#[ViewsFilter("moderation_state_filter")]
class ModerationStateFilter extends InOperator implements DependentWithRemovalPluginInterface {
use ModerationStateJoinViewsHandlerTrait;
/**
* {@inheritdoc}
*/
protected $valueFormType = 'select';
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The bundle information service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* The storage handler of the workflow entity type.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $workflowStorage;
/**
* Creates an instance of ModerationStateFilter.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info, EntityStorageInterface $workflow_storage) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->bundleInfo = $bundle_info;
$this->workflowStorage = $workflow_storage;
}
/**
* {@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('entity_type.bundle.info'),
$container->get('entity_type.manager')->getStorage('workflow')
);
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return Cache::mergeTags(parent::getCacheTags(), $this->entityTypeManager->getDefinition('workflow')->getListCacheTags());
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return Cache::mergeContexts(parent::getCacheContexts(), $this->entityTypeManager->getDefinition('workflow')->getListCacheContexts());
}
/**
* {@inheritdoc}
*/
public function getValueOptions() {
if (isset($this->valueOptions)) {
return $this->valueOptions;
}
$this->valueOptions = [];
// Find all workflows which are moderating entity types of the same type the
// view is displaying.
foreach ($this->workflowStorage->loadByProperties(['type' => 'content_moderation']) as $workflow) {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface $workflow_type */
$workflow_type = $workflow->getTypePlugin();
if (in_array($this->getEntityType(), $workflow_type->getEntityTypes(), TRUE)) {
foreach ($workflow_type->getStates() as $state_id => $state) {
$this->valueOptions[$workflow->label()][implode('-', [$workflow->id(), $state_id])] = $state->label();
}
}
}
return $this->valueOptions;
}
/**
* {@inheritdoc}
*/
protected function opSimple() {
if (empty($this->value)) {
return;
}
$this->ensureMyTable();
$entity_type = $this->entityTypeManager->getDefinition($this->getEntityType());
$bundle_condition = NULL;
if ($entity_type->hasKey('bundle')) {
// Get a list of bundles that are being moderated by the workflows
// configured in this filter.
$workflow_ids = $this->getWorkflowIds();
$moderated_bundles = [];
foreach ($this->bundleInfo->getBundleInfo($this->getEntityType()) as $bundle_id => $bundle) {
if (isset($bundle['workflow']) && in_array($bundle['workflow'], $workflow_ids, TRUE)) {
$moderated_bundles[] = $bundle_id;
}
}
// If we have a list of moderated bundles, restrict the query to show only
// entities in those bundles.
if ($moderated_bundles) {
$entity_base_table_alias = $this->relationship ?: $this->table;
// The bundle field of an entity type is not revisionable so we need to
// join the base table.
$entity_base_table = $entity_type->getBaseTable();
$entity_revision_base_table = $entity_type->isTranslatable() ? $entity_type->getRevisionDataTable() : $entity_type->getRevisionTable();
if ($this->table === $entity_revision_base_table) {
$entity_revision_base_table_alias = $this->relationship ?: $this->table;
$configuration = [
'table' => $entity_base_table,
'field' => $entity_type->getKey('id'),
'left_table' => $entity_revision_base_table_alias,
'left_field' => $entity_type->getKey('id'),
'type' => 'INNER',
];
$join = Views::pluginManager('join')->createInstance('standard', $configuration);
$entity_base_table_alias = $this->query->addRelationship($entity_base_table, $join, $entity_revision_base_table_alias);
}
$bundle_condition = $this->view->query->getConnection()->condition('AND');
$bundle_condition->condition("$entity_base_table_alias.{$entity_type->getKey('bundle')}", $moderated_bundles, 'IN');
}
// Otherwise, force the query to return an empty result.
else {
$this->query->addWhereExpression($this->options['group'], '1 = 0');
return;
}
}
if ($this->operator === 'in') {
$operator = "=";
}
else {
$operator = "<>";
}
// The values are strings composed from the workflow ID and the state ID, so
// we need to create a complex WHERE condition.
$field = $this->view->query->getConnection()->condition('OR');
foreach ((array) $this->value as $value) {
[$workflow_id, $state_id] = explode('-', $value, 2);
$and = $this->view->query->getConnection()->condition('AND');
$and
->condition("$this->tableAlias.workflow", $workflow_id, '=')
->condition("$this->tableAlias.$this->realField", $state_id, $operator);
$field->condition($and);
}
if ($bundle_condition) {
// The query must match the bundle AND the workflow/state conditions.
$bundle_condition->condition($field);
$this->query->addWhere($this->options['group'], $bundle_condition);
}
else {
$this->query->addWhere($this->options['group'], $field);
}
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$dependencies = parent::calculateDependencies();
if ($workflow_ids = $this->getWorkflowIds()) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
foreach ($this->workflowStorage->loadMultiple($workflow_ids) as $workflow) {
$dependencies[$workflow->getConfigDependencyKey()][] = $workflow->getConfigDependencyName();
}
}
return $dependencies;
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
// See if this handler is responsible for any of the dependencies being
// removed. If this is the case, indicate that this handler needs to be
// removed from the View.
$remove = FALSE;
// Get all the current dependencies for this handler.
$current_dependencies = $this->calculateDependencies();
foreach ($current_dependencies as $group => $dependency_list) {
// Check if any of the handler dependencies match the dependencies being
// removed.
foreach ($dependency_list as $config_key) {
if (isset($dependencies[$group]) && array_key_exists($config_key, $dependencies[$group])) {
// This handlers dependency matches a dependency being removed,
// indicate that this handler needs to be removed.
$remove = TRUE;
break 2;
}
}
}
return $remove;
}
/**
* Gets the list of Workflow IDs configured for this filter.
*
* @return array
* And array of workflow IDs.
*/
protected function getWorkflowIds() {
$workflow_ids = [];
foreach ((array) $this->value as $value) {
[$workflow_id] = explode('-', $value, 2);
$workflow_ids[] = $workflow_id;
}
return array_unique($workflow_ids);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Drupal\content_moderation\Plugin\views\sort;
use Drupal\content_moderation\Plugin\views\ModerationStateJoinViewsHandlerTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\views\Attribute\ViewsSort;
use Drupal\views\Plugin\views\sort\SortPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Enables sorting for the computed moderation_state field.
*
* @ingroup views_sort_handlers
*/
#[ViewsSort("moderation_state_sort")]
class ModerationStateSort extends SortPluginBase {
use ModerationStateJoinViewsHandlerTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Creates an instance of ModerationStateFilter.
*
* @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
* The entity type manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@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')
);
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace Drupal\content_moderation\Routing;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteSubscriberBase;
use Drupal\Core\Routing\RoutingEvents;
use Drupal\workflows\Entity\Workflow;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Subscriber for moderated revisionable entity forms.
*
* @internal
* There is ongoing discussion about how pending revisions should behave.
* The logic enabling pending revision support is likely to change once a
* decision is made.
*
* @see https://www.drupal.org/node/2940575
*/
class ContentModerationRouteSubscriber extends RouteSubscriberBase {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* An associative array of moderated entity types keyed by ID.
*
* @var \Drupal\Core\Entity\ContentEntityTypeInterface[]
*/
protected $moderatedEntityTypes;
/**
* ContentModerationRouteSubscriber constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
protected function alterRoutes(RouteCollection $collection) {
foreach ($collection as $route) {
$this->setLatestRevisionFlag($route);
}
}
/**
* Ensure revisionable entities load the latest revision on entity forms.
*
* @param \Symfony\Component\Routing\Route $route
* The route object.
*/
protected function setLatestRevisionFlag(Route $route) {
if (!$entity_form = $route->getDefault('_entity_form')) {
return;
}
// Only set the flag on entity types which are revisionable.
[$entity_type] = explode('.', $entity_form, 2);
if (!isset($this->getModeratedEntityTypes()[$entity_type]) || !$this->getModeratedEntityTypes()[$entity_type]->isRevisionable()) {
return;
}
$parameters = $route->getOption('parameters') ?: [];
foreach ($parameters as &$parameter) {
if (isset($parameter['type']) && $parameter['type'] === 'entity:' . $entity_type && !isset($parameter['load_latest_revision'])) {
$parameter['load_latest_revision'] = TRUE;
}
}
$route->setOption('parameters', $parameters);
}
/**
* Returns the moderated entity types.
*
* @return \Drupal\Core\Entity\ContentEntityTypeInterface[]
* An associative array of moderated entity types keyed by ID.
*/
protected function getModeratedEntityTypes() {
if (!isset($this->moderatedEntityTypes)) {
$entity_types = $this->entityTypeManager->getDefinitions();
/** @var \Drupal\workflows\WorkflowInterface $workflow */
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */
$plugin = $workflow->getTypePlugin();
foreach ($plugin->getEntityTypes() as $entity_type_id) {
$this->moderatedEntityTypes[$entity_type_id] = $entity_types[$entity_type_id];
}
}
}
return $this->moderatedEntityTypes;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$events = parent::getSubscribedEvents();
// This needs to run after that EntityResolverManager has set the route
// entity type.
$events[RoutingEvents::ALTER] = ['onAlterRoutes', -200];
return $events;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\workflows\StateInterface;
use Drupal\workflows\Transition;
use Drupal\workflows\WorkflowInterface;
/**
* Validates whether a certain state transition is allowed.
*/
class StateTransitionValidation implements StateTransitionValidationInterface {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* Stores the possible state transitions.
*
* @var array
*/
protected $possibleTransitions = [];
/**
* Constructs a new StateTransitionValidation.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
*/
public function __construct(ModerationInformationInterface $moderation_info) {
$this->moderationInfo = $moderation_info;
}
/**
* {@inheritdoc}
*/
public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) {
$workflow = $this->moderationInfo->getWorkflowForEntity($entity);
$current_state = $entity->moderation_state->value ? $workflow->getTypePlugin()->getState($entity->moderation_state->value) : $workflow->getTypePlugin()->getInitialState($entity);
return array_filter($current_state->getTransitions(), function (Transition $transition) use ($workflow, $user) {
return $user->hasPermission('use ' . $workflow->id() . ' transition ' . $transition->id());
});
}
/**
* {@inheritdoc}
*/
public function isTransitionValid(WorkflowInterface $workflow, StateInterface $original_state, StateInterface $new_state, AccountInterface $user, ContentEntityInterface $entity) {
$transition = $workflow->getTypePlugin()->getTransitionFromStateToState($original_state->id(), $new_state->id());
return $user->hasPermission('use ' . $workflow->id() . ' transition ' . $transition->id());
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\workflows\StateInterface;
use Drupal\workflows\WorkflowInterface;
/**
* Validates whether a certain state transition is allowed.
*/
interface StateTransitionValidationInterface {
/**
* 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\workflows\Transition[]
* The list of transitions that are legal for this user on this entity.
*/
public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user);
/**
* Checks if a transition between two states if valid for the given user.
*
* @param \Drupal\workflows\WorkflowInterface $workflow
* The workflow entity.
* @param \Drupal\workflows\StateInterface $original_state
* The original workflow state.
* @param \Drupal\workflows\StateInterface $new_state
* The new workflow state.
* @param \Drupal\Core\Session\AccountInterface $user
* The user to validate.
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to be transitioned.
*
* @return bool
* Returns TRUE if transition is valid, otherwise FALSE.
*/
public function isTransitionValid(WorkflowInterface $workflow, StateInterface $original_state, StateInterface $new_state, AccountInterface $user, ContentEntityInterface $entity);
}

View File

@@ -0,0 +1,88 @@
<?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.
*
* @internal
*/
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 = [];
$entity_types_with_moderation = array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $type) {
return $this->moderationInformation->isModeratedEntityType($type);
});
foreach ($entity_types_with_moderation as $entity_type) {
$table = $entity_type->getDataTable() ?: $entity_type->getBaseTable();
$data[$table]['moderation_state'] = [
'title' => t('Moderation state'),
'field' => [
'id' => 'moderation_state_field',
'default_formatter' => 'content_moderation_state',
'field_name' => 'moderation_state',
],
'filter' => ['id' => 'moderation_state_filter', 'allow empty' => TRUE],
'sort' => ['id' => 'moderation_state_sort'],
];
$revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
$data[$revision_table]['moderation_state'] = [
'title' => t('Moderation state'),
'field' => [
'id' => 'moderation_state_field',
'default_formatter' => 'content_moderation_state',
'field_name' => 'moderation_state',
],
'filter' => ['id' => 'moderation_state_filter', 'allow empty' => TRUE],
'sort' => ['id' => 'moderation_state_sort'],
];
}
return $data;
}
}

View File

@@ -0,0 +1,7 @@
<ul class="entity-moderation-form">
<li class="entity-moderation-form__item">{{ form.current }}</li>
<li class="entity-moderation-form__item">{{ form.new_state }}</li>
<li class="entity-moderation-form__item">{{ form.revision_log }}</li>
<li class="entity-moderation-form__item">{{ form.submit }}</li>
</ul>
{{ form|without('current', 'new_state', 'revision_log', 'submit') }}

View File

@@ -0,0 +1,13 @@
name: 'Content moderation test local task'
type: module
description: 'Provides a local task for testing.'
package: Testing
# version: VERSION
dependencies:
- drupal:content_moderation
- drupal:node
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,4 @@
entity.node.test_local_task_without_upcast_node:
route_name: entity.node.test_local_task_without_upcast_node
base_route: entity.node.canonical
title: 'Task Without Upcast Node'

View File

@@ -0,0 +1,7 @@
entity.node.test_local_task_without_upcast_node:
path: '/node/{node}/task-without-upcast-node'
defaults:
_title: 'Page Without Upcast Node'
_controller: '\Drupal\content_moderation_test_local_task\Controller\TestLocalTaskController::methodWithoutUpcastNode'
requirements:
_access: 'TRUE'

View File

@@ -0,0 +1,17 @@
<?php
namespace Drupal\content_moderation_test_local_task\Controller;
/**
* A test controller.
*/
class TestLocalTaskController {
/**
* A method which does not hint the node parameter to avoid upcasting.
*/
public function methodWithoutUpcastNode($node) {
return ['#markup' => 'It works!'];
}
}

View File

@@ -0,0 +1,12 @@
name: 'Content moderation test re-save'
type: module
description: 'Re-saves moderated entities for testing purposes.'
package: Testing
# version: VERSION
dependencies:
- drupal:content_moderation
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,15 @@
<?php
/**
* @file
* Contains install functions for the Content moderation test re-save module.
*/
/**
* Implements hook_install().
*/
function content_moderation_test_resave_install() {
// Make sure that this module's hooks are run before Content Moderation's
// hooks.
module_set_weight('content_moderation_test_resave', -10);
}

View File

@@ -0,0 +1,30 @@
<?php
/**
* @file
* Contains hook implementations for the Content moderation test re-save module.
*/
use Drupal\Core\Entity\EntityInterface;
/**
* Implements hook_entity_insert().
*/
function content_moderation_test_resave_entity_insert(EntityInterface $entity) {
/** @var \Drupal\content_moderation\ModerationInformationInterface $content_moderation */
$content_moderation = \Drupal::service('content_moderation.moderation_information');
if ($content_moderation->isModeratedEntity($entity)) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
// Saving the passed entity object would populate its loaded revision ID,
// which we want to avoid. Thus, save a clone of the original object.
$cloned_entity = clone $entity;
// Set the entity's syncing status, as we do not want Content Moderation to
// create new revisions for the re-saving. Without this call Content
// Moderation would end up creating two separate content moderation state
// entities: one for the re-save revision and one for the initial revision.
$cloned_entity->setSyncing(TRUE)->save();
// Record the fact that a re-save happened.
\Drupal::state()->set('content_moderation_test_resave', TRUE);
}
}

View File

@@ -0,0 +1,15 @@
name: 'Content moderation test views'
type: module
description: 'Provides default views for views Content moderation tests.'
package: Testing
# version: VERSION
dependencies:
- drupal:content_moderation
- drupal:node
- drupal:views
- drupal:entity_test
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,45 @@
<?php
/**
* @file
* Module file for the content moderation test views module.
*/
use Drupal\views\Plugin\views\query\QueryPluginBase;
use Drupal\views\ViewExecutable;
/**
* Implements hook_views_query_alter().
*
* @see \Drupal\Tests\content_moderation\Kernel\ViewsModerationStateSortTest::testSortRevisionBaseTable()
*/
function content_moderation_test_views_views_query_alter(ViewExecutable $view, QueryPluginBase $query) {
// Add a secondary sort order to ensure consistent builds when testing click
// and table sorting.
if ($view->id() === 'test_content_moderation_state_sort_revision_table') {
$query->addOrderBy('node_field_revision', 'vid', 'ASC');
}
}
/**
* Implements hook_views_data_alter().
*
* @see \Drupal\Tests\content_moderation\Kernel\ViewsModerationStateFilterTest
*/
function content_moderation_test_views_views_data_alter(array &$data) {
if (isset($data['users_field_data'])) {
$data['users_field_data']['uid_revision_test'] = [
'help' => t('Relate the content revision to the user who created it.'),
'real field' => 'uid',
'relationship' => [
'title' => t('Content revision authored'),
'help' => t('Relate the content revision to the user who created it. This relationship will create one record for each content revision item created by the user.'),
'id' => 'standard',
'base' => 'node_field_revision',
'base field' => 'uid',
'field' => 'uid',
'label' => t('node revisions'),
],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\language\Functional\AdminPathEntityConverterLanguageTest;
/**
* Test administration path based entity conversion when moderation enabled.
*
* @group content_moderation
*/
class ContentModerationAdminPathEntityConverterLanguageTest extends AdminPathEntityConverterLanguageTest {
/**
* {@inheritdoc}
*/
protected static $modules = [
'language',
'language_test',
'content_moderation',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Test the workflow type plugin in the content_moderation module.
*
* @group content_moderation
*/
class ContentModerationWorkflowTypeTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = [
'content_moderation',
'node',
'entity_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$admin = $this->drupalCreateUser([
'administer workflows',
]);
$this->drupalLogin($admin);
}
/**
* Tests creating a new workflow using the content moderation plugin.
*/
public function testNewWorkflow(): void {
$types[] = $this->createContentType();
$types[] = $this->createContentType();
$types[] = $this->createContentType();
$entity_bundle_info = \Drupal::service('entity_type.bundle.info');
$this->drupalGet('admin/config/workflow/workflows/add');
$this->submitForm([
'label' => 'Test',
'id' => 'test',
'workflow_type' => 'content_moderation',
], 'Save');
$session = $this->assertSession();
// Make sure the test workflow includes the default states and transitions.
$session->pageTextContains('Draft');
$session->pageTextContains('Published');
$session->pageTextContains('Create New Draft');
$session->pageTextContains('Publish');
$session->linkByHrefNotExists('/admin/config/workflow/workflows/manage/test/state/draft/delete');
$session->linkByHrefNotExists('/admin/config/workflow/workflows/manage/test/state/published/delete');
// Ensure after a workflow is created, the bundle information can be
// refreshed.
$entity_bundle_info->clearCachedBundles();
$this->assertNotEmpty($entity_bundle_info->getAllBundleInfo());
$this->clickLink('Add a new state');
$this->submitForm([
'label' => 'Test State',
'id' => 'test_state',
'type_settings[published]' => TRUE,
'type_settings[default_revision]' => FALSE,
], 'Save');
$session->pageTextContains('Created Test State state.');
$session->linkByHrefExists('/admin/config/workflow/workflows/manage/test/state/test_state/delete');
// Check there is a link to delete a default transition.
$session->linkByHrefExists('/admin/config/workflow/workflows/manage/test/transition/publish/delete');
// Delete the transition.
$this->drupalGet('/admin/config/workflow/workflows/manage/test/transition/publish/delete');
$this->submitForm([], 'Delete');
// The link to delete the transition should now be gone.
$session->linkByHrefNotExists('/admin/config/workflow/workflows/manage/test/transition/publish/delete');
// Ensure that the published settings cannot be changed.
$this->drupalGet('admin/config/workflow/workflows/manage/test/state/published');
$session->fieldDisabled('type_settings[published]');
$session->fieldDisabled('type_settings[default_revision]');
// Ensure that the draft settings cannot be changed.
$this->drupalGet('admin/config/workflow/workflows/manage/test/state/draft');
$session->fieldDisabled('type_settings[published]');
$session->fieldDisabled('type_settings[default_revision]');
$this->drupalGet('admin/config/workflow/workflows/manage/test/type/node');
$session->pageTextContains('Select the content types for the Test workflow');
foreach ($types as $type) {
$session->pageTextContains($type->label());
$session->elementContains('css', sprintf('.form-item-bundles-%s label', $type->id()), sprintf('Update %s', $type->label()));
}
// Ensure warning message are displayed for unsupported features.
$this->drupalGet('admin/config/workflow/workflows/manage/test/type/entity_test_rev');
$this->assertSession()->pageTextContains('Test entity - revisions entities do not support publishing statuses. For example, even after transitioning from a published workflow state to an unpublished workflow state they will still be visible to site visitors.');
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
/**
* Tests setting a custom default moderation state.
*
* @group content_moderation
*/
class DefaultModerationStateTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE);
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
}
/**
* Tests a workflow with a default moderation state set.
*/
public function testPublishedDefaultState(): void {
// Set the default moderation state to be "published".
$this->drupalGet('admin/config/workflow/workflows/manage/' . $this->workflow->id());
$this->submitForm(['type_settings[workflow_settings][default_moderation_state]' => 'published'], 'Save');
$this->drupalGet('node/add/moderated_content');
$this->assertEquals('published', $this->assertSession()->selectExists('moderation_state[0][state]')->getValue());
$this->submitForm([
'title[0][value]' => 'moderated content',
], 'Save');
$node = $this->getNodeByTitle('moderated content');
$this->assertEquals('published', $node->moderation_state->value);
}
/**
* Tests access to deleting the default state.
*/
public function testDeleteDefaultStateAccess(): void {
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/state/archived/delete');
$this->assertSession()->statusCodeEquals(200);
$this->drupalGet('admin/config/workflow/workflows/manage/' . $this->workflow->id());
$this->submitForm(['type_settings[workflow_settings][default_moderation_state]' => 'archived'], 'Save');
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/state/archived/delete');
$this->assertSession()->statusCodeEquals(403);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for content_moderation.
*
* @group content_moderation
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\block_content\Entity\BlockContentType;
use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Tests Content Moderation's integration with Layout Builder.
*
* @group content_moderation
* @group layout_builder
*/
class LayoutBuilderContentModerationIntegrationTest extends BrowserTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'layout_builder',
'node',
'content_moderation',
'menu_ui',
'block_content',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// @todo The Layout Builder UI relies on local tasks; fix in
// https://www.drupal.org/project/drupal/issues/2917777.
$this->drupalPlaceBlock('local_tasks_block');
// Add a new bundle.
$this->createContentType(['type' => 'bundle_with_section_field']);
// Add a new block content bundle to the editorial workflow.
BlockContentType::create([
'id' => 'basic',
'label' => 'Basic',
'revision' => 1,
])->save();
block_content_add_body_field('basic');
// Enable layout overrides.
LayoutBuilderEntityViewDisplay::load('node.bundle_with_section_field.default')
->enableLayoutBuilder()
->setOverridable()
->save();
// Create a node before enabling the workflow on the bundle.
$node = $this->createNode([
'type' => 'bundle_with_section_field',
'title' => 'Pre-workflow node',
'body' => [
[
'value' => 'The first node body',
],
],
]);
// View the node to ensure the new extra field blocks are not cached when
// the workflow is updated.
$this->drupalGet($node->toUrl());
// Add editorial workflow for the bundle.
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('block_content', 'basic');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'bundle_with_section_field');
$workflow->save();
$this->drupalLogin($this->drupalCreateUser([
'configure any layout',
'edit any bundle_with_section_field content',
'view bundle_with_section_field revisions',
'revert bundle_with_section_field revisions',
'view own unpublished content',
'view latest version',
'use editorial transition create_new_draft',
'use editorial transition publish',
'create and edit custom blocks',
]));
}
/**
* Tests that Layout changes are respected by Content Moderation.
*/
public function testLayoutModeration(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Create an unpublished node. Revision count: 1.
$node = $this->createNode([
'type' => 'bundle_with_section_field',
'title' => 'The first node title',
'body' => [
[
'value' => 'The first node body',
],
],
]);
_menu_ui_node_save($node, [
'title' => 'bar',
'menu_name' => 'main',
'description' => 'view bar',
'parent' => '',
]);
$this->drupalGet($node->toUrl());
// Publish the node. Revision count: 2.
$page->fillField('new_state', 'published');
$page->pressButton('Apply');
// Modify the layout.
$page->clickLink('Layout');
$assert_session->checkboxChecked('revision');
$assert_session->fieldDisabled('revision');
$page->clickLink('Add block');
$page->clickLink('Powered by Drupal');
$page->pressButton('Add block');
// Save the node as a draft. Revision count: 3.
$page->fillField('moderation_state[0][state]', 'draft');
$page->pressButton('Save layout');
// Block is visible on the revision page.
$assert_session->addressEquals("node/{$node->id()}/latest");
$assert_session->pageTextContains('Powered by Drupal');
// Block is visible on the layout form.
$page->clickLink('Layout');
$assert_session->pageTextContains('Powered by Drupal');
// Block is not visible on the live node page.
$page->clickLink('View');
$assert_session->pageTextNotContains('Powered by Drupal');
// Publish the node. Revision count: 4.
$page->clickLink('Latest version');
$page->fillField('new_state', 'published');
$page->pressButton('Apply');
// Block is visible on the live node page.
$assert_session->pageTextContains('Powered by Drupal');
// Revert to the previous revision.
$page->clickLink('Revisions');
// Assert that there are 4 total revisions and 3 revert links.
$assert_session->elementsCount('named', ['link', 'Revert'], 3);
// Revert to the 2nd revision before modifying the layout.
$this->clickLink('Revert', 1);
$page->pressButton('Revert');
$page->clickLink('View');
$assert_session->pageTextNotContains('Powered by Drupal');
}
/**
* Test placing inline blocks that belong to a moderated content block bundle.
*/
public function testModeratedInlineBlockBundles(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$node = $this->createNode([
'type' => 'bundle_with_section_field',
'title' => 'The first node title',
'moderation_state' => 'published',
]);
$this->drupalGet("node/{$node->id()}/layout");
$page->clickLink('Add block');
$this->clickLink('Create content block');
$assert_session->fieldNotExists('settings[block_form][moderation_state][0][state]');
$this->submitForm([
'settings[label]' => 'Test inline block',
'settings[block_form][body][0][value]' => 'Example block body',
], 'Add block');
// Save a draft of the page with the inline block and ensure the drafted
// content appears on the latest version page.
$this->assertSession()->pageTextContains('Example block body');
$this->submitForm([
'moderation_state[0][state]' => 'draft',
], 'Save layout');
$assert_session->pageTextContains('The layout override has been saved.');
$assert_session->pageTextContains('Example block body');
// Publish the draft of the page ensure the draft inline block content
// appears on the published page.
$this->submitForm([
'new_state' => 'published',
], 'Apply');
$assert_session->pageTextContains('The moderation state has been updated.');
$assert_session->pageTextContains('Example block body');
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests moderated content dynamic local task.
*
* @group content_moderation
*/
class ModeratedContentLocalTaskTest extends BrowserTestBase {
/**
* A user to test with.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected static $modules = [
'block',
'content_moderation',
'node',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalPlaceBlock('local_tasks_block');
$this->adminUser = $this->drupalCreateUser([
'access administration pages',
'access content overview',
'view any unpublished content',
]);
}
/**
* Tests the moderated content local task appears.
*/
public function testModeratedContentLocalTask(): void {
$this->drupalLogin($this->adminUser);
// Verify the moderated content tab exists.
$this->drupalGet('admin/content');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->linkExists('Moderated content');
// Uninstall the node module which should also remove the tab.
$this->container->get('module_installer')->uninstall(['node']);
// Verify the moderated content local task does not exist without the node
// module installed.
$this->drupalGet('admin/content');
$this->assertSession()->statusCodeEquals(403);
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Tests moderated content administration page functionality.
*
* @group content_moderation
*/
class ModeratedContentViewTest extends BrowserTestBase {
use ContentModerationTestTrait;
/**
* A user with permission to bypass access content.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected static $modules = [
'content_moderation',
'node',
'views',
'language',
'content_translation',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page'])->save();
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article'])->save();
$this->drupalCreateContentType(['type' => 'unmoderated_type', 'name' => 'Unmoderated type'])->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'page');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'article');
$workflow->save();
$this->adminUser = $this->drupalCreateUser([
'access administration pages',
'view any unpublished content',
'administer nodes',
'bypass node access',
]);
}
/**
* Tests the moderated content page.
*/
public function testModeratedContentPage(): void {
$assert_session = $this->assertSession();
$this->drupalLogin($this->adminUser);
// Use an explicit changed time to ensure the expected order in the content
// admin listing. We want these to appear in the table in the same order as
// they appear in the following code, and the 'moderated_content' view has a
// table style configuration with a default sort on the 'changed' field
// descending.
$time = \Drupal::time()->getRequestTime();
$excluded_nodes['published_page'] = $this->drupalCreateNode(['type' => 'page', 'changed' => $time--, 'moderation_state' => 'published']);
$excluded_nodes['published_article'] = $this->drupalCreateNode(['type' => 'article', 'changed' => $time--, 'moderation_state' => 'published']);
$excluded_nodes['unmoderated_type'] = $this->drupalCreateNode(['type' => 'unmoderated_type', 'changed' => $time--]);
$excluded_nodes['unmoderated_type']->setNewRevision(TRUE);
$excluded_nodes['unmoderated_type']->isDefaultRevision(FALSE);
$excluded_nodes['unmoderated_type']->changed->value = $time--;
$excluded_nodes['unmoderated_type']->save();
$nodes['published_then_draft_article'] = $this->drupalCreateNode(['type' => 'article', 'changed' => $time--, 'moderation_state' => 'published', 'title' => 'first article - published']);
$nodes['published_then_draft_article']->setNewRevision(TRUE);
$nodes['published_then_draft_article']->setTitle('first article - draft');
$nodes['published_then_draft_article']->moderation_state->value = 'draft';
$nodes['published_then_draft_article']->changed->value = $time--;
$nodes['published_then_draft_article']->save();
$nodes['published_then_archived_article'] = $this->drupalCreateNode(['type' => 'article', 'changed' => $time--, 'moderation_state' => 'published']);
$nodes['published_then_archived_article']->setNewRevision(TRUE);
$nodes['published_then_archived_article']->moderation_state->value = 'archived';
$nodes['published_then_archived_article']->changed->value = $time--;
$nodes['published_then_archived_article']->save();
$nodes['draft_article'] = $this->drupalCreateNode(['type' => 'article', 'changed' => $time--, 'moderation_state' => 'draft']);
$nodes['draft_page_1'] = $this->drupalCreateNode(['type' => 'page', 'changed' => $time--, 'moderation_state' => 'draft']);
$nodes['draft_page_2'] = $this->drupalCreateNode(['type' => 'page', 'changed' => $time, 'moderation_state' => 'draft']);
// Verify view, edit, and delete links for any content.
$this->drupalGet('admin/content/moderated');
$assert_session->statusCodeEquals(200);
// Check that nodes with pending revisions appear in the view.
$node_type_labels = $this->xpath('//td[contains(@class, "views-field-type")]');
$delta = 0;
foreach ($nodes as $node) {
$assert_session->linkByHrefExists('node/' . $node->id());
$assert_session->linkByHrefExists('node/' . $node->id() . '/edit');
$assert_session->linkByHrefExists('node/' . $node->id() . '/delete');
// Verify that we can see the content type label.
$this->assertEquals($node->type->entity->label(), trim($node_type_labels[$delta]->getText()));
$delta++;
}
// Check that nodes that are not moderated or do not have a pending revision
// do not appear in the view.
foreach ($excluded_nodes as $node) {
$assert_session->linkByHrefNotExists('node/' . $node->id());
}
// Check that the latest revision is displayed.
$assert_session->pageTextContains('first article - draft');
$assert_session->pageTextNotContains('first article - published');
// Verify filtering by moderation state.
$this->drupalGet('admin/content/moderated', ['query' => ['moderation_state' => 'editorial-draft']]);
$assert_session->linkByHrefExists('node/' . $nodes['published_then_draft_article']->id() . '/edit');
$assert_session->linkByHrefExists('node/' . $nodes['draft_article']->id() . '/edit');
$assert_session->linkByHrefExists('node/' . $nodes['draft_page_1']->id() . '/edit');
$assert_session->linkByHrefExists('node/' . $nodes['draft_page_1']->id() . '/edit');
$assert_session->linkByHrefNotExists('node/' . $nodes['published_then_archived_article']->id() . '/edit');
// Verify filtering by moderation state and content type.
$this->drupalGet('admin/content/moderated', ['query' => ['moderation_state' => 'editorial-draft', 'type' => 'page']]);
$assert_session->linkByHrefExists('node/' . $nodes['draft_page_1']->id() . '/edit');
$assert_session->linkByHrefExists('node/' . $nodes['draft_page_2']->id() . '/edit');
$assert_session->linkByHrefNotExists('node/' . $nodes['published_then_draft_article']->id() . '/edit');
$assert_session->linkByHrefNotExists('node/' . $nodes['published_then_archived_article']->id() . '/edit');
$assert_session->linkByHrefNotExists('node/' . $nodes['draft_article']->id() . '/edit');
}
/**
* Tests the moderated content page with multilingual content.
*/
public function testModeratedContentPageMultilingual(): void {
ConfigurableLanguage::createFromLangcode('fr')->save();
$node = $this->drupalCreateNode([
'type' => 'article',
'moderation_state' => 'published',
'title' => 'en article published',
]);
$node->title = 'en draft revision';
$node->moderation_state = 'draft';
$node->save();
$translation = Node::load($node->id())->addTranslation('fr');
$translation->title = 'fr draft revision';
$translation->moderation_state = 'draft';
$translation->save();
$this->drupalLogin($this->adminUser);
// The moderated content view should show both the pending en draft revision
// and the pending fr draft revision.
$this->drupalGet('admin/content/moderated');
$this->assertSession()->linkExists('fr draft revision');
$this->assertSession()->linkExists('en draft revision');
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\Entity\Node;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
/**
* Test the content moderation actions.
*
* @group content_moderation
*/
class ModerationActionsTest extends BrowserTestBase {
use ContentTypeCreationTrait;
use ContentModerationTestTrait;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'content_moderation',
'node',
'views',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$moderated_bundle = $this->createContentType(['type' => 'moderated_bundle']);
$moderated_bundle->save();
$standard_bundle = $this->createContentType(['type' => 'standard_bundle']);
$standard_bundle->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'moderated_bundle');
$workflow->save();
$admin = $this->drupalCreateUser([
'access content overview',
'administer nodes',
'bypass node access',
]);
$this->drupalLogin($admin);
}
/**
* Tests the node status actions report moderation status to users correctly.
*
* @dataProvider nodeStatusActionsTestCases
*/
public function testNodeStatusActions($action, $bundle, $warning_appears, $starting_status, $final_status): void {
// Create and run an action on a node.
$node = Node::create([
'type' => $bundle,
'title' => $this->randomString(),
'status' => $starting_status,
]);
if ($bundle == 'moderated_bundle') {
$node->moderation_state->value = $starting_status ? 'published' : 'draft';
}
$node->save();
$this->drupalGet('admin/content');
$this->submitForm([
'node_bulk_form[0]' => TRUE,
'action' => $action,
], 'Apply to selected items');
if ($warning_appears) {
if ($action == 'node_publish_action') {
$this->assertSession()->statusMessageContains(node_get_type_label($node) . ' content items were skipped as they are under moderation and may not be directly published.', 'warning');
}
else {
$this->assertSession()->statusMessageContains(node_get_type_label($node) . ' content items were skipped as they are under moderation and may not be directly unpublished.', 'warning');
}
}
else {
$this->assertSession()->statusMessageNotExists('warning');
}
// Ensure after the action has run, the node matches the expected status.
$node = Node::load($node->id());
$this->assertEquals($node->isPublished(), $final_status);
}
/**
* Test cases for ::testNodeStatusActions.
*
* @return array
* An array of test cases.
*/
public static function nodeStatusActionsTestCases() {
return [
'Moderated bundle shows warning (publish action)' => [
'node_publish_action',
'moderated_bundle',
TRUE,
// If the node starts out unpublished, the action should not work.
FALSE,
FALSE,
],
'Moderated bundle shows warning (unpublish action)' => [
'node_unpublish_action',
'moderated_bundle',
TRUE,
// If the node starts out published, the action should not work.
TRUE,
TRUE,
],
'Normal bundle works (publish action)' => [
'node_publish_action',
'standard_bundle',
FALSE,
// If the node starts out unpublished, the action should work.
FALSE,
TRUE,
],
'Normal bundle works (unpublish action)' => [
'node_unpublish_action',
'standard_bundle',
FALSE,
// If the node starts out published, the action should work.
TRUE,
FALSE,
],
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\content_translation\Traits\ContentTranslationTestTrait;
/**
* Test content_moderation functionality with content_translation.
*
* @group content_moderation
*/
class ModerationContentTranslationTest extends BrowserTestBase {
use ContentModerationTestTrait;
use ContentTranslationTestTrait;
/**
* A user with permission to bypass access content.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'node',
'locale',
'content_translation',
];
/**
* {@inheritdoc}
*
* @todo Remove and fix test to not rely on super user.
* @see https://www.drupal.org/project/drupal/issues/3437620
*/
protected bool $usesSuperUserAccessPolicy = TRUE;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->rootUser);
// Create an Article content type.
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article'])->save();
static::createLanguageFromLangcode('fr');
// Enable content translation on articles.
$this->enableContentTranslation('node', 'article');
// Adding languages requires a container rebuild in the test running
// environment so that multilingual services are used.
$this->rebuildContainer();
}
/**
* Tests existing translations being edited after enabling content moderation.
*/
public function testModerationWithExistingContent(): void {
// Create a published article in English.
$edit = [
'title[0][value]' => 'Published English node',
'langcode[0][value]' => 'en',
];
$this->drupalGet('node/add/article');
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('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('Add');
$edit = [
'title[0][value]' => 'Published French node',
];
$this->submitForm($edit, 'Save (this translation)');
$this->assertSession()->pageTextContains('Article Published French node has been updated.');
// Install content moderation and enable moderation on Article node type.
\Drupal::service('module_installer')->install(['content_moderation']);
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'article');
$workflow->save();
$this->drupalLogin($this->rootUser);
// Edit the English node.
$this->drupalGet('node/' . $english_node->id() . '/edit');
$this->assertSession()->statusCodeEquals(200);
$edit = [
'title[0][value]' => 'Published English new node',
];
$this->submitForm($edit, 'Save');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Article Published English new node has been updated.');
// Edit the French translation.
$this->drupalGet('fr/node/' . $english_node->id() . '/edit');
$this->assertSession()->statusCodeEquals(200);
$edit = [
'title[0][value]' => 'Published French new node',
];
$this->submitForm($edit, 'Save (this translation)');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Article Published French new node has been updated.');
}
}

View File

@@ -0,0 +1,572 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Url;
use Drupal\Tests\content_translation\Traits\ContentTranslationTestTrait;
/**
* Tests the moderation form, specifically on nodes.
*
* @group content_moderation
* @group #slow
*/
class ModerationFormTest extends ModerationStateTestBase {
use ContentTranslationTestTrait;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'node',
'content_moderation',
'locale',
'content_translation',
];
/**
* {@inheritdoc}
*
* @todo Remove and fix test to not rely on super user.
* @see https://www.drupal.org/project/drupal/issues/3437620
*/
protected bool $usesSuperUserAccessPolicy = TRUE;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE);
$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 pending revision.
*
* @see \Drupal\content_moderation\EntityOperations
* @see \Drupal\Tests\content_moderation\Functional\ModerationStateBlockTest::testCustomBlockModeration
*/
public function testModerationForm(): void {
// Test the states that appear by default when creating a new item of
// content.
$this->drupalGet('node/add/moderated_content');
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
// Previewing a new item of content should not change the available states.
$this->submitForm([
'moderation_state[0][state]' => 'published',
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
], 'Preview');
$this->clickLink('Back to content editing');
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
// Create new moderated content in draft.
$this->submitForm(['moderation_state[0][state]' => 'draft'], 'Save');
$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 canonical view should have a moderation form, because it is not the
// live revision.
$this->drupalGet($canonical_path);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->fieldExists('edit-new-state');
// The latest version page should not show, because there is no pending
// revision.
$this->drupalGet($latest_version_path);
$this->assertSession()->statusCodeEquals(403);
// Update the draft.
$this->drupalGet($edit_path);
$this->submitForm([
'body[0][value]' => 'Second version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save');
// The canonical view should have a moderation form, because it is not the
// live revision.
$this->drupalGet($canonical_path);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->fieldExists('edit-new-state');
// Preview the draft.
$this->drupalGet($edit_path);
$this->submitForm([
'body[0][value]' => 'Second version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Preview');
// The preview view should not have a moderation form.
$preview_url = Url::fromRoute('entity.node.preview', [
'node_preview' => $node->uuid(),
'view_mode_id' => 'full',
]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->addressEquals($preview_url);
$this->assertSession()->fieldNotExists('edit-new-state');
// The latest version page should not show, because there is still no
// pending revision.
$this->drupalGet($latest_version_path);
$this->assertSession()->statusCodeEquals(403);
// Publish the draft.
$this->drupalGet($edit_path);
$this->submitForm([
'body[0][value]' => 'Third version of the content.',
'moderation_state[0][state]' => 'published',
], 'Save');
// Check widget default value.
$this->drupalGet($edit_path);
$this->assertSession()->fieldValueEquals('moderation_state[0][state]', 'published');
// Preview the content while selecting the "draft" state and when the user
// returns to the edit form, ensure all of the available transitions are
// still those available from the "published" source state.
$this->submitForm(['moderation_state[0][state]' => 'draft'], 'Preview');
$this->clickLink('Back to content editing');
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
// The published view should not have a moderation form, because it is the
// live revision.
$this->drupalGet($canonical_path);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->fieldNotExists('edit-new-state');
// The latest version page should not show, because there is still no
// pending revision.
$this->drupalGet($latest_version_path);
$this->assertSession()->statusCodeEquals(403);
// Make a pending revision.
$this->drupalGet($edit_path);
$this->submitForm([
'body[0][value]' => 'Fourth version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save');
// The published view should not have a moderation form, because it is the
// live revision.
$this->drupalGet($canonical_path);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->fieldNotExists('edit-new-state');
// The latest version page should show the moderation form and have "Draft"
// status, because the pending revision is in "Draft".
$this->drupalGet($latest_version_path);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->fieldExists('edit-new-state');
$this->assertSession()->pageTextContains('Draft');
// Submit the moderation form to change status to published.
$this->drupalGet($latest_version_path);
$this->submitForm(['new_state' => 'published'], 'Apply');
// The latest version page should not show, because there is no
// pending revision.
$this->drupalGet($latest_version_path);
$this->assertSession()->statusCodeEquals(403);
}
/**
* Tests moderation non-bundle entity type.
*/
public function testNonBundleModerationForm(): void {
$this->drupalLogin($this->rootUser);
$this->workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_mulrevpub', 'entity_test_mulrevpub');
$this->workflow->save();
// Create new moderated content in draft.
$this->drupalGet('entity_test_mulrevpub/add');
$this->submitForm(['moderation_state[0][state]' => 'draft'], 'Save');
// The latest version page should not show, because there is no pending
// revision.
$this->drupalGet('/entity_test_mulrevpub/manage/1/latest');
$this->assertSession()->statusCodeEquals(403);
// Update the draft.
$this->drupalGet('entity_test_mulrevpub/manage/1/edit');
$this->submitForm(['moderation_state[0][state]' => 'draft'], 'Save');
// The latest version page should not show, because there is still no
// pending revision.
$this->drupalGet('/entity_test_mulrevpub/manage/1/latest');
$this->assertSession()->statusCodeEquals(403);
// Publish the draft.
$this->drupalGet('entity_test_mulrevpub/manage/1/edit');
$this->submitForm(['moderation_state[0][state]' => 'published'], 'Save');
// The published view should not have a moderation form, because it is the
// default revision.
$this->drupalGet('entity_test_mulrevpub/manage/1');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextNotContains('Status');
// The latest version page should not show, because there is still no
// pending revision.
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertSession()->statusCodeEquals(403);
// Make a pending revision.
$this->drupalGet('entity_test_mulrevpub/manage/1/edit');
$this->submitForm(['moderation_state[0][state]' => 'draft'], 'Save');
// The published view should not have a moderation form, because it is the
// default revision.
$this->drupalGet('entity_test_mulrevpub/manage/1');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextNotContains('Status');
// The latest version page should show the moderation form and have "Draft"
// status, because the pending revision is in "Draft".
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Moderation state');
$this->assertSession()->pageTextContains('Draft');
// Submit the moderation form to change status to published.
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->submitForm(['new_state' => 'published'], 'Apply');
// The latest version page should not show, because there is no
// pending revision.
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertSession()->statusCodeEquals(403);
}
/**
* Tests the revision author is updated when the moderation form is used.
*/
public function testModerationFormSetsRevisionAuthor(): void {
// Create new moderated content in published.
$node = $this->createNode(['type' => 'moderated_content', 'moderation_state' => 'published']);
// Make a pending revision.
$node->title = $this->randomMachineName();
$node->moderation_state->value = 'draft';
$node->setRevisionCreationTime(12345);
$node->save();
$another_user = $this->drupalCreateUser($this->permissions);
$this->grantUserPermissionToCreateContentOfType($another_user, 'moderated_content');
$this->drupalLogin($another_user);
$this->drupalGet(sprintf('node/%d/latest', $node->id()));
$this->submitForm(['new_state' => 'published'], 'Apply');
$this->drupalGet(sprintf('node/%d/revisions', $node->id()));
$this->assertSession()->pageTextContains('by ' . $another_user->getAccountName());
// Verify the revision creation time has been updated.
$node = $node->load($node->id());
$this->assertGreaterThan(12345, $node->getRevisionCreationTime());
}
/**
* Tests translated and moderated nodes.
*/
public function testContentTranslationNodeForm(): void {
$this->drupalLogin($this->rootUser);
// Add French language.
static::createLanguageFromLangcode('fr');
// Enable content translation on moderated_content.
$this->enableContentTranslation('node', 'moderated_content');
// Adding languages requires a container rebuild in the test running
// environment so that multilingual services are used.
$this->rebuildContainer();
// Create new moderated content in draft (revision 1).
$this->drupalGet('node/add/moderated_content');
$this->submitForm([
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save');
$this->assertSession()->elementExists('xpath', '//ul[@class="entity-moderation-form"]');
$node = $this->drupalGetNodeByTitle('Some moderated content');
$this->assertNotEmpty($node->language(), 'en');
$edit_path = sprintf('node/%d/edit', $node->id());
$translate_path = sprintf('node/%d/translations/add/en/fr', $node->id());
$latest_version_path = sprintf('node/%d/latest', $node->id());
$french = \Drupal::languageManager()->getLanguage('fr');
$this->drupalGet($latest_version_path);
$this->assertSession()->statusCodeEquals(403);
$this->assertSession()->elementNotExists('xpath', '//ul[@class="entity-moderation-form"]');
// Add french translation (revision 2).
$this->drupalGet($translate_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'body[0][value]' => 'Second version of the content.',
'moderation_state[0][state]' => 'published',
], 'Save (this translation)');
$this->drupalGet($latest_version_path, ['language' => $french]);
$this->assertSession()->statusCodeEquals(403);
$this->assertSession()->elementNotExists('xpath', '//ul[@class="entity-moderation-form"]');
// Add french pending revision (revision 3).
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
// Preview the content while selecting the "draft" state and when the user
// returns to the edit form, ensure all of the available transitions are
// still those available from the "published" source state.
$this->submitForm(['moderation_state[0][state]' => 'draft'], 'Preview');
$this->clickLink('Back to content editing');
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'body[0][value]' => 'Third version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save (this translation)');
$this->drupalGet($latest_version_path, ['language' => $french]);
$this->assertSession()->elementExists('xpath', '//ul[@class="entity-moderation-form"]');
$this->drupalGet($edit_path);
$this->clickLink('Delete');
$this->assertSession()->buttonExists('Delete');
$this->drupalGet($latest_version_path);
$this->assertSession()->elementNotExists('xpath', '//ul[@class="entity-moderation-form"]');
// Publish the french pending revision (revision 4).
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'body[0][value]' => 'Fifth version of the content.',
'moderation_state[0][state]' => 'published',
], 'Save (this translation)');
$this->drupalGet($latest_version_path, ['language' => $french]);
$this->assertSession()->elementNotExists('xpath', '//ul[@class="entity-moderation-form"]');
// Publish the English pending revision (revision 5).
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'body[0][value]' => 'Sixth version of the content.',
'moderation_state[0][state]' => 'published',
], 'Save (this translation)');
$this->drupalGet($latest_version_path);
$this->assertSession()->elementNotExists('xpath', '//ul[@class="entity-moderation-form"]');
// Make sure we are allowed to create a pending French revision.
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
// Add an English pending revision (revision 6).
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'body[0][value]' => 'Seventh version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save (this translation)');
$this->drupalGet($latest_version_path);
$this->assertSession()->elementExists('xpath', '//ul[@class="entity-moderation-form"]');
$this->drupalGet($latest_version_path, ['language' => $french]);
$this->assertSession()->elementNotExists('xpath', '//ul[@class="entity-moderation-form"]');
// Publish the English pending revision (revision 7)
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'body[0][value]' => 'Eighth version of the content.',
'moderation_state[0][state]' => 'published',
], 'Save (this translation)');
$this->drupalGet($latest_version_path);
$this->assertSession()->elementNotExists('xpath', '//ul[@class="entity-moderation-form"]');
// Make sure we are allowed to create a pending French revision.
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
// Make sure we are allowed to create a pending English revision.
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
// Create new moderated content (revision 1).
$this->drupalGet('node/add/moderated_content');
$this->submitForm([
'title[0][value]' => 'Third moderated content',
'moderation_state[0][state]' => 'published',
], 'Save');
$node = $this->drupalGetNodeByTitle('Third moderated content');
$this->assertNotEmpty($node->language(), 'en');
$edit_path = sprintf('node/%d/edit', $node->id());
$translate_path = sprintf('node/%d/translations/add/en/fr', $node->id());
// Translate it, without updating data (revision 2).
$this->drupalGet($translate_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'moderation_state[0][state]' => 'draft',
], 'Save (this translation)');
// Add another draft for the translation (revision 3).
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'moderation_state[0][state]' => 'draft',
], 'Save (this translation)');
// Updating and publishing the french translation is still possible.
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'moderation_state[0][state]' => 'published',
], 'Save (this translation)');
// Now the french translation is published, an english draft can be added.
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
$this->submitForm([
'moderation_state[0][state]' => 'draft',
], 'Save (this translation)');
}
/**
* Tests the moderation_state field when an alternative widget is set.
*/
public function testAlternativeModerationStateWidget(): void {
$entity_form_display = EntityFormDisplay::load('node.moderated_content.default');
$entity_form_display->setComponent('moderation_state', [
'type' => 'string_textfield',
'region' => 'content',
]);
$entity_form_display->save();
$this->drupalGet('node/add/moderated_content');
$this->submitForm([
'title[0][value]' => 'Test content',
'moderation_state[0][value]' => 'published',
], 'Save');
$this->assertSession()->pageTextContains('Moderated content Test content has been created.');
}
/**
* Tests that workflows and states can not be deleted if they are in use.
*
* @covers \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration::workflowHasData
* @covers \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration::workflowStateHasData
*/
public function testWorkflowInUse(): void {
$user = $this->createUser([
'administer workflows',
'create moderated_content content',
'edit own moderated_content content',
'use editorial transition create_new_draft',
'use editorial transition publish',
'use editorial transition archive',
]);
$this->drupalLogin($user);
$paths = [
'archived_state' => 'admin/config/workflow/workflows/manage/editorial/state/archived/delete',
'editorial_workflow' => 'admin/config/workflow/workflows/manage/editorial/delete',
];
$messages = [
'archived_state' => 'This workflow state is in use. You cannot remove this workflow state until you have removed all content using it.',
'editorial_workflow' => 'This workflow is in use. You cannot remove this workflow until you have removed all content using it.',
];
foreach ($paths as $path) {
$this->drupalGet($path);
$this->assertSession()->buttonExists('Delete');
}
// Create new moderated content in draft.
$this->drupalGet('node/add/moderated_content');
$this->submitForm([
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save');
// The archived state is not used yet, so can still be deleted.
$this->drupalGet($paths['archived_state']);
$this->assertSession()->buttonExists('Delete');
// The workflow is being used, so can't be deleted.
$this->drupalGet($paths['editorial_workflow']);
$this->assertSession()->buttonNotExists('Delete');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains($messages['editorial_workflow']);
$node = $this->drupalGetNodeByTitle('Some moderated content');
$this->drupalGet('node/' . $node->id() . '/edit');
$this->submitForm(['moderation_state[0][state]' => 'published'], 'Save');
$this->drupalGet('node/' . $node->id() . '/edit');
$this->submitForm(['moderation_state[0][state]' => 'archived'], 'Save');
// Now the archived state is being used so it can not be deleted either.
foreach ($paths as $type => $path) {
$this->drupalGet($path);
$this->assertSession()->buttonNotExists('Delete');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains($messages[$type]);
}
}
}

View File

@@ -0,0 +1,681 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\NodeInterface;
use Drupal\Tests\content_translation\Traits\ContentTranslationTestTrait;
/**
* Test content_moderation functionality with localization and translation.
*
* @group content_moderation
* @group #slow
*/
class ModerationLocaleTest extends ModerationStateTestBase {
use ContentTranslationTestTrait;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'node',
'content_moderation',
'locale',
'content_translation',
];
/**
* {@inheritdoc}
*
* @todo Remove and fix test to not rely on super user.
* @see https://www.drupal.org/project/drupal/issues/3437620
*/
protected bool $usesSuperUserAccessPolicy = TRUE;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->rootUser);
// Enable moderation on Article node type.
$this->createContentTypeFromUi('Article', 'article', TRUE);
// Add French and Italian languages.
static::createLanguageFromLangcode('fr');
static::createLanguageFromLangcode('it');
// Enable content translation on articles.
$this->enableContentTranslation('node', 'article');
// Adding languages requires a container rebuild in the test running
// environment so that multilingual services are used.
$this->rebuildContainer();
}
/**
* Tests article translations can be moderated separately.
*/
public function testTranslateModeratedContent(): void {
// Create a published article in English.
$edit = [
'title[0][value]' => 'Published English node',
'langcode[0][value]' => 'en',
'moderation_state[0][state]' => 'published',
];
$this->drupalGet('node/add/article');
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('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('Add');
$edit = [
'title[0][value]' => 'French node Draft',
'moderation_state[0][state]' => 'draft',
];
$this->submitForm($edit, 'Save (this translation)');
// Here the error has occurred "The website encountered an unexpected error.
// Try again later."
// If the translation has got lost.
$this->assertSession()->pageTextContains('Article French node Draft has been updated.');
// Create an article in English.
$edit = [
'title[0][value]' => 'English node',
'langcode[0][value]' => 'en',
'moderation_state[0][state]' => 'draft',
];
$this->drupalGet('node/add/article');
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('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('Add');
$edit = [
'title[0][value]' => 'French node',
'moderation_state[0][state]' => 'draft',
];
$this->submitForm($edit, 'Save (this translation)');
$this->assertSession()->pageTextContains('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->drupalGet('node/' . $english_node->id() . '/edit');
$this->submitForm(['moderation_state[0][state]' => 'published'], 'Save (this translation)');
$this->assertSession()->pageTextContains('Article English node has been updated.');
$english_node = $this->drupalGetNodeByTitle('English node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEquals('French node', $french_node->label());
$this->assertEquals('published', $english_node->moderation_state->value);
$this->assertTrue($english_node->isPublished());
$this->assertEquals('draft', $french_node->moderation_state->value);
$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',
'moderation_state[0][state]' => 'draft',
];
$this->drupalGet('node/add/article');
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('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('Add');
$edit = [
'title[0][value]' => 'Translated node',
'moderation_state[0][state]' => 'draft',
];
$this->submitForm($edit, 'Save (this translation)');
$this->assertSession()->pageTextContains('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->drupalGet('fr/node/' . $english_node->id() . '/edit');
$this->submitForm(['moderation_state[0][state]' => 'published'], 'Save (this translation)');
$this->assertSession()->pageTextContains('Article Translated node has been updated.');
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEquals('published', $french_node->moderation_state->value);
$this->assertTrue($french_node->isPublished());
$this->assertEquals('draft', $english_node->moderation_state->value);
$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',
'moderation_state[0][state]' => 'draft',
];
$this->drupalGet('fr/node/' . $english_node->id() . '/edit');
$this->submitForm($edit, 'Save (this translation)');
$this->assertSession()->pageTextContains('Article New draft of translated node has been updated.');
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEquals('published', $french_node->moderation_state->value);
$this->assertTrue($french_node->isPublished());
$this->assertEquals('Translated node', $french_node->getTitle(), 'The default revision of the published translation remains the same.');
// Publish the French article before testing the archive transition.
$this->drupalGet('fr/node/' . $english_node->id() . '/edit');
$this->submitForm(['moderation_state[0][state]' => 'published'], 'Save (this translation)');
$this->assertSession()->pageTextContains('Article New draft of translated node has been updated.');
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEquals('published', $french_node->moderation_state->value);
$this->assertTrue($french_node->isPublished());
$this->assertEquals('New draft of translated node', $french_node->getTitle(), 'The draft has replaced the published revision.');
// Publish the English article before testing the archive transition.
$this->drupalGet('node/' . $english_node->id() . '/edit');
$this->submitForm([
'moderation_state[0][state]' => 'published',
], 'Save (this translation)');
$this->assertSession()->pageTextContains('Article Another node has been updated.');
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$this->assertEquals('published', $english_node->moderation_state->value);
// Archive the node and its translation.
$this->drupalGet('node/' . $english_node->id() . '/edit');
$this->submitForm([
'moderation_state[0][state]' => 'archived',
], 'Save (this translation)');
$this->assertSession()->pageTextContains('Article Another node has been updated.');
$this->drupalGet('fr/node/' . $english_node->id() . '/edit');
$this->submitForm([
'moderation_state[0][state]' => 'archived',
], 'Save (this translation)');
$this->assertSession()->pageTextContains('Article New draft of translated node has been updated.');
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEquals('archived', $english_node->moderation_state->value);
$this->assertFalse($english_node->isPublished());
$this->assertEquals('archived', $french_node->moderation_state->value);
$this->assertFalse($french_node->isPublished());
}
/**
* Tests that individual translations can be moderated independently.
*/
public function testLanguageIndependentContentModeration(): void {
// Create a published article in English (revision 1).
$this->drupalGet('node/add/article');
$node = $this->submitNodeForm('Test 1.1 EN', 'published');
$this->assertNotLatestVersionPage($node);
$edit_path = $node->toUrl('edit-form');
$translate_path = $node->toUrl('drupal:content-translation-overview');
// Create a new English draft (revision 2).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 1.2 EN', 'draft', TRUE);
$this->assertLatestVersionPage($node);
// Add a French translation draft (revision 3).
$this->drupalGet($translate_path);
$this->clickLink('Add');
$this->submitNodeForm('Test 1.3 FR', 'draft');
$fr_node = $this->loadTranslation($node, 'fr');
$this->assertLatestVersionPage($fr_node);
$this->assertModerationForm($node);
// Add an Italian translation draft (revision 4).
$this->drupalGet($translate_path);
$this->clickLink('Add');
$this->submitNodeForm('Test 1.4 IT', 'draft');
$it_node = $this->loadTranslation($node, 'it');
$this->assertLatestVersionPage($it_node);
$this->assertModerationForm($node);
$this->assertModerationForm($fr_node);
// Publish the English draft (revision 5).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 1.5 EN', 'published', TRUE);
$this->assertNotLatestVersionPage($node);
$this->assertModerationForm($fr_node);
$this->assertModerationForm($it_node);
// Publish the Italian draft (revision 6).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 3);
$this->submitNodeForm('Test 1.6 IT', 'published');
$this->assertNotLatestVersionPage($it_node);
$this->assertNoModerationForm($node);
$this->assertModerationForm($fr_node);
// Publish the French draft (revision 7).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 2);
$this->submitNodeForm('Test 1.7 FR', 'published');
$this->assertNotLatestVersionPage($fr_node);
$this->assertNoModerationForm($node);
$this->assertNoModerationForm($it_node);
// Create an Italian draft (revision 8).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 3);
$this->submitNodeForm('Test 1.8 IT', 'draft');
$this->assertLatestVersionPage($it_node);
$this->assertNoModerationForm($node);
$this->assertNoModerationForm($fr_node);
// Create a French draft (revision 9).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 2);
$this->submitNodeForm('Test 1.9 FR', 'draft');
$this->assertLatestVersionPage($fr_node);
$this->assertNoModerationForm($node);
$this->assertModerationForm($it_node);
// Create an English draft (revision 10).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 1.10 EN', 'draft');
$this->assertLatestVersionPage($node);
$this->assertModerationForm($fr_node);
$this->assertModerationForm($it_node);
// Now start from a draft article in English (revision 1).
$this->drupalGet('node/add/article');
$node2 = $this->submitNodeForm('Test 2.1 EN', 'draft', TRUE);
$this->assertNotLatestVersionPage($node2, TRUE);
$edit_path = $node2->toUrl('edit-form');
$translate_path = $node2->toUrl('drupal:content-translation-overview');
// Add a French translation (revision 2).
$this->drupalGet($translate_path);
$this->clickLink('Add');
$this->submitNodeForm('Test 2.2 FR', 'draft');
$fr_node2 = $this->loadTranslation($node2, 'fr');
$this->assertNotLatestVersionPage($fr_node2, TRUE);
$this->assertModerationForm($node2, FALSE);
// Add an Italian translation (revision 3).
$this->drupalGet($translate_path);
$this->clickLink('Add');
$this->submitNodeForm('Test 2.3 IT', 'draft');
$it_node2 = $this->loadTranslation($node2, 'it');
$this->assertNotLatestVersionPage($it_node2, TRUE);
$this->assertModerationForm($node2, FALSE);
$this->assertModerationForm($fr_node2, FALSE);
// Publish the English draft (revision 4).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 2.4 EN', 'published', TRUE);
$this->assertNotLatestVersionPage($node2);
$this->assertModerationForm($fr_node2, FALSE);
$this->assertModerationForm($it_node2, FALSE);
// Publish the Italian draft (revision 5).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 3);
$this->submitNodeForm('Test 2.5 IT', 'published');
$this->assertNotLatestVersionPage($it_node2);
$this->assertNoModerationForm($node2);
$this->assertModerationForm($fr_node2, FALSE);
// Publish the French draft (revision 6).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 2);
$this->submitNodeForm('Test 2.6 FR', 'published');
$this->assertNotLatestVersionPage($fr_node2);
$this->assertNoModerationForm($node2);
$this->assertNoModerationForm($it_node2);
// Now that all revision translations are published, verify that the
// moderation form is never displayed on revision pages.
/** @var \Drupal\node\NodeStorageInterface $storage */
$storage = $this->container->get('entity_type.manager')->getStorage('node');
foreach (range(11, 16) as $revision_id) {
/** @var \Drupal\node\NodeInterface $revision */
$revision = $storage->loadRevision($revision_id);
foreach ($revision->getTranslationLanguages() as $langcode => $language) {
if ($revision->isRevisionTranslationAffected()) {
$this->drupalGet($revision->toUrl('revision'));
$this->assertFalse($this->hasModerationForm(), 'Moderation form is not displayed correctly for revision ' . $revision_id);
break;
}
}
}
// Create an Italian draft (revision 7).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 3);
$this->submitNodeForm('Test 2.7 IT', 'draft');
$this->assertLatestVersionPage($it_node2);
$this->assertNoModerationForm($node2);
$this->assertNoModerationForm($fr_node2);
// Create a French draft (revision 8).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 2);
$this->submitNodeForm('Test 2.8 FR', 'draft');
$this->assertLatestVersionPage($fr_node2);
$this->assertNoModerationForm($node2);
$this->assertModerationForm($it_node2);
// Create an English draft (revision 9).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 2.9 EN', 'draft', TRUE);
$this->assertLatestVersionPage($node2);
$this->assertModerationForm($fr_node2);
$this->assertModerationForm($it_node2);
// Now publish a draft in another language first and verify that the
// moderation form is not displayed on the English node view page.
$this->drupalGet('node/add/article');
$node3 = $this->submitNodeForm('Test 3.1 EN', 'published');
$this->assertNotLatestVersionPage($node3);
$edit_path = $node3->toUrl('edit-form');
$translate_path = $node3->toUrl('drupal:content-translation-overview');
// Create an English draft (revision 2).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 3.2 EN', 'draft', TRUE);
$this->assertLatestVersionPage($node3);
// Add a French translation (revision 3).
$this->drupalGet($translate_path);
$this->clickLink('Add');
$this->submitNodeForm('Test 3.3 FR', 'draft');
$fr_node3 = $this->loadTranslation($node3, 'fr');
$this->assertLatestVersionPage($fr_node3);
$this->assertModerationForm($node3);
// Publish the French draft (revision 4).
$this->drupalGet($translate_path);
$this->clickLink('Edit', 2);
$this->submitNodeForm('Test 3.4 FR', 'published');
$this->assertNotLatestVersionPage($fr_node3);
$this->assertModerationForm($node3);
}
/**
* Checks that new translation values are populated properly.
*/
public function testNewTranslationSourceValues(): void {
// Create a published article in Italian (revision 1).
$this->drupalGet('node/add/article');
$node = $this->submitNodeForm('Test 1.1 IT', 'published', TRUE, 'it');
$this->assertNotLatestVersionPage($node);
// Create a new draft (revision 2).
$this->drupalGet($node->toUrl('edit-form'));
$this->submitNodeForm('Test 1.2 IT', 'draft', TRUE);
$this->assertLatestVersionPage($node);
// Create an English draft (revision 3) and verify that the Italian draft
// values are used as source values.
$url = $node->toUrl('drupal:content-translation-add');
$url->setRouteParameter('source', 'it');
$url->setRouteParameter('target', 'en');
$this->drupalGet($url);
$this->assertSession()->pageTextContains('Test 1.2 IT');
$this->submitNodeForm('Test 1.3 EN', 'draft');
$this->assertLatestVersionPage($node);
// Create a French draft (without saving) and verify that the Italian draft
// values are used as source values.
$url->setRouteParameter('target', 'fr');
$this->drupalGet($url);
$this->assertSession()->pageTextContains('Test 1.2 IT');
// Now switch source language and verify that the English draft values are
// used as source values.
$url->setRouteParameter('source', 'en');
$this->drupalGet($url);
$this->assertSession()->pageTextContains('Test 1.3 EN');
}
/**
* Tests article revision history shows revisions for the correct translation.
*/
public function testTranslationRevisionsHistory(): void {
// Create a published article in English.
$edit = [
'title[0][value]' => 'English node',
'langcode[0][value]' => 'en',
'moderation_state[0][state]' => 'published',
'revision_log[0][value]' => 'Log Message - English - Published - Edit 1',
];
$this->drupalGet('node/add/article');
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('Article English node has been created.');
$node = $this->drupalGetNodeByTitle('English node');
// Add a French translation.
$this->drupalGet('node/' . $node->id() . '/translations');
$this->clickLink('Add');
$edit = [
'title[0][value]' => 'French node',
'moderation_state[0][state]' => 'draft',
'revision_log[0][value]' => 'Log Message - French - Draft - Edit 1',
];
$this->submitForm($edit, 'Save (this translation)');
// Here the error has occurred "The website encountered an unexpected error.
// Try again later."
// If the translation has got lost.
$this->assertSession()->pageTextContains('Article French node has been updated.');
$french_node = $this->loadTranslation($node, 'fr');
$this->assertEquals('published', $node->moderation_state->value);
$this->assertTrue($node->isPublished());
$this->assertEquals('draft', $french_node->moderation_state->value);
$this->assertFalse($french_node->isPublished());
// Verify the revisions history for the English node.
$this->drupalGet('node/' . $node->id() . '/revisions');
$this->assertSession()->pageTextContains('Log Message - English - Published - Edit 1');
$this->assertSession()->pageTextNotContains('Log Message - French');
// Verify the revisions history for the French node.
$this->drupalGet($french_node->language()->getId() . '/node/' . $node->id() . '/revisions');
$this->assertSession()->pageTextContains('Log Message - French - Draft - Edit 1');
$this->assertSession()->pageTextNotContains('Log Message - English');
// Create a new draft for the English article.
$edit = [
'moderation_state[0][state]' => 'draft',
'revision_log[0][value]' => 'Log Message - English - Draft - Edit 2',
];
$this->drupalGet('node/' . $node->id() . '/edit');
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('Article English node has been updated.');
// Create a new draft for the French article.
$edit = [
'moderation_state[0][state]' => 'draft',
'revision_log[0][value]' => 'Log Message - French - Draft - Edit 2',
];
$this->drupalGet($french_node->language()->getId() . '/node/' . $node->id() . '/edit');
$this->submitForm($edit, 'Save (this translation)');
$this->assertSession()->pageTextContains('Article French node has been updated.');
// Verify the revisions history for the English node.
$this->drupalGet('node/' . $node->id() . '/revisions');
$this->assertSession()->pageTextContains('Log Message - English - Published - Edit 1');
$this->assertSession()->pageTextContains('Log Message - English - Draft - Edit 2');
$this->assertSession()->pageTextNotContains('Log Message - French');
// Verify the revisions history for the French node.
$this->drupalGet($french_node->language()->getId() . '/node/' . $node->id() . '/revisions');
$this->assertSession()->pageTextContains('Log Message - French - Draft - Edit 1');
$this->assertSession()->pageTextContains('Log Message - French - Draft - Edit 2');
$this->assertSession()->pageTextNotContains('Log Message - English');
// Publish the French Node.
$edit = [
'moderation_state[0][state]' => 'published',
'revision_log[0][value]' => 'Log Message - French - Published - Edit 3',
];
$this->drupalGet($french_node->language()->getId() . '/node/' . $node->id() . '/edit');
$this->submitForm($edit, 'Save (this translation)');
$this->assertSession()->pageTextContains('Article French node has been updated.');
// Verify the revisions history for the English node.
$this->drupalGet('node/' . $node->id() . '/revisions');
$this->assertSession()->pageTextContains('Log Message - English - Published - Edit 1');
$this->assertSession()->pageTextContains('Log Message - English - Draft - Edit 2');
$this->assertSession()->pageTextNotContains('Log Message - French');
// Verify the revisions history for the French node.
$this->drupalGet($french_node->language()->getId() . '/node/' . $node->id() . '/revisions');
$this->assertSession()->pageTextContains('Log Message - French - Draft - Edit 1');
$this->assertSession()->pageTextContains('Log Message - French - Draft - Edit 2');
$this->assertSession()->pageTextContains('Log Message - French - Published - Edit 3');
$this->assertSession()->pageTextNotContains('Log Message - English');
}
/**
* Submits the node form at the current URL with the specified values.
*
* @param string $title
* The node title.
* @param string $moderation_state
* The moderation state.
* @param bool $default_translation
* (optional) Whether we are editing the default translation.
* @param string|null $langcode
* (optional) The node language. Defaults to English.
*
* @return \Drupal\node\NodeInterface|null
* A node object if a new one is being created, NULL otherwise.
*/
protected function submitNodeForm($title, $moderation_state, $default_translation = FALSE, $langcode = 'en') {
$is_new = str_contains($this->getSession()->getCurrentUrl(), '/node/add/');
$edit = [
'title[0][value]' => $title,
'moderation_state[0][state]' => $moderation_state,
];
if ($is_new) {
$default_translation = TRUE;
$edit['langcode[0][value]'] = $langcode;
}
$submit = $default_translation ? 'Save' : 'Save (this translation)';
$this->submitForm($edit, $submit);
$message = $is_new ? "Article $title has been created." : "Article $title has been updated.";
$this->assertSession()->pageTextContains($message);
return $is_new ? $this->drupalGetNodeByTitle($title) : NULL;
}
/**
* Loads the node translation for the specified language.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
* @param string $langcode
* The translation language code.
*
* @return \Drupal\node\NodeInterface
* The node translation object.
*/
protected function loadTranslation(NodeInterface $node, $langcode) {
/** @var \Drupal\node\NodeStorageInterface $storage */
$storage = $this->container->get('entity_type.manager')->getStorage('node');
// Explicitly invalidate the cache for that node, as the call below is
// statically cached.
$storage->resetCache([$node->id()]);
/** @var \Drupal\node\NodeInterface $node */
$node = $storage->loadRevision($storage->getLatestRevisionId($node->id()));
return $node->getTranslation($langcode);
}
/**
* Asserts that this is the "latest version" page for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
*
* @internal
*/
public function assertLatestVersionPage(NodeInterface $node): void {
$this->assertEquals($node->toUrl('latest-version')->setAbsolute()->toString(), $this->getSession()->getCurrentUrl());
$this->assertModerationForm($node);
}
/**
* Asserts that this is not the "latest version" page for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
* @param bool $moderation_form
* (optional) Whether the page should contain the moderation form. Defaults
* to FALSE.
*
* @internal
*/
public function assertNotLatestVersionPage(NodeInterface $node, bool $moderation_form = FALSE): void {
$this->assertNotEquals($node->toUrl('latest-version')->setAbsolute()->toString(), $this->getSession()->getCurrentUrl());
if ($moderation_form) {
$this->assertModerationForm($node, FALSE);
}
else {
$this->assertNoModerationForm($node);
}
}
/**
* Asserts that the moderation form is displayed for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
* @param bool $latest_tab
* (optional) Whether the node form is expected to be displayed on the
* latest version page or on the node view page. Defaults to the former.
*
* @internal
*/
public function assertModerationForm(NodeInterface $node, bool $latest_tab = TRUE): void {
$this->drupalGet($node->toUrl());
$this->assertEquals(!$latest_tab, $this->hasModerationForm());
$this->drupalGet($node->toUrl('latest-version'));
$this->assertEquals($latest_tab, $this->hasModerationForm());
}
/**
* Asserts that the moderation form is not displayed for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
*
* @internal
*/
public function assertNoModerationForm(NodeInterface $node): void {
$this->drupalGet($node->toUrl());
$this->assertFalse($this->hasModerationForm());
$this->drupalGet($node->toUrl('latest-version'));
$this->assertEquals(403, $this->getSession()->getStatusCode());
}
/**
* Checks whether the page contains the moderation form.
*
* @return bool
* TRUE if the moderation form could be find in the page, FALSE otherwise.
*/
public function hasModerationForm() {
return (bool) $this->xpath('//ul[@class="entity-moderation-form"]');
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
/**
* Test revision revert.
*
* @group content_moderation
*/
class ModerationRevisionRevertTest extends BrowserTestBase {
use ContentTypeCreationTrait;
use ContentModerationTestTrait;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'content_moderation',
'node',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$moderated_bundle = $this->createContentType(['type' => 'moderated_bundle']);
$moderated_bundle->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'moderated_bundle');
$workflow->save();
/** @var \Drupal\Core\Routing\RouteBuilderInterface $router_builder */
$router_builder = $this->container->get('router.builder');
$router_builder->rebuildIfNeeded();
$admin = $this->drupalCreateUser([
'access content overview',
'administer nodes',
'bypass node access',
'view all revisions',
'use editorial transition create_new_draft',
'use editorial transition publish',
]);
$this->drupalLogin($admin);
}
/**
* Tests that reverting a revision works.
*/
public function testEditingAfterRevertRevision(): void {
// Create a draft.
$this->drupalGet('node/add/moderated_bundle');
$this->submitForm([
'title[0][value]' => 'First draft node',
'moderation_state[0][state]' => 'draft',
], 'Save');
// Now make it published.
$this->drupalGet('node/1/edit');
$this->submitForm([
'title[0][value]' => 'Published node',
'moderation_state[0][state]' => 'published',
], 'Save');
// Check the editing form that show the published title.
$this->drupalGet('node/1/edit');
$this->assertSession()
->pageTextContains('Published node');
// Revert the first revision.
$revision_url = 'node/1/revisions/1/revert';
$this->drupalGet($revision_url);
$this->assertSession()->elementExists('css', '.form-submit');
$this->click('.form-submit');
// Check that it reverted.
$this->drupalGet('node/1/edit');
$this->assertSession()
->pageTextContains('First draft node');
// Try to save the node.
$this->drupalGet('node/1/edit');
$this->submitForm(['moderation_state[0][state]' => 'draft'], 'Save');
// Check if the submission passed the EntityChangedConstraintValidator.
$this->assertSession()
->pageTextNotContains('The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved.');
// Check the node has been saved.
$this->assertSession()
->pageTextContains('moderated_bundle First draft node has been updated');
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Tests the view access control handler for moderation state entities.
*
* @group content_moderation
*/
class ModerationStateAccessTest extends BrowserTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'content_moderation',
'node',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$node_type = NodeType::create([
'type' => 'test',
'name' => 'Test',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'test');
$workflow->save();
$this->container->get('module_installer')->install(['content_moderation_test_views']);
}
/**
* Tests the view operation access handler with the view permission.
*/
public function testViewShowsCorrectStates(): void {
$permissions = [
'access content',
'view all revisions',
];
$editor1 = $this->drupalCreateUser($permissions);
$this->drupalLogin($editor1);
$node_1 = Node::create([
'type' => 'test',
'title' => 'Draft node',
'uid' => $editor1->id(),
]);
$node_1->moderation_state->value = 'draft';
$node_1->save();
$node_2 = Node::create([
'type' => 'test',
'title' => 'Published node',
'uid' => $editor1->id(),
]);
$node_2->moderation_state->value = 'published';
$node_2->save();
// Resave the node with a new state.
$node_2->setTitle('Archived node');
$node_2->moderation_state->value = '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->hasContent('Draft'));
$this->assertTrue($page->hasContent('Archived'));
$this->assertFalse($page->hasContent('Published'));
// Now log in as an admin and test the same thing.
$permissions = [
'access content',
'view all revisions',
];
$admin1 = $this->drupalCreateUser($permissions);
$this->drupalLogin($admin1);
$this->drupalGet('/latest');
$page = $this->getSession()->getPage();
$this->assertEquals(200, $this->getSession()->getStatusCode());
$this->assertTrue($page->hasContent('Draft'));
$this->assertTrue($page->hasContent('Archived'));
$this->assertFalse($page->hasContent('Published'));
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
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}
*
* @todo Remove and fix test to not rely on super user.
* @see https://www.drupal.org/project/drupal/issues/3437620
*/
protected bool $usesSuperUserAccessPolicy = TRUE;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
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 content 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(): void {
$this->drupalLogin($this->rootUser);
// Enable moderation for content blocks.
$edit['bundles[basic]'] = TRUE;
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/type/block_content');
$this->submitForm($edit, 'Save');
// Create a content block at block/add and save it as draft.
$body = 'Body of moderated block';
$edit = [
'info[0][value]' => 'Moderated block',
'moderation_state[0][state]' => 'draft',
'body[0][value]' => $body,
];
$this->drupalGet('block/add');
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('basic Moderated block has been created.');
// Place the block in the Sidebar First region.
$instance = [
'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->drupalGet($url);
$this->submitForm($instance, '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->assertSession()->pageTextContains($body);
// Update the block.
$updated_body = 'This is the new body value';
$edit = [
'body[0][value]' => $updated_body,
'moderation_state[0][state]' => 'draft',
];
$this->drupalGet('admin/content/block/' . $block->id());
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('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->assertSession()->pageTextContains($updated_body);
// Publish the block so we can create a pending revision.
$this->drupalGet('admin/content/block/' . $block->id());
$this->submitForm(['moderation_state[0][state]' => 'published'], 'Save');
// Create a pending revision.
$pending_revision_body = 'This is the pending revision body value';
$edit = [
'body[0][value]' => $pending_revision_body,
'moderation_state[0][state]' => 'draft',
];
$this->drupalGet('admin/content/block/' . $block->id());
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains('basic Moderated block has been updated.');
// Navigate to home page and check that the pending revision doesn't show,
// since it should not be set as the default revision.
$this->drupalGet('');
$this->assertSession()->pageTextContains($updated_body);
// Open the latest tab and publish the new draft.
$edit = [
'new_state' => 'published',
];
$this->drupalGet('admin/content/block/' . $block->id() . '/latest');
$this->submitForm($edit, 'Apply');
$this->assertSession()->pageTextContains('The moderation state has been updated.');
// Navigate to home page and check that the pending revision is now the
// default revision and therefore visible.
$this->drupalGet('');
$this->assertSession()->pageTextContains($pending_revision_body);
// Check that revision is checked by default when content moderation is
// enabled.
$this->drupalGet('/admin/content/block/' . $block->id());
$this->assertSession()->checkboxChecked('revision');
$this->assertSession()->pageTextContains('Revisions must be required when moderation is enabled.');
$this->assertSession()->fieldDisabled('revision');
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Core\Url;
use Drupal\node\Entity\Node;
/**
* Tests general content moderation workflow for nodes.
*
* @group content_moderation
* @group #slow
*/
class ModerationStateNodeTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE);
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
}
/**
* Tests creating and deleting content.
*/
public function testCreatingContent(): void {
$this->drupalGet('node/add/moderated_content');
$this->submitForm([
'title[0][value]' => 'moderated content',
'moderation_state[0][state]' => 'draft',
], 'Save');
$node = $this->getNodeByTitle('moderated content');
if (!$node) {
$this->fail('Test node was not saved correctly.');
}
$this->assertEquals('draft', $node->moderation_state->value);
$path = 'node/' . $node->id() . '/edit';
// Set up published revision.
$this->drupalGet($path);
$this->submitForm(['moderation_state[0][state]' => 'published'], 'Save');
\Drupal::entityTypeManager()->getStorage('node')->resetCache([$node->id()]);
/** @var \Drupal\node\NodeInterface $node */
$node = \Drupal::entityTypeManager()->getStorage('node')->load($node->id());
$this->assertTrue($node->isPublished());
$this->assertEquals('published', $node->moderation_state->value);
// Verify that the state field is not shown.
$this->assertSession()->pageTextNotContains('Published');
// Delete the node.
$this->drupalGet('node/' . $node->id() . '/delete');
$this->submitForm([], 'Delete');
$this->assertSession()->pageTextContains('The Moderated content moderated content has been deleted.');
// Disable content moderation.
$edit['bundles[moderated_content]'] = FALSE;
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/type/node');
$this->submitForm($edit, 'Save');
// Ensure the parent environment is up-to-date.
// @see content_moderation_workflow_insert()
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
// Create a new node.
$this->drupalGet('node/add/moderated_content');
$this->submitForm(['title[0][value]' => 'non-moderated content'], 'Save');
$node = $this->getNodeByTitle('non-moderated content');
if (!$node) {
$this->fail('Non-moderated test node was not saved correctly.');
}
$this->assertFalse($node->hasField('moderation_state'));
}
/**
* Tests edit form destinations.
*/
public function testFormSaveDestination(): void {
// Create new moderated content in draft.
$this->drupalGet('node/add/moderated_content');
$this->submitForm([
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save');
$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->assertSession()->addressEquals(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertSession()->pageTextContains('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->drupalGet($edit_path);
$this->submitForm([
'body[0][value]' => 'Second version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save');
$this->assertSession()->addressEquals(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertSession()->pageTextContains('Second version of the content.');
// Make a new published revision; after saving, we should be at the
// canonical URL.
$this->drupalGet($edit_path);
$this->submitForm([
'body[0][value]' => 'Third version of the content.',
'moderation_state[0][state]' => 'published',
], 'Save');
$this->assertSession()->addressEquals(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertSession()->pageTextContains('Third version of the content.');
// Make a new pending revision; after saving, we should be on the "Latest
// version" tab.
$this->drupalGet($edit_path);
$this->submitForm([
'body[0][value]' => 'Fourth version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save');
$this->assertSession()->addressEquals(Url::fromRoute('entity.node.latest_version', ['node' => $node->id()]));
$this->assertSession()->pageTextContains('Fourth version of the content.');
}
/**
* Tests pagers aren't broken by content_moderation.
*/
public function testPagers(): void {
// 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 = $element[0]->getAttribute('href');
$query = [];
parse_str(parse_url($url, PHP_URL_QUERY), $query);
$this->assertEquals(0, $query['page']);
}
/**
* Tests the workflow when a user has no Content Moderation permissions.
*/
public function testNoContentModerationPermissions(): void {
$session_assert = $this->assertSession();
// Create a user with quite advanced node permissions but no content
// moderation permissions.
$limited_user = $this->createUser([
'administer nodes',
'bypass node access',
]);
$this->drupalLogin($limited_user);
// Check the user can see the content entity form, but can't see the
// moderation state select or save the entity form.
$this->drupalGet('node/add/moderated_content');
$session_assert->statusCodeEquals(200);
$session_assert->fieldNotExists('moderation_state[0][state]');
$this->submitForm([
'title[0][value]' => 'moderated content',
], 'Save');
$session_assert->pageTextContains('You do not have access to transition from Draft to Draft');
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
/**
* Tests moderation state node type integration.
*
* @group content_moderation
* @group #slow
*/
class ModerationStateNodeTypeTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A node type without moderation state disabled.
*
* @covers \Drupal\content_moderation\EntityTypeInfo::formAlter
* @covers \Drupal\content_moderation\Entity\Handler\NodeModerationHandler::enforceRevisionsBundleFormAlter
*/
public function testNotModerated(): void {
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Not moderated', 'not_moderated');
$this->assertSession()->pageTextContains('The content type Not moderated has been added.');
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated');
$this->drupalGet('node/add/not_moderated');
$this->assertSession()->pageTextContains('Save');
$this->submitForm([
'title[0][value]' => 'Test',
], 'Save');
$this->assertSession()->pageTextContains('Not moderated Test has been created.');
}
/**
* Tests enabling moderation on an existing node-type, with content.
*
* @covers \Drupal\content_moderation\EntityTypeInfo::formAlter
* @covers \Drupal\content_moderation\Entity\Handler\NodeModerationHandler::enforceRevisionsBundleFormAlter
*/
public function testEnablingOnExistingContent(): void {
$editor_permissions = [
'administer workflows',
'access administration pages',
'administer content types',
'administer nodes',
'view latest version',
'view any unpublished content',
'access content overview',
'use editorial transition create_new_draft',
];
$publish_permissions = array_merge($editor_permissions, ['use editorial transition publish']);
$editor = $this->drupalCreateUser($editor_permissions);
$editor_with_publish = $this->drupalCreateUser($publish_permissions);
// Create a node type that is not moderated.
$this->drupalLogin($editor);
$this->createContentTypeFromUi('Not moderated', 'not_moderated');
$this->grantUserPermissionToCreateContentOfType($editor, 'not_moderated');
$this->grantUserPermissionToCreateContentOfType($editor_with_publish, 'not_moderated');
// Create content.
$this->drupalGet('node/add/not_moderated');
$this->submitForm([
'title[0][value]' => 'Test',
], 'Save');
$this->assertSession()->pageTextContains('Not moderated Test has been created.');
// Check that the 'Create new revision' is not disabled.
$this->drupalGet('/admin/structure/types/manage/not_moderated');
$this->assertNull($this->assertSession()->fieldExists('options[revision]')->getAttribute('disabled'));
// Now enable moderation state.
$this->enableModerationThroughUi('not_moderated');
// Check that the 'Create new revision' checkbox is checked and disabled.
$this->drupalGet('/admin/structure/types/manage/not_moderated');
$this->assertSession()->checkboxChecked('options[revision]');
$this->assertSession()->fieldDisabled('options[revision]');
// 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->assertSession()->statusCodeEquals(200);
$this->assertSession()->linkByHrefExists('node/' . $node->id() . '/edit');
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'published');
$this->drupalLogin($editor_with_publish);
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
}
/**
* @covers \Drupal\content_moderation\Entity\Handler\NodeModerationHandler::enforceRevisionsBundleFormAlter
*/
public function testEnforceRevisionsEntityFormAlter(): void {
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated', 'moderated');
// Ensure checkboxes in the 'workflow' section can be altered, even when
// 'revision' is enforced and disabled.
$this->drupalGet('admin/structure/types/manage/moderated');
$this->submitForm(['options[promote]' => TRUE], 'Save');
$this->drupalGet('admin/structure/types/manage/moderated');
$this->assertSession()->checkboxChecked('options[promote]');
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Core\Language\LanguageInterface;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
/**
* Tests the taxonomy term moderation handler.
*
* @group content_moderation
*/
class ModerationStateTaxonomyTermTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create a "Tags" vocabulary.
$bundle = Vocabulary::create([
'vid' => 'tags',
'name' => 'Tags',
'new_revision' => FALSE,
])->save();
}
/**
* Tests the taxonomy term moderation handler alters the forms as intended.
*
* @covers \Drupal\content_moderation\Entity\Handler\TaxonomyTermModerationHandler::enforceRevisionsEntityFormAlter
* @covers \Drupal\content_moderation\Entity\Handler\TaxonomyTermModerationHandler::enforceRevisionsBundleFormAlter
*/
public function testEnforceRevisionsEntityFormAlter(): void {
$this->drupalLogin($this->adminUser);
// Enable moderation for the tags vocabulary.
$edit['bundles[tags]'] = TRUE;
$this->drupalGet('/admin/config/workflow/workflows/manage/editorial/type/taxonomy_term');
$this->submitForm($edit, 'Save');
// Check that revision is checked by default when content moderation is
// enabled for the vocabulary.
$this->drupalGet('/admin/structure/taxonomy/manage/tags');
$this->assertSession()->checkboxChecked('revision');
$this->assertSession()->pageTextContains('Revisions must be required when moderation is enabled.');
$this->assertSession()->fieldDisabled('revision');
// Create a taxonomy term and save it as draft.
$term = Term::create([
'name' => 'Test tag',
'vid' => 'tags',
'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
]);
$term->save();
// Check that revision is checked by default when editing a term and
// content moderation is enabled for the term's vocabulary.
$this->drupalGet($term->toUrl('edit-form'));
$this->assertSession()->checkboxChecked('revision');
$this->assertSession()->pageTextContains('Revisions must be required when moderation is enabled.');
$this->assertSession()->fieldDisabled('revision');
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\user\Entity\Role;
/**
* Defines a base class for moderation state tests.
*/
abstract class ModerationStateTestBase extends BrowserTestBase {
use ContentModerationTestTrait;
/**
* Profile to use.
*
* @var string
*/
protected $profile = 'testing';
/**
* Admin user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* Permissions to grant admin user.
*
* @var array
*/
protected $permissions = [
'administer workflows',
'access administration pages',
'administer content types',
'administer nodes',
'view latest version',
'view any unpublished content',
'access content overview',
'use editorial transition create_new_draft',
'use editorial transition publish',
'use editorial transition archive',
'use editorial transition archived_draft',
'use editorial transition archived_published',
'administer taxonomy',
];
/**
* The editorial workflow entity.
*
* @var \Drupal\workflows\Entity\Workflow
*/
protected $workflow;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'content_moderation',
'block',
'block_content',
'node',
'entity_test',
'taxonomy',
];
/**
* Sets the test up.
*/
protected function setUp(): void {
parent::setUp();
$this->workflow = $this->createEditorialWorkflow();
$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']);
}
/**
* Gets the permission machine name for a transition.
*
* @param string $workflow_id
* The workflow ID.
* @param string $transition_id
* The transition ID.
*
* @return string
* The permission machine name for a transition.
*/
protected function getWorkflowTransitionPermission($workflow_id, $transition_id) {
return 'use ' . $workflow_id . ' transition ' . $transition_id;
}
/**
* 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 $workflow_id
* The workflow to attach to the bundle.
*/
protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, $workflow_id = 'editorial') {
$this->drupalGet('admin/structure/types');
$this->clickLink('Add content type');
$edit = [
'name' => $content_type_name,
'type' => $content_type_id,
];
$this->submitForm($edit, 'Save');
// Check the content type has been set to create new revisions.
$this->assertTrue(NodeType::load($content_type_id)->shouldCreateNewRevision());
if ($moderated) {
$this->enableModerationThroughUi($content_type_id, $workflow_id);
}
}
/**
* Enable moderation for a specified content type, using the UI.
*
* @param string $content_type_id
* Machine name.
* @param string $workflow_id
* The workflow to attach to the bundle.
*/
public function enableModerationThroughUi($content_type_id, $workflow_id = 'editorial') {
$this->drupalGet('/admin/config/workflow/workflows');
$this->assertSession()->linkByHrefExists('admin/config/workflow/workflows/manage/' . $workflow_id);
$edit['bundles[' . $content_type_id . ']'] = TRUE;
$this->drupalGet('admin/config/workflow/workflows/manage/' . $workflow_id . '/type/node');
$this->submitForm($edit, 'Save');
// Ensure the parent environment is up-to-date.
// @see content_moderation_workflow_insert()
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
/** @var \Drupal\Core\Routing\RouteBuilderInterface $router_builder */
$router_builder = $this->container->get('router.builder');
$router_builder->rebuildIfNeeded();
}
/**
* 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();
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\Entity\NodeType;
/**
* Tests permission access control around nodes.
*
* @group content_moderation
*/
class NodeAccessTest extends ModerationStateTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'content_moderation',
'block',
'block_content',
'node',
'node_access_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Permissions to grant admin user.
*
* @var array
*/
protected $permissions = [
'administer workflows',
'access administration pages',
'administer content types',
'administer nodes',
'view latest version',
'view any unpublished content',
'access content overview',
'use editorial transition create_new_draft',
'use editorial transition publish',
'bypass node access',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated content', 'moderated_content', FALSE);
// Ensure the statically cached entity bundle info is aware of the content
// type that was just created in the UI.
$this->container->get('entity_type.bundle.info')->clearCachedBundles();
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
// Add the private field to the node type.
node_access_test_add_field(NodeType::load('moderated_content'));
// Rebuild permissions because hook_node_grants() is implemented by the
// node_access_test_empty module.
node_access_rebuild();
}
/**
* Verifies that a non-admin user can still access the appropriate pages.
*/
public function testPageAccess(): void {
// Initially disable access grant records in
// node_access_test_node_access_records().
\Drupal::state()->set('node_access_test.private', TRUE);
$this->drupalLogin($this->adminUser);
// Access the node form before moderation is enabled, the publication state
// should now be visible.
$this->drupalGet('node/add/moderated_content');
$this->assertSession()->fieldExists('Published');
// Now enable the workflow.
$this->enableModerationThroughUi('moderated_content', 'editorial');
// Access that the status field is no longer visible.
$this->drupalGet('node/add/moderated_content');
$this->assertSession()->fieldNotExists('Published');
// Create a node to test with.
$this->submitForm([
'title[0][value]' => 'moderated content',
'moderation_state[0][state]' => 'draft',
], 'Save');
$node = $this->getNodeByTitle('moderated content');
if (!$node) {
$this->fail('Test node was not saved correctly.');
}
$view_path = 'node/' . $node->id();
$edit_path = 'node/' . $node->id() . '/edit';
$latest_path = 'node/' . $node->id() . '/latest';
// Now make a new user and verify that the new user's access is correct.
$user = $this->createUser([
'use editorial transition create_new_draft',
'view latest version',
'view any unpublished content',
]);
$this->drupalLogin($user);
$this->drupalGet($edit_path);
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($latest_path);
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($view_path);
$this->assertSession()->statusCodeEquals(200);
// Publish the node.
$this->drupalLogin($this->adminUser);
$this->drupalGet($edit_path);
$this->submitForm(['moderation_state[0][state]' => 'published'], 'Save');
// Ensure access works correctly for anonymous users.
$this->drupalLogout();
$this->drupalGet($edit_path);
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($latest_path);
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($view_path);
$this->assertSession()->statusCodeEquals(200);
// Create a pending revision for the 'Latest revision' tab.
$this->drupalLogin($this->adminUser);
$this->drupalGet($edit_path);
$this->submitForm([
'title[0][value]' => 'moderated content revised',
'moderation_state[0][state]' => 'draft',
], 'Save');
$this->drupalLogin($user);
$this->drupalGet($edit_path);
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($latest_path);
$this->assertSession()->statusCodeEquals(200);
$this->drupalGet($view_path);
$this->assertSession()->statusCodeEquals(200);
// Now make another user, who should not be able to see pending revisions.
$user = $this->createUser([
'use editorial transition create_new_draft',
]);
$this->drupalLogin($user);
$this->drupalGet($edit_path);
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($latest_path);
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($view_path);
$this->assertSession()->statusCodeEquals(200);
// Now create a private node that the user is not granted access to by the
// node grants, but is granted access via hook_ENTITY_TYPE_access().
// @see node_access_test_node_access
$node = $this->createNode([
'type' => 'moderated_content',
'private' => TRUE,
'uid' => $this->adminUser->id(),
]);
$user = $this->createUser([
'use editorial transition publish',
]);
$this->drupalLogin($user);
// Grant access to the node via node_access_test_node_access().
\Drupal::state()->set('node_access_test.allow_uid', $user->id());
$this->drupalGet($node->toUrl());
$this->assertSession()->statusCodeEquals(200);
// Verify the moderation form is in place by publishing the node.
$this->submitForm([], 'Apply');
$node = \Drupal::entityTypeManager()->getStorage('node')->loadUnchanged($node->id());
$this->assertEquals('published', $node->moderation_state->value);
}
}

View File

@@ -0,0 +1,381 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Entity\View;
use Drupal\views\ViewEntityInterface;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the views 'moderation_state_filter' filter plugin.
*
* @coversDefaultClass \Drupal\content_moderation\Plugin\views\filter\ModerationStateFilter
*
* @group content_moderation
* @group #slow
*/
class ViewsModerationStateFilterTest extends ViewTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'content_moderation',
'workflows',
'workflow_type_test',
'entity_test',
'language',
'content_translation',
'views_ui',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = []): void {
parent::setUp(FALSE, $modules);
$this->drupalCreateContentType([
'type' => 'example_a',
]);
$this->drupalCreateContentType([
'type' => 'example_b',
]);
$this->drupalCreateContentType([
'type' => 'example_c',
]);
$this->createEditorialWorkflow();
$new_workflow = Workflow::create([
'type' => 'content_moderation',
'id' => 'new_workflow',
'label' => 'New workflow',
]);
$new_workflow->getTypePlugin()->addState('bar', 'Bar');
$new_workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example_c');
$new_workflow->save();
$this->drupalLogin($this->drupalCreateUser([
'administer workflows',
'administer views',
]));
$this->container->get('module_installer')->install(['content_moderation_test_views']);
$new_workflow->getTypePlugin()->removeEntityTypeAndBundle('node', 'example_c');
$new_workflow->save();
}
/**
* Tests the dependency handling of the moderation state filter.
*
* @covers ::calculateDependencies
* @covers ::onDependencyRemoval
*/
public function testModerationStateFilterDependencyHandling(): void {
// First, check that the view doesn't have any config dependency when there
// are no states configured in the filter.
$view_id = 'test_content_moderation_state_filter_base_table';
$view = View::load($view_id);
$this->assertWorkflowDependencies([], $view);
$this->assertTrue($view->status());
// Configure the Editorial workflow for a node bundle, set the filter value
// to use one of its states and check that the workflow is now a dependency
// of the view.
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/type/node');
$this->submitForm(['bundles[example_a]' => TRUE], 'Save');
$edit['options[value][]'] = ['editorial-published'];
$this->drupalGet("admin/structure/views/nojs/handler/{$view_id}/default/filter/moderation_state");
$this->submitForm($edit, 'Apply');
$this->drupalGet("admin/structure/views/view/{$view_id}");
$this->submitForm([], 'Save');
$view = $this->loadViewUnchanged($view_id);
$this->assertWorkflowDependencies(['editorial'], $view);
$this->assertTrue($view->status());
// Create another workflow and repeat the checks above.
$this->drupalGet('admin/config/workflow/workflows/add');
$this->submitForm([
'label' => 'Translation',
'id' => 'translation',
'workflow_type' => 'content_moderation',
], 'Save');
$this->drupalGet('admin/config/workflow/workflows/manage/translation/add_state');
$this->submitForm([
'label' => 'Needs Review',
'id' => 'needs_review',
], 'Save');
$this->drupalGet('admin/config/workflow/workflows/manage/translation/type/node');
$this->submitForm(['bundles[example_b]' => TRUE], 'Save');
$edit['options[value][]'] = ['editorial-published', 'translation-needs_review'];
$this->drupalGet("admin/structure/views/nojs/handler/{$view_id}/default/filter/moderation_state");
$this->submitForm($edit, 'Apply');
$this->drupalGet("admin/structure/views/view/{$view_id}");
$this->submitForm([], 'Save');
$view = $this->loadViewUnchanged($view_id);
$this->assertWorkflowDependencies(['editorial', 'translation'], $view);
$this->assertTrue(isset($view->getDisplay('default')['display_options']['filters']['moderation_state']));
$this->assertTrue($view->status());
// Remove the 'Translation' workflow.
$this->drupalGet('admin/config/workflow/workflows/manage/translation/delete');
$this->submitForm([], 'Delete');
// Check that the view has been disabled, the filter has been deleted, the
// view can be saved and there are no more config dependencies.
$view = $this->loadViewUnchanged($view_id);
$this->assertFalse($view->status());
$this->assertFalse(isset($view->getDisplay('default')['display_options']['filters']['moderation_state']));
$this->drupalGet("admin/structure/views/view/{$view_id}");
$this->submitForm([], 'Save');
$this->assertWorkflowDependencies([], $view);
}
/**
* Load a view from the database after it has been modified in a sub-request.
*
* @param string $view_id
* The view ID.
*
* @return \Drupal\views\ViewEntityInterface
* A loaded view, bypassing static caches.
*/
public function loadViewUnchanged($view_id) {
$this->container->get('cache.config')->deleteAll();
$this->container->get('config.factory')->reset();
return $this->container->get('entity_type.manager')->getStorage('view')->loadUnchanged($view_id);
}
/**
* Tests the moderation state filter when the configured workflow is changed.
*
* @dataProvider providerTestWorkflowChanges
*/
public function testWorkflowChanges($view_id): void {
// First, apply the Editorial workflow to both of our content types.
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/type/node');
$this->submitForm([
'bundles[example_a]' => TRUE,
'bundles[example_b]' => TRUE,
], 'Save');
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
// Update the view and make the default filter not exposed anymore,
// otherwise all results will be shown when there are no more moderated
// bundles left.
$this->drupalGet("admin/structure/views/nojs/handler/{$view_id}/default/filter/moderation_state");
$this->submitForm([], 'Hide filter');
$this->drupalGet("admin/structure/views/view/{$view_id}");
$this->submitForm([], 'Save');
// Add a few nodes in various moderation states.
$this->createNode(['type' => 'example_a', 'moderation_state' => 'published']);
$this->createNode(['type' => 'example_b', 'moderation_state' => 'published']);
$archived_node_a = $this->createNode(['type' => 'example_a', 'moderation_state' => 'archived']);
$archived_node_b = $this->createNode(['type' => 'example_b', 'moderation_state' => 'archived']);
// Configure the view to only show nodes in the 'archived' moderation state.
$edit['options[value][]'] = ['editorial-archived'];
$this->drupalGet("admin/structure/views/nojs/handler/{$view_id}/default/filter/moderation_state");
$this->submitForm($edit, 'Apply');
$this->drupalGet("admin/structure/views/view/{$view_id}");
$this->submitForm([], 'Save');
// Check that only the archived nodes from both bundles are displayed by the
// view.
$view = $this->loadViewUnchanged($view_id);
$this->executeAndAssertIdenticalResultset($view, [['nid' => $archived_node_a->id()], ['nid' => $archived_node_b->id()]], ['nid' => 'nid']);
// Remove the Editorial workflow from one of the bundles.
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/type/node');
$this->submitForm([
'bundles[example_a]' => TRUE,
'bundles[example_b]' => FALSE,
], 'Save');
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
$view = $this->loadViewUnchanged($view_id);
$this->executeAndAssertIdenticalResultset($view, [['nid' => $archived_node_a->id()]], ['nid' => 'nid']);
// Check that the view can still be edited and saved without any
// intervention.
$this->drupalGet("admin/structure/views/view/{$view_id}");
$this->submitForm([], 'Save');
// Remove the Editorial workflow from both bundles.
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/type/node');
$this->submitForm([
'bundles[example_a]' => FALSE,
'bundles[example_b]' => FALSE,
], 'Save');
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
// Check that the view doesn't return any result.
$view = $this->loadViewUnchanged($view_id);
$this->executeAndAssertIdenticalResultset($view, [], []);
// Check that the view contains a broken filter, since the moderation_state
// field was removed from the entity type.
$this->drupalGet("admin/structure/views/view/{$view_id}");
$this->submitForm([], 'Save');
$this->assertSession()->pageTextContains("Broken/missing handler");
}
/**
* Execute a view and assert the expected results.
*
* @param \Drupal\views\ViewEntityInterface $view_entity
* A view configuration entity.
* @param array $expected
* An expected result set.
* @param array $column_map
* An associative array mapping the columns of the result set from the view
* (as keys) and the expected result set (as values).
*/
protected function executeAndAssertIdenticalResultset(ViewEntityInterface $view_entity, $expected, $column_map) {
$executable = $this->container->get('views.executable')->get($view_entity);
$this->executeView($executable);
$this->assertIdenticalResultset($executable, $expected, $column_map);
}
/**
* Data provider for testWorkflowChanges.
*
* @return string[]
* An array of view IDs.
*/
public static function providerTestWorkflowChanges() {
return [
'view on base table, filter on base table' => [
'test_content_moderation_state_filter_base_table',
],
'view on base table, filter on revision table' => [
'test_content_moderation_state_filter_base_table_filter_on_revision',
],
];
}
/**
* Tests the content moderation state filter caching is correct.
*/
public function testFilterRenderCache(): void {
// Initially all states of the workflow are displayed.
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/type/node');
$this->submitForm(['bundles[example_a]' => TRUE], 'Save');
$this->assertFilterStates(['All', 'editorial-draft', 'editorial-published', 'editorial-archived']);
// Adding a new state to the editorial workflow will display that state in
// the list of filters.
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/add_state');
$this->submitForm([
'label' => 'Foo',
'id' => 'foo',
], 'Save');
$this->assertFilterStates(['All', 'editorial-draft', 'editorial-published', 'editorial-archived', 'editorial-foo']);
// Adding a second workflow to nodes will also show new states.
$this->drupalGet('admin/config/workflow/workflows/manage/new_workflow/type/node');
$this->submitForm(['bundles[example_b]' => TRUE], 'Save');
$this->assertFilterStates(['All', 'editorial-draft', 'editorial-published', 'editorial-archived', 'editorial-foo', 'new_workflow-draft', 'new_workflow-published', 'new_workflow-bar']);
// Add a few more states and change the exposed filter to allow multiple
// selections so we can check that the size of the select element does not
// exceed 8 options.
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/add_state');
$this->submitForm([
'label' => 'Foo 2',
'id' => 'foo2',
], 'Save');
$this->drupalGet('admin/config/workflow/workflows/manage/editorial/add_state');
$this->submitForm([
'label' => 'Foo 3',
'id' => 'foo3',
], 'Save');
$view_id = 'test_content_moderation_state_filter_base_table';
$edit['options[expose][multiple]'] = TRUE;
$this->drupalGet("admin/structure/views/nojs/handler/{$view_id}/default/filter/moderation_state");
$this->submitForm($edit, 'Apply');
$this->drupalGet("admin/structure/views/view/{$view_id}");
$this->submitForm([], 'Save');
$this->assertFilterStates(['editorial-draft', 'editorial-published', 'editorial-archived', 'editorial-foo', 'editorial-foo2', 'editorial-foo3', 'new_workflow-draft', 'new_workflow-published', 'new_workflow-bar'], TRUE);
}
/**
* Assert the states which appear in the filter.
*
* @param array $states
* The states which should appear in the filter.
* @param bool $check_size
* (optional) Whether to check that size of the select element is not
* greater than 8. Defaults to FALSE.
*
* @internal
*/
protected function assertFilterStates(array $states, bool $check_size = FALSE): void {
$this->drupalGet('/filter-test-path');
$assert_session = $this->assertSession();
// Check that the select contains the correct number of options.
$assert_session->elementsCount('css', '#edit-default-revision-state option', count($states));
// Check that the size of the select element does not exceed 8 options.
if ($check_size) {
$this->assertGreaterThan(8, count($states));
$assert_session->elementAttributeContains('css', '#edit-default-revision-state', 'size', '8');
}
// Check that an option exists for each of the expected states.
foreach ($states as $state) {
$assert_session->optionExists('Default Revision State', $state);
}
}
/**
* Asserts the views dependencies on workflow config entities.
*
* @param string[] $workflow_ids
* An array of workflow IDs to check.
* @param \Drupal\views\ViewEntityInterface $view
* A view configuration object.
*
* @internal
*/
protected function assertWorkflowDependencies(array $workflow_ids, ViewEntityInterface $view): void {
$dependencies = $view->getDependencies();
$expected = [];
foreach (Workflow::loadMultiple($workflow_ids) as $workflow) {
$expected[] = $workflow->getConfigDependencyName();
}
if ($expected) {
$this->assertSame($expected, $dependencies['config']);
}
else {
$this->assertTrue(!isset($dependencies['config']));
}
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\workspaces\Functional\WorkspaceTestUtilities;
use Drupal\workspaces\Entity\Workspace;
/**
* Tests Workspaces together with Content Moderation.
*
* @group content_moderation
* @group workspaces
*/
class WorkspaceContentModerationIntegrationTest extends ModerationStateTestBase {
use WorkspaceTestUtilities;
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'workspaces'];
/**
* {@inheritdoc}
*
* @todo Remove and fix test to not rely on super user.
* @see https://www.drupal.org/project/drupal/issues/3437620
*/
protected bool $usesSuperUserAccessPolicy = TRUE;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->rootUser);
// Enable moderation on Article node type.
$this->createContentTypeFromUi('Article', 'article', TRUE);
$this->setupWorkspaceSwitcherBlock();
}
/**
* Tests moderating nodes in a workspace.
*/
public function testModerationInWorkspace(): void {
$stage = Workspace::load('stage');
$this->switchToWorkspace($stage);
// Create two nodes, a published and a draft one.
$this->drupalGet('node/add/article');
$this->submitForm([
'title[0][value]' => 'First article - published',
'moderation_state[0][state]' => 'published',
], 'Save');
$this->drupalGet('node/add/article');
$this->submitForm([
'title[0][value]' => 'Second article - draft',
'moderation_state[0][state]' => 'draft',
], 'Save');
$first_article = $this->drupalGetNodeByTitle('First article - published', TRUE);
$this->assertEquals('published', $first_article->moderation_state->value);
$second_article = $this->drupalGetNodeByTitle('Second article - draft', TRUE);
$this->assertEquals('draft', $second_article->moderation_state->value);
// Check that neither of them are visible in Live.
$this->switchToLive();
$this->drupalGet('<front>');
$this->assertSession()->pageTextNotContains('First article');
$this->assertSession()->pageTextNotContains('Second article');
// Switch back to Stage.
$this->switchToWorkspace($stage);
// Take the first node through various moderation states.
$this->drupalGet('/node/1/edit');
$this->assertEquals('Current state Published', $this->cssSelect('#edit-moderation-state-0-current')[0]->getText());
$this->submitForm([
'title[0][value]' => 'First article - draft',
'moderation_state[0][state]' => 'draft',
], 'Save');
$this->drupalGet('/node/1');
$this->assertSession()->pageTextContains('First article - draft');
$this->drupalGet('/node/1/edit');
$this->assertEquals('Current state Draft', $this->cssSelect('#edit-moderation-state-0-current')[0]->getText());
$this->submitForm([
'title[0][value]' => 'First article - published',
'moderation_state[0][state]' => 'published',
], 'Save');
$this->drupalGet('/node/1/edit');
$this->submitForm([
'title[0][value]' => 'First article - archived',
'moderation_state[0][state]' => 'archived',
], 'Save');
$this->drupalGet('/node/1');
$this->assertSession()->pageTextContains('First article - archived');
// Get the second node to a default revision state and publish the
// workspace.
$this->drupalGet('/node/2/edit');
$this->submitForm([
'title[0][value]' => 'Second article - published',
'moderation_state[0][state]' => 'published',
], 'Save');
$stage->publish();
// The admin user can see unpublished nodes.
$this->drupalGet('/node/1');
$this->assertSession()->pageTextContains('First article - archived');
$this->drupalGet('/node/2');
$this->assertSession()->pageTextContains('Second article - published');
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Kernel\ConfigAction;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Recipe\Recipe;
use Drupal\Core\Recipe\RecipeRunner;
use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
use Drupal\workflows\Entity\Workflow;
/**
* @covers \Drupal\content_moderation\Plugin\ConfigAction\AddModeration
* @covers \Drupal\content_moderation\Plugin\ConfigAction\AddModerationDeriver
* @group content_moderation
* @group Recipe
*/
class AddModerationConfigActionTest extends KernelTestBase {
use ContentTypeCreationTrait;
use RecipeTestTrait {
createRecipe as traitCreateRecipe;
}
use TaxonomyTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'field',
'node',
'system',
'taxonomy',
'text',
'user',
];
public function testAddEntityTypeAndBundle(): void {
$this->installConfig('node');
$this->createContentType(['type' => 'a']);
$this->createContentType(['type' => 'b']);
$this->createContentType(['type' => 'c']);
$this->createVocabulary(['vid' => 'tags']);
$recipe = $this->createRecipe('workflows.workflow.editorial');
RecipeRunner::processRecipe($recipe);
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface $plugin */
$plugin = Workflow::load('editorial')?->getTypePlugin();
$this->assertSame(['a', 'b'], $plugin->getBundlesForEntityType('node'));
$this->assertSame(['tags'], $plugin->getBundlesForEntityType('taxonomy_term'));
}
public function testWorkflowMustBeContentModeration(): void {
$this->enableModules(['workflows', 'workflow_type_test']);
$workflow = Workflow::create([
'id' => 'test',
'label' => 'Test workflow',
'type' => 'workflow_type_test',
]);
$workflow->save();
$recipe = $this->createRecipe($workflow->getConfigDependencyName());
$this->expectException(ConfigActionException::class);
$this->expectExceptionMessage("The add_moderation:addNodeTypes config action only works with Content Moderation workflows.");
RecipeRunner::processRecipe($recipe);
}
public function testActionOnlyTargetsWorkflows(): void {
$recipe = $this->createRecipe('user.role.anonymous');
$this->expectException(PluginNotFoundException::class);
$this->expectExceptionMessage('The "addNodeTypes" plugin does not exist.');
RecipeRunner::processRecipe($recipe);
}
public function testDeriverAdminLabel(): void {
$this->enableModules(['workflows', 'content_moderation']);
/** @var array<string, array{admin_label: \Stringable}> $definitions */
$definitions = $this->container->get('plugin.manager.config_action')
->getDefinitions();
$this->assertSame('Add moderation to all content types', (string) $definitions['add_moderation:addNodeTypes']['admin_label']);
$this->assertSame('Add moderation to all vocabularies', (string) $definitions['add_moderation:addTaxonomyVocabularies']['admin_label']);
}
private function createRecipe(string $config_name): Recipe {
$recipe = <<<YAML
name: 'Add entity types and bundles to workflow'
recipes:
- core/recipes/editorial_workflow
config:
actions:
$config_name:
addNodeTypes:
- a
- b
addTaxonomyVocabularies: '*'
YAML;
return $this->traitCreateRecipe($recipe);
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Session\UserSession;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\user\Entity\Role;
/**
* Tests content moderation access.
*
* @group content_moderation
*/
class ContentModerationAccessTest extends KernelTestBase {
use NodeCreationTrait;
use UserCreationTrait;
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'content_moderation',
'filter',
'node',
'system',
'user',
'workflows',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('content_moderation_state');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installConfig(['content_moderation', 'filter']);
$this->installSchema('node', ['node_access']);
// Add a moderated node type.
$node_type = NodeType::create([
'type' => 'page',
'name' => 'Page',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'page');
$workflow->save();
}
/**
* Tests access cacheability.
*/
public function testAccessCacheability(): void {
$node = $this->createNode(['type' => 'page']);
/** @var \Drupal\user\RoleInterface $authenticated */
$authenticated = Role::create([
'id' => 'authenticated',
'label' => 'Authenticated',
]);
$authenticated->grantPermission('access content');
$authenticated->grantPermission('edit any page content');
$authenticated->save();
$account = new UserSession([
'uid' => 2,
'roles' => ['authenticated'],
]);
$result = $node->access('update', $account, TRUE);
$this->assertFalse($result->isAllowed());
$this->assertEqualsCanonicalizing(['user.permissions'], $result->getCacheContexts());
$this->assertEqualsCanonicalizing(['config:workflows.workflow.editorial', 'node:' . $node->id()], $result->getCacheTags());
$this->assertEquals(CacheBackendInterface::CACHE_PERMANENT, $result->getCacheMaxAge());
$authenticated->grantPermission('use editorial transition create_new_draft');
$authenticated->save();
\Drupal::entityTypeManager()->getAccessControlHandler('node')->resetCache();
$result = $node->access('update', $account, TRUE);
$this->assertTrue($result->isAllowed());
$this->assertEqualsCanonicalizing(['user.permissions'], $result->getCacheContexts());
$this->assertEqualsCanonicalizing(['config:workflows.workflow.editorial', 'node:' . $node->id()], $result->getCacheTags());
$this->assertEquals(CacheBackendInterface::CACHE_PERMANENT, $result->getCacheMaxAge());
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\content_moderation\Permissions;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Test to ensure content moderation permissions are generated correctly.
*
* @group content_moderation
*/
class ContentModerationPermissionsTest extends KernelTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = [
'workflows',
'content_moderation',
'workflow_type_test',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('workflow');
}
/**
* Tests permissions generated by content moderation.
*
* @dataProvider permissionsTestCases
*/
public function testPermissions($workflow, $permissions): void {
Workflow::create($workflow)->save();
$this->assertEquals($permissions, (new Permissions())->transitionPermissions());
}
/**
* Test cases for ::testPermissions.
*
* @return array
* Content moderation permissions based test cases.
*/
public static function permissionsTestCases() {
return [
'Simple Content Moderation Workflow' => [
[
'id' => 'simple_workflow',
'label' => 'Simple Workflow',
'type' => 'content_moderation',
'type_settings' => [
'states' => [
'draft' => [
'label' => 'Draft',
'published' => FALSE,
'default_revision' => FALSE,
'weight' => 0,
],
'published' => [
'label' => 'Published',
'published' => TRUE,
'default_revision' => TRUE,
'weight' => 1,
],
'archived' => [
'label' => 'Archived',
'published' => FALSE,
'default_revision' => TRUE,
'weight' => 2,
],
],
'transitions' => [
'create_new_draft' => [
'label' => 'Create New Draft',
'to' => 'draft',
'weight' => 0,
'from' => [
'draft',
'published',
],
],
'publish' => [
'label' => 'Publish',
'to' => 'published',
'weight' => 1,
'from' => [
'draft',
'published',
],
],
'archive' => [
'label' => 'Archive',
'to' => 'archived',
'weight' => 2,
'from' => [
'published',
],
],
],
],
],
[
'use simple_workflow transition publish' => [
'title' => 'Simple Workflow workflow: Use Publish transition.',
'description' => 'Move content from Draft, Published states to Published state.',
'dependencies' => [
'config' => [
'workflows.workflow.simple_workflow',
],
],
],
'use simple_workflow transition create_new_draft' => [
'title' => 'Simple Workflow workflow: Use Create New Draft transition.',
'description' => 'Move content from Draft, Published states to Draft state.',
'dependencies' => [
'config' => [
'workflows.workflow.simple_workflow',
],
],
],
'use simple_workflow transition archive' => [
'title' => 'Simple Workflow workflow: Use Archive transition.',
'description' => 'Move content from Published state to Archived state.',
'dependencies' => [
'config' => [
'workflows.workflow.simple_workflow',
],
],
],
],
],
'Non Content Moderation Workflow' => [
[
'id' => 'morning',
'label' => 'Morning',
'type' => 'workflow_type_test',
'transitions' => [
'drink_coffee' => [
'label' => 'Drink Coffee',
'from' => ['tired'],
'to' => 'awake',
'weight' => 0,
],
],
'states' => [
'awake' => [
'label' => 'Awake',
'weight' => -5,
],
'tired' => [
'label' => 'Tired',
'weight' => -0,
],
],
],
[],
],
];
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\content_moderation\Entity\ContentModerationState;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Tests Content Moderation with entities that get re-saved automatically.
*
* @group content_moderation
*/
class ContentModerationResaveTest extends KernelTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
// Make sure the test module is listed first as module weights do not apply
// for kernel tests.
/* @see \content_moderation_test_resave_install() */
'content_moderation_test_resave',
'content_moderation',
'entity_test',
'user',
'workflows',
];
/**
* The content moderation state entity storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $contentModerationStateStorage;
/**
* The entity storage for the entity type used in the test.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $entityStorage;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$entity_type_id = 'entity_test_rev';
$this->installEntitySchema('content_moderation_state');
$this->installEntitySchema($entity_type_id);
$workflow = $this->createEditorialWorkflow();
$this->addEntityTypeAndBundleToWorkflow($workflow, $entity_type_id, $entity_type_id);
/** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
$entity_type_manager = $this->container->get('entity_type.manager');
$this->contentModerationStateStorage = $entity_type_manager->getStorage('content_moderation_state');
$this->entityStorage = $entity_type_manager->getStorage($entity_type_id);
$this->state = $this->container->get('state');
}
/**
* Tests that Content Moderation works with entities being resaved.
*/
public function testContentModerationResave(): void {
$entity = $this->entityStorage->create();
$this->assertSame('draft', $entity->get('moderation_state')->value);
$this->assertNull(\Drupal::state()->get('content_moderation_test_resave'));
$this->assertNull(ContentModerationState::loadFromModeratedEntity($entity));
$content_moderation_state_query = $this->contentModerationStateStorage
->getQuery()
->accessCheck(FALSE)
->count();
$this->assertSame(0, (int) $content_moderation_state_query->execute());
$content_moderation_state_revision_query = $this->contentModerationStateStorage
->getQuery()
->accessCheck(FALSE)
->allRevisions()
->count();
$this->assertSame(0, (int) $content_moderation_state_revision_query->execute());
// The test module will re-save the entity in its hook_insert()
// implementation creating the content moderation state entity before
// Content Moderation's hook_insert() has run for the initial save
// operation.
$entity->save();
$this->assertSame('draft', $entity->get('moderation_state')->value);
$this->assertTrue(\Drupal::state()->get('content_moderation_test_resave'));
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertInstanceOf(ContentModerationState::class, $content_moderation_state);
$this->assertSame(1, (int) $content_moderation_state_query->execute());
$this->assertSame(1, (int) $content_moderation_state_revision_query->execute());
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\content_moderation\Entity\ContentModerationState;
use Drupal\KernelTests\KernelTestBase;
/**
* @coversDefaultClass \Drupal\content_moderation\ContentModerationStateAccessControlHandler
* @group content_moderation
*/
class ContentModerationStateAccessControlHandlerTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'content_moderation',
'workflows',
'user',
];
/**
* The content_moderation_state access control handler.
*
* @var \Drupal\Core\Entity\EntityAccessControlHandlerInterface
*/
protected $accessControlHandler;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('content_moderation_state');
$this->installEntitySchema('user');
$this->accessControlHandler = $this->container->get('entity_type.manager')->getAccessControlHandler('content_moderation_state');
}
/**
* @covers ::checkAccess
* @covers ::checkCreateAccess
*/
public function testHandler(): void {
$entity = ContentModerationState::create([]);
$this->assertFalse($this->accessControlHandler->access($entity, 'view'));
$this->assertFalse($this->accessControlHandler->access($entity, 'update'));
$this->assertFalse($this->accessControlHandler->access($entity, 'delete'));
$this->assertFalse($this->accessControlHandler->createAccess());
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* @group content_moderation
*/
class ContentModerationStateResourceTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['serialization', 'rest', 'content_moderation'];
/**
* @see \Drupal\content_moderation\Entity\ContentModerationState
*/
public function testCreateContentModerationStateResource(): void {
$this->expectException(PluginNotFoundException::class);
$this->expectExceptionMessage('The "entity:content_moderation_state" plugin does not exist.');
RestResourceConfig::create([
'id' => 'entity.content_moderation_state',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET'],
'formats' => ['json'],
'authentication' => ['cookie'],
],
])
->enable()
->save();
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\content_moderation\Entity\ContentModerationState;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Test the ContentModerationState storage schema.
*
* @coversDefaultClass \Drupal\content_moderation\ContentModerationStateStorageSchema
* @group content_moderation
*/
class ContentModerationStateStorageSchemaTest extends KernelTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'content_moderation',
'user',
'system',
'text',
'workflows',
'entity_test',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('entity_test');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
NodeType::create([
'type' => 'example',
'name' => 'Example',
])->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
}
/**
* Tests the ContentModerationState unique keys.
*
* @covers ::getEntitySchema
*/
public function testUniqueKeys(): void {
// Create a node which will create a new ContentModerationState entity.
$node = Node::create([
'title' => 'Test title',
'type' => 'example',
'moderation_state' => 'draft',
]);
$node->save();
// Ensure an exception when all values match.
$this->assertStorageException([
'content_entity_type_id' => $node->getEntityTypeId(),
'content_entity_id' => $node->id(),
'content_entity_revision_id' => $node->getRevisionId(),
], TRUE);
// No exception for the same values, with a different langcode.
$this->assertStorageException([
'content_entity_type_id' => $node->getEntityTypeId(),
'content_entity_id' => $node->id(),
'content_entity_revision_id' => $node->getRevisionId(),
'langcode' => 'de',
], FALSE);
// A different workflow should not trigger an exception.
$this->assertStorageException([
'content_entity_type_id' => $node->getEntityTypeId(),
'content_entity_id' => $node->id(),
'content_entity_revision_id' => $node->getRevisionId(),
'workflow' => 'foo',
], FALSE);
// Different entity types should not trigger an exception.
$this->assertStorageException([
'content_entity_type_id' => 'entity_test',
'content_entity_id' => $node->id(),
'content_entity_revision_id' => $node->getRevisionId(),
], FALSE);
// Different entity and revision IDs should not trigger an exception.
$this->assertStorageException([
'content_entity_type_id' => $node->getEntityTypeId(),
'content_entity_id' => 9999,
'content_entity_revision_id' => 9999,
], FALSE);
// Creating a version of the entity with a previously used, but not current
// revision ID should trigger an exception.
$old_revision_id = $node->getRevisionId();
$node->setNewRevision(TRUE);
$node->title = 'Updated title';
$node->moderation_state = 'published';
$node->save();
$this->assertStorageException([
'content_entity_type_id' => $node->getEntityTypeId(),
'content_entity_id' => $node->id(),
'content_entity_revision_id' => $old_revision_id,
], TRUE);
}
/**
* Assert if a storage exception is triggered when saving a given entity.
*
* @param array $values
* An array of entity values.
* @param bool $has_exception
* If an exception should be triggered when saving the entity.
*
* @internal
*/
protected function assertStorageException(array $values, bool $has_exception): void {
$defaults = [
'moderation_state' => 'draft',
'workflow' => 'editorial',
];
$entity = ContentModerationState::create($values + $defaults);
$exception_triggered = FALSE;
try {
ContentModerationState::updateOrCreateFromEntity($entity);
}
catch (\Exception $e) {
$exception_triggered = TRUE;
}
$this->assertEquals($has_exception, $exception_triggered);
}
}

Some files were not shown because too many files have changed in this diff Show More