updated core and modules

This commit is contained in:
Bachir Soussi Chiadmi
2017-09-09 16:27:51 +02:00
parent 027aa99b32
commit 41863f872c
7810 changed files with 318922 additions and 83631 deletions
@@ -0,0 +1,63 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
id: editorial
label: 'Editorial workflow'
states:
archived:
label: Archived
weight: 5
draft:
label: Draft
weight: -5
published:
label: Published
weight: 0
transitions:
archive:
label: Archive
from:
- published
to: archived
weight: 2
archived_draft:
label: 'Restore to Draft'
from:
- archived
to: draft
weight: 3
archived_published:
label: Restore
from:
- archived
to: published
weight: 4
create_new_draft:
label: 'Create New Draft'
from:
- draft
- published
to: draft
weight: 0
publish:
label: Publish
from:
- draft
- published
to: published
weight: 1
type: content_moderation
type_settings:
states:
archived:
published: false
default_revision: true
draft:
published: false
default_revision: false
published:
published: true
default_revision: true
entity_types: { }
@@ -0,0 +1,33 @@
views.filter.latest_revision:
type: views_filter
label: 'Latest revision'
mapping:
value:
type: string
label: 'Value'
workflow.type_settings.content_moderation:
type: mapping
mapping:
states:
type: sequence
label: 'Additional state configuration for content moderation'
sequence:
type: mapping
label: 'States'
mapping:
published:
type: boolean
label: 'Is published'
default_revision:
type: boolean
label: 'Is default revision'
entity_types:
type: sequence
label: 'Entity types'
sequence:
type: sequence
label: 'Bundles'
sequence:
type: string
label: 'Bundle ID'
@@ -0,0 +1,15 @@
name: 'Content Moderation'
type: module
description: 'Provides moderation states for content'
# version: VERSION
# core: 8.x
package: Core (Experimental)
configure: entity.workflow.collection
dependencies:
- workflows
# Information added by Drupal.org packaging script on 2017-08-16
version: '8.3.7'
core: '8.x'
project: 'drupal'
datestamp: 1502903957
@@ -0,0 +1,7 @@
content_moderation:
version: VERSION
css:
component:
css/content_moderation.module.css: {}
theme:
css/content_moderation.theme.css: {}
@@ -0,0 +1,3 @@
content_moderation.workflows:
deriver: 'Drupal\content_moderation\Plugin\Derivative\DynamicLocalTasks'
weight: 100
@@ -0,0 +1,236 @@
<?php
/**
* @file
* Contains content_moderation.module.
*/
use Drupal\content_moderation\EntityOperations;
use Drupal\content_moderation\EntityTypeInfo;
use Drupal\content_moderation\ContentPreprocess;
use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublishNode;
use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublishNode;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\workflows\WorkflowInterface;
use Drupal\node\NodeInterface;
use Drupal\node\Plugin\Action\PublishNode;
use Drupal\node\Plugin\Action\UnpublishNode;
use Drupal\workflows\Entity\Workflow;
/**
* Implements hook_help().
*/
function content_moderation_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
// Main module help for the content_moderation module.
case 'help.page.content_moderation':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('The Content Moderation module provides moderation for content by applying workflows to content. For more information, see the <a href=":content_moderation">online documentation for the Content Moderation module</a>.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation']) . '</p>';
$output .= '<h3>' . t('Uses') . '</h3>';
$output .= '<dl>';
$output .= '<dt>' . t('Configuring workflows') . '</dt>';
$output .= '<dd>' . t('Enable the Workflow UI module to create, edit and delete content moderation workflows.') . '</p>';
$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, then they can move that node from the start state to the end state') . '</p>';
$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_type_alter().
*/
function content_moderation_entity_type_alter(array &$entity_types) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityTypeAlter($entity_types);
}
/**
* Implements hook_entity_operation().
*/
function content_moderation_entity_operation(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityOperation($entity);
}
/**
* 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_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().
*
* Many default node templates rely on $page to determine whether to output the
* node title as part of the node content.
*/
function content_moderation_preprocess_node(&$variables) {
\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_node_access().
*
* Nodes in particular should be viewable if unpublished and the user has
* the appropriate permission. This permission is therefore effectively
* mandatory for any user that wants to moderate things.
*/
function content_moderation_node_access(NodeInterface $node, $operation, AccountInterface $account) {
/** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
$moderation_info = Drupal::service('content_moderation.moderation_information');
$access_result = NULL;
if ($operation === 'view') {
$access_result = (!$node->isPublished())
? AccessResult::allowedIfHasPermission($account, 'view any unpublished content')
: AccessResult::neutral();
$access_result->addCacheableDependency($node);
}
elseif ($operation === 'update' && $moderation_info->isModeratedEntity($node) && $node->moderation_state) {
/** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */
$transition_validation = \Drupal::service('content_moderation.state_transition_validation');
$valid_transition_targets = $transition_validation->getValidTransitions($node, $account);
$access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden();
$access_result->addCacheableDependency($node);
$access_result->addCacheableDependency($account);
$workflow = \Drupal::service('content_moderation.moderation_information')->getWorkflowForEntity($node);
$access_result->addCacheableDependency($workflow);
foreach ($valid_transition_targets as $valid_transition_target) {
$access_result->addCacheableDependency($valid_transition_target);
}
}
return $access_result;
}
/**
* Implements hook_theme().
*/
function content_moderation_theme() {
return ['entity_moderation_form' => ['render element' => 'form']];
}
/**
* Implements hook_action_info_alter().
*/
function content_moderation_action_info_alter(&$definitions) {
// The publish/unpublish actions are not valid on moderated entities. So swap
// their implementations out for alternates that will become a no-op on a
// moderated node. If another module has already swapped out those classes,
// though, we'll be polite and do nothing.
if (isset($definitions['node_publish_action']['class']) && $definitions['node_publish_action']['class'] == PublishNode::class) {
$definitions['node_publish_action']['class'] = ModerationOptOutPublishNode::class;
}
if (isset($definitions['node_unpublish_action']['class']) && $definitions['node_unpublish_action']['class'] == UnpublishNode::class) {
$definitions['node_unpublish_action']['class'] = ModerationOptOutUnpublishNode::class;
}
}
/**
* Implements hook_entity_bundle_info_alter().
*/
function content_moderation_entity_bundle_info_alter(&$bundles) {
/** @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();
}
}
}
}
}
/**
* 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();
}
/**
* Implements hook_ENTITY_TYPE_update().
*/
function content_moderation_workflow_update(WorkflowInterface $entity) {
content_moderation_workflow_insert($entity);
}
@@ -0,0 +1,19 @@
view any unpublished content:
title: 'View any unpublished content'
description: 'This permission is necessary for any users that may moderate content.'
view content moderation:
title: 'View content moderation'
description: 'View content moderation.'
'administer content moderation':
title: 'Administer content moderation'
description: 'Administer workflows on content entities.'
'restrict access': TRUE
view latest version:
title: 'View the latest version'
description: 'View the latest version of an entity. (Also requires "View any unpublished content" permission)'
permission_callbacks:
- \Drupal\content_moderation\Permissions::transitionPermissions
@@ -0,0 +1,22 @@
services:
paramconverter.latest_revision:
class: Drupal\content_moderation\ParamConverter\EntityRevisionConverter
arguments: ['@entity.manager', '@content_moderation.moderation_information']
tags:
- { name: paramconverter, priority: 5 }
content_moderation.state_transition_validation:
class: \Drupal\content_moderation\StateTransitionValidation
arguments: ['@content_moderation.moderation_information']
content_moderation.moderation_information:
class: Drupal\content_moderation\ModerationInformation
arguments: ['@entity_type.manager', '@entity_type.bundle.info']
access_check.latest_revision:
class: Drupal\content_moderation\Access\LatestRevisionCheck
arguments: ['@content_moderation.moderation_information']
tags:
- { name: access_check, applies_to: _content_moderation_latest_version }
content_moderation.revision_tracker:
class: Drupal\content_moderation\RevisionTracker
arguments: ['@database']
tags:
- { name: backend_overridable }
@@ -0,0 +1,37 @@
<?php
/**
* @file
* Provide views data for content_moderation.module.
*
* @ingroup views_module_handlers
*/
use Drupal\content_moderation\ViewsData;
/**
* Implements hook_views_data().
*/
function content_moderation_views_data() {
return _content_moderation_views_data_object()->getViewsData();
}
/**
* Implements hook_views_data_alter().
*/
function content_moderation_views_data_alter(array &$data) {
_content_moderation_views_data_object()->alterViewsData($data);
}
/**
* Creates a ViewsData object to respond to views hooks.
*
* @return \Drupal\content_moderation\ViewsData
* The content moderation ViewsData object.
*/
function _content_moderation_views_data_object() {
return new ViewsData(
\Drupal::service('entity_type.manager'),
\Drupal::service('content_moderation.moderation_information')
);
}
@@ -0,0 +1,19 @@
/**
* @file
* Component styles for the content_moderation module.
*/
ul.entity-moderation-form {
list-style: none;
display: -webkit-flex; /* Safari */
display: flex;
-webkit-flex-wrap: wrap; /* Safari */
flex-wrap: wrap;
-webkit-justify-content: space-around; /* Safari */
justify-content: space-around;
-webkit-align-items: flex-end; /* Safari */
align-items: flex-end;
}
ul.entity-moderation-form input[type=submit] {
margin-bottom: 1.2em;
}
@@ -0,0 +1,7 @@
/**
* @file
* Theme styles for the content_moderation module.
*/
ul.entity-moderation-form {
border-bottom: 1px solid gray;
}
@@ -0,0 +1,100 @@
<?php
namespace Drupal\content_moderation\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use 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 forward revision available.
*
* This checker assumes the presence of an '_entity_access' requirement key
* in the same form as used by EntityAccessCheck.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
* @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->hasForwardRevision($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()->addCacheableDependency($entity);
}
/**
* Returns the default revision of the entity this route is for.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* returns the Entity in question.
*
* @throws \Exception
* A generic exception is thrown if the entity couldn't be loaded. This
* almost always implies a developer error, so it should get turned into
* an HTTP 500.
*/
protected function loadEntity(Route $route, RouteMatchInterface $route_match) {
$entity_type = $route->getOption('_content_moderation_entity_type');
if ($entity = $route_match->getParameter($entity_type)) {
if ($entity instanceof EntityInterface) {
return $entity;
}
}
throw new \Exception(sprintf('%s is not a valid entity route. The LatestRevisionCheck access checker may only be used with a route that has a single entity parameter.', $route_match->getRouteName()));
}
}
@@ -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();
}
}
@@ -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.');
}
}
@@ -0,0 +1,16 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\user\EntityOwnerInterface;
/**
* An interface for Content moderation state entity.
*
* Content moderation state entities track the moderation state of other content
* entities.
*/
interface ContentModerationStateInterface extends ContentEntityInterface, EntityOwnerInterface {
}
@@ -0,0 +1,38 @@
<?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',
];
$schema['content_moderation_state_field_data']['unique keys'] += [
'content_moderation_state__lookup' => $unique_keys,
];
$schema['content_moderation_state_field_revision']['unique keys'] += [
'content_moderation_state__lookup' => $unique_keys,
];
return $schema;
}
}
@@ -0,0 +1,68 @@
<?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.
*/
class ContentPreprocess implements ContainerInjectionInterface {
/**
* The route match service.
*
* @var \Drupal\Core\Routing\RouteMatchInterface $routeMatch
*/
protected $routeMatch;
/**
* Constructor.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* Current route match service.
*/
public function __construct(RouteMatchInterface $route_match) {
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_route_match')
);
}
/**
* Wrapper for hook_preprocess_HOOK().
*
* @param array $variables
* Theme variables to preprocess.
*/
public function preprocessNode(array &$variables) {
// Set the 'page' template variable when the node is being displayed on the
// "Latest version" tab provided by content_moderation.
$variables['page'] = $variables['page'] || $this->isLatestVersionPage($variables['node']);
}
/**
* Checks whether a route is the "Latest version" tab of a node.
*
* @param \Drupal\node\Entity\Node $node
* A node.
*
* @return bool
* True if the current route is the latest version tab of the given node.
*/
public function isLatestVersionPage(Node $node) {
return $this->routeMatch->getRouteName() == 'entity.node.latest_version'
&& ($pageNode = $this->routeMatch->getParameter('node'))
&& $pageNode->id() == $node->id();
}
}
@@ -0,0 +1,182 @@
<?php
namespace Drupal\content_moderation\Entity;
use Drupal\content_moderation\ContentModerationStateInterface;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\user\UserInterface;
/**
* Defines the Content moderation state entity.
*
* @ContentEntityType(
* id = "content_moderation_state",
* label = @Translation("Content moderation state"),
* label_singular = @Translation("content moderation state"),
* label_plural = @Translation("content moderation states"),
* label_count = @PluralTranslation(
* singular = "@count content moderation state",
* plural = "@count content moderation states"
* ),
* handlers = {
* "storage_schema" = "Drupal\content_moderation\ContentModerationStateStorageSchema",
* "views_data" = "\Drupal\views\EntityViewsData",
* "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,
* entity_keys = {
* "id" = "id",
* "revision" = "revision_id",
* "uuid" = "uuid",
* "uid" = "uid",
* "langcode" = "langcode",
* }
* )
*/
class ContentModerationState extends ContentEntityBase implements ContentModerationStateInterface {
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields['uid'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('User'))
->setDescription(t('The username of the entity creator.'))
->setSetting('target_type', 'user')
->setDefaultValueCallback('Drupal\content_moderation\Entity\ContentModerationState::getCurrentUserId')
->setTranslatable(TRUE)
->setRevisionable(TRUE);
$fields['workflow'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Workflow'))
->setDescription(t('The workflow the moderation state is in.'))
->setSetting('target_type', 'workflow')
->setRequired(TRUE)
->setTranslatable(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;
}
/**
* {@inheritdoc}
*/
public function getOwner() {
return $this->get('uid')->entity;
}
/**
* {@inheritdoc}
*/
public function getOwnerId() {
return $this->getEntityKey('uid');
}
/**
* {@inheritdoc}
*/
public function setOwnerId($uid) {
$this->set('uid', $uid);
return $this;
}
/**
* {@inheritdoc}
*/
public function setOwner(UserInterface $account) {
$this->set('uid', $account->id());
return $this;
}
/**
* Creates or updates an entity's moderation state whilst saving that entity.
*
* @param \Drupal\content_moderation\Entity\ContentModerationState $content_moderation_state
* The content moderation entity content entity to create or save.
*
* @internal
* This method should only be called as a result of saving the related
* content entity.
*/
public static function updateOrCreateFromEntity(ContentModerationState $content_moderation_state) {
$content_moderation_state->realSave();
}
/**
* Default value callback for the 'uid' base field definition.
*
* @see \Drupal\content_moderation\Entity\ContentModerationState::baseFieldDefinitions()
*
* @return array
* An array of default values.
*/
public static function getCurrentUserId() {
return [\Drupal::currentUser()->id()];
}
/**
* {@inheritdoc}
*/
public function save() {
$related_entity = \Drupal::entityTypeManager()
->getStorage($this->content_entity_type_id->value)
->loadRevision($this->content_entity_revision_id->value);
if ($related_entity instanceof TranslatableInterface) {
$related_entity = $related_entity->getTranslation($this->activeLangcode);
}
$related_entity->moderation_state = $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();
}
}
@@ -0,0 +1,30 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Form\FormStateInterface;
/**
* Customizations for block content entities.
*/
class BlockContentModerationHandler extends ModerationHandler {
/**
* {@inheritdoc}
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
$form['revision_information']['revision']['#default_value'] = TRUE;
$form['revision_information']['revision']['#disabled'] = TRUE;
$form['revision_information']['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.');
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
$form['revision']['#default_value'] = 1;
$form['revision']['#disabled'] = TRUE;
$form['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.');
}
}
@@ -0,0 +1,55 @@
<?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.
*/
class ModerationHandler implements ModerationHandlerInterface, EntityHandlerInterface {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static();
}
/**
* {@inheritdoc}
*/
public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
// This is probably not necessary if configuration is setup correctly.
$entity->setNewRevision(TRUE);
$entity->isDefaultRevision($default_revision);
// 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) {
}
}
@@ -0,0 +1,57 @@
<?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.
*/
interface ModerationHandlerInterface {
/**
* 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);
}
@@ -0,0 +1,64 @@
<?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.
*/
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) {
/* @var \Drupal\node\Entity\NodeType $entity */
$entity = $form_state->getFormObject()->getEntity();
if ($this->moderationInfo->getWorkflowForEntity($entity)) {
// Force the revision checkbox on.
$form['workflow']['options']['#default_value']['revision'] = 'revision';
$form['workflow']['options']['revision']['#disabled'] = TRUE;
}
}
}
@@ -0,0 +1,284 @@
<?php
namespace Drupal\content_moderation;
use Drupal\content_moderation\Entity\ContentModerationState as ContentModerationStateEntity;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\content_moderation\Form\EntityModerationForm;
use Drupal\workflows\WorkflowInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for reacting to entity events.
*/
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 Revision Tracker service.
*
* @var \Drupal\content_moderation\RevisionTrackerInterface
*/
protected $tracker;
/**
* The entity bundle information service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* Constructs a new EntityOperations object.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* Moderation information service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager service.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The form builder.
* @param \Drupal\content_moderation\RevisionTrackerInterface $tracker
* The revision tracker.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info
* The entity bundle information service.
*/
public function __construct(ModerationInformationInterface $moderation_info, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, RevisionTrackerInterface $tracker, EntityTypeBundleInfoInterface $bundle_info) {
$this->moderationInfo = $moderation_info;
$this->entityTypeManager = $entity_type_manager;
$this->formBuilder = $form_builder;
$this->tracker = $tracker;
$this->bundleInfo = $bundle_info;
}
/**
* {@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('content_moderation.revision_tracker'),
$container->get('entity_type.bundle.info')
);
}
/**
* Acts on an entity and set published status based on the moderation state.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being saved.
*/
public function entityPresave(EntityInterface $entity) {
if (!$this->moderationInfo->isModeratedEntity($entity)) {
return;
}
if ($entity->moderation_state->value) {
$workflow = $this->moderationInfo->getWorkflowForEntity($entity);
/** @var \Drupal\content_moderation\ContentModerationState $current_state */
$current_state = $workflow->getState($entity->moderation_state->value);
// This entity is default if it is new, a new translation, the default
// revision, or the default revision is not published.
$update_default_revision = $entity->isNew()
|| $entity->isNewTranslation()
|| $current_state->isDefaultRevisionState()
|| !$this->isDefaultRevisionPublished($entity, $workflow);
// Fire per-entity-type logic for handling the save process.
$this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->onPresave($entity, $update_default_revision, $current_state->isPublishedState());
}
}
/**
* Hook bridge.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity that was just saved.
*
* @see hook_entity_insert()
*/
public function entityInsert(EntityInterface $entity) {
if ($this->moderationInfo->isModeratedEntity($entity)) {
$this->updateOrCreateFromEntity($entity);
$this->setLatestRevision($entity);
}
}
/**
* Hook bridge.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity that was just saved.
*
* @see hook_entity_update()
*/
public function entityUpdate(EntityInterface $entity) {
if ($this->moderationInfo->isModeratedEntity($entity)) {
$this->updateOrCreateFromEntity($entity);
$this->setLatestRevision($entity);
}
}
/**
* Creates or updates the moderation state of an entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to update or create a moderation state for.
*/
protected function updateOrCreateFromEntity(EntityInterface $entity) {
$moderation_state = $entity->moderation_state->value;
$workflow = $this->moderationInfo->getWorkflowForEntity($entity);
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
if (!$moderation_state) {
$moderation_state = $workflow->getTypePlugin()->getInitialState($workflow, $entity)->id();
}
// @todo what if $entity->moderation_state is null at this point?
$entity_type_id = $entity->getEntityTypeId();
$entity_id = $entity->id();
$entity_revision_id = $entity->getRevisionId();
$storage = $this->entityTypeManager->getStorage('content_moderation_state');
$entities = $storage->loadByProperties([
'content_entity_type_id' => $entity_type_id,
'content_entity_id' => $entity_id,
'workflow' => $workflow->id(),
]);
/** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */
$content_moderation_state = reset($entities);
if (!($content_moderation_state instanceof ContentModerationStateInterface)) {
$content_moderation_state = $storage->create([
'content_entity_type_id' => $entity_type_id,
'content_entity_id' => $entity_id,
]);
$content_moderation_state->workflow->target_id = $workflow->id();
}
elseif ($content_moderation_state->content_entity_revision_id->value != $entity_revision_id) {
// If a new revision of the content has been created, add a new content
// moderation state revision.
$content_moderation_state->setNewRevision(TRUE);
}
// Sync translations.
if ($entity->getEntityType()->hasKey('langcode')) {
$entity_langcode = $entity->language()->getId();
if (!$content_moderation_state->hasTranslation($entity_langcode)) {
$content_moderation_state->addTranslation($entity_langcode);
}
if ($content_moderation_state->language()->getId() !== $entity_langcode) {
$content_moderation_state = $content_moderation_state->getTranslation($entity_langcode);
}
}
// Create the ContentModerationState entity for the inserted entity.
$content_moderation_state->set('content_entity_revision_id', $entity_revision_id);
$content_moderation_state->set('moderation_state', $moderation_state);
ContentModerationStateEntity::updateOrCreateFromEntity($content_moderation_state);
}
/**
* Set the latest revision.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The content entity to create content_moderation_state entity for.
*/
protected function setLatestRevision(EntityInterface $entity) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$this->tracker->setLatestRevision(
$entity->getEntityTypeId(),
$entity->id(),
$entity->language()->getId(),
$entity->getRevisionId()
);
}
/**
* Act on entities being assembled before rendering.
*
* This is a hook bridge.
*
* @see hook_entity_view()
* @see EntityFieldManagerInterface::getExtraFields()
*/
public function entityView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
if (!$this->moderationInfo->isModeratedEntity($entity)) {
return;
}
if (!$this->moderationInfo->isLatestRevision($entity)) {
return;
}
if ($this->moderationInfo->isLiveRevision($entity)) {
return;
}
$component = $display->getComponent('content_moderation_control');
if ($component) {
$build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity);
$build['content_moderation_control']['#weight'] = $component['weight'];
}
}
/**
* Check if the default revision for the given entity is published.
*
* The default revision is the same as the entity retrieved by "default" from
* the storage handler. If the entity is translated, check if any of the
* translations are published.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being saved.
* @param \Drupal\workflows\WorkflowInterface $workflow
* The workflow being applied to the entity.
*
* @return bool
* TRUE if the default revision is published. FALSE otherwise.
*/
protected function isDefaultRevisionPublished(EntityInterface $entity, WorkflowInterface $workflow) {
$default_revision = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->load($entity->id());
// 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.
$language_revision = $default_revision->getTranslation($language->getId());
// Return TRUE if a translation with a published state is found.
if ($workflow->getState($language_revision->moderation_state->value)->isPublishedState()) {
return TRUE;
}
}
}
return $workflow->getState($default_revision->moderation_state->value)->isPublishedState();
}
}
@@ -0,0 +1,390 @@
<?php
namespace Drupal\content_moderation;
use Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList;
use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
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\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\Url;
use Drupal\content_moderation\Entity\Handler\BlockContentModerationHandler;
use Drupal\content_moderation\Entity\Handler\ModerationHandler;
use Drupal\content_moderation\Entity\Handler\NodeModerationHandler;
use Drupal\content_moderation\Form\BundleModerationConfigurationForm;
use Drupal\content_moderation\Routing\EntityModerationRouteProvider;
use Drupal\content_moderation\Routing\EntityTypeModerationRouteProvider;
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.
*/
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;
/**
* A keyed array of custom moderation handlers for given entity types.
*
* Any entity not specified will use a common default.
*
* @var array
*/
protected $moderationHandlers = [
'node' => NodeModerationHandler::class,
'block_content' => BlockContentModerationHandler::class,
];
/**
* EntityTypeInfo constructor.
*
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation
* The translation service. for form alters.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info
* Bundle information service.
* @param \Drupal\Core\Session\AccountInterface $current_user
* Current user.
*/
public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info, AccountInterface $current_user) {
$this->stringTranslation = $translation;
$this->moderationInfo = $moderation_information;
$this->entityTypeManager = $entity_type_manager;
$this->bundleInfo = $bundle_info;
$this->currentUser = $current_user;
}
/**
* {@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')
);
}
/**
* Adds Moderation configuration to appropriate entity types.
*
* This is an alter hook bridge.
*
* @param EntityTypeInterface[] $entity_types
* The master entity type list to alter.
*
* @see hook_entity_type_alter()
*/
public function entityTypeAlter(array &$entity_types) {
foreach ($entity_types as $entity_type_id => $entity_type) {
// The ContentModerationState entity type should never be moderated.
if ($entity_type->isRevisionable() && $entity_type_id != 'content_moderation_state') {
$entity_types[$entity_type_id] = $this->addModerationToEntityType($entity_type);
// Add additional moderation support to entity types whose bundles are
// managed by a config entity type.
if ($entity_type->getBundleEntityType()) {
$entity_types[$entity_type->getBundleEntityType()] = $this->addModerationToBundleEntityType($entity_types[$entity_type->getBundleEntityType()]);
}
}
}
}
/**
* 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');
}
// @todo Core forgot to add a direct way to manipulate route_provider, so
// we have to do it the sloppy way for now.
$providers = $type->getRouteProviderClasses() ?: [];
if (empty($providers['moderation'])) {
$providers['moderation'] = EntityModerationRouteProvider::class;
$type->setHandlerClass('route_provider', $providers);
}
return $type;
}
/**
* Configures moderation configuration support on a entity type definition.
*
* That "configuration support" includes a configuration form, a hypermedia
* link, and a route provider to tie it all together. There's also a
* moderation handler for per-entity-type variation.
*
* @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $type
* The config entity definition to modify.
*
* @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface
* The modified config entity definition.
*/
protected function addModerationToBundleEntityType(ConfigEntityTypeInterface $type) {
if ($type->hasLinkTemplate('edit-form') && !$type->hasLinkTemplate('moderation-form')) {
$type->setLinkTemplate('moderation-form', $type->getLinkTemplate('edit-form') . '/moderation');
}
if (!$type->getFormClass('moderation')) {
$type->setFormClass('moderation', BundleModerationConfigurationForm::class);
}
// @todo Core forgot to add a direct way to manipulate route_provider, so
// we have to do it the sloppy way for now.
$providers = $type->getRouteProviderClasses() ?: [];
if (empty($providers['moderation'])) {
$providers['moderation'] = EntityTypeModerationRouteProvider::class;
$type->setHandlerClass('route_provider', $providers);
}
return $type;
}
/**
* Adds an operation on bundles that should have a Moderation form.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity on which to define an operation.
*
* @return array
* An array of operation definitions.
*
* @see hook_entity_operation()
*/
public function entityOperation(EntityInterface $entity) {
$operations = [];
$type = $entity->getEntityType();
$bundle_of = $type->getBundleOf();
if ($this->currentUser->hasPermission('administer content moderation') && $bundle_of &&
$this->moderationInfo->canModerateEntitiesOfEntityType($this->entityTypeManager->getDefinition($bundle_of))
) {
$operations['manage-moderation'] = [
'title' => t('Manage moderation'),
'weight' => 27,
'url' => Url::fromRoute("entity.{$type->id()}.moderation", [$entity->getEntityTypeId() => $entity->id()]),
];
}
return $operations;
}
/**
* Gets the "extra fields" for a bundle.
*
* This is a hook bridge.
*
* @see hook_entity_extra_field_info()
*
* @return array
* A nested array of 'pseudo-field' elements. Each list is nested within the
* following keys: entity type, bundle name, context (either 'form' or
* 'display'). The keys are the name of the elements as appearing in the
* renderable array (either the entity form or the displayed entity). The
* value is an associative array:
* - label: The human readable name of the element. Make sure you sanitize
* this appropriately.
* - description: A short description of the element contents.
* - weight: The default weight of the element.
* - visible: (optional) The default visibility of the element. Defaults to
* TRUE.
* - edit: (optional) String containing markup (normally a link) used as the
* element's 'edit' operation in the administration interface. Only for
* 'form' context.
* - delete: (optional) String containing markup (normally a link) used as
* the element's 'delete' operation in the administration interface. Only
* for 'form' context.
*/
public function entityExtraFieldInfo() {
$return = [];
foreach ($this->getModeratedBundles() as $bundle) {
$return[$bundle['entity']][$bundle['bundle']]['display']['content_moderation_control'] = [
'label' => $this->t('Moderation control'),
'description' => $this->t("Status listing and form for the entity's moderation state."),
'weight' => -20,
'visible' => TRUE,
];
}
return $return;
}
/**
* Returns an iterable list of entity names and bundle names under moderation.
*
* That is, this method returns a list of bundles that have Content
* Moderation enabled on them.
*
* @return \Generator
* A generator, yielding a 2 element associative array:
* - entity: The machine name of an entity type, such as "node" or
* "block_content".
* - bundle: The machine name of a bundle, such as "page" or "article".
*/
protected function getModeratedBundles() {
$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.
*/
public function entityBaseFieldInfo(EntityTypeInterface $entity_type) {
if (!$this->moderationInfo->canModerateEntitiesOfEntityType($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)
->setSetting('target_type', 'moderation_state')
->setDisplayOptions('view', [
'label' => 'hidden',
'region' => 'hidden',
'weight' => -5,
])
->setDisplayOptions('form', [
'type' => 'moderation_state_default',
'weight' => 5,
'settings' => [],
])
->addConstraint('ModerationState', [])
->setDisplayConfigurable('form', FALSE)
->setDisplayConfigurable('view', FALSE)
->setReadOnly(FALSE)
->setTranslatable(TRUE);
return $fields;
}
/**
* 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) {
$type = $form_object->getEntity()->getEntityType();
if ($this->moderationInfo->canModerateEntitiesOfEntityType($type)) {
$this->entityTypeManager->getHandler($type->getBundleOf(), 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id);
}
}
elseif ($form_object instanceof ContentEntityFormInterface) {
$entity = $form_object->getEntity();
if ($this->moderationInfo->isModeratedEntity($entity)) {
$this->entityTypeManager
->getHandler($entity->getEntityTypeId(), 'moderation')
->enforceRevisionsEntityFormAlter($form, $form_state, $form_id);
// Submit handler to redirect to the latest version, if available.
$form['actions']['submit']['#submit'][] = [EntityTypeInfo::class, 'bundleFormRedirect'];
}
}
}
/**
* Redirect content entity edit forms on save, if there is a forward revision.
*
* When saving their changes, editors should see those changes displayed on
* the next page.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public static function bundleFormRedirect(array &$form, FormStateInterface $form_state) {
/* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $form_state->getFormObject()->getEntity();
$moderation_info = \Drupal::getContainer()->get('content_moderation.moderation_information');
if ($moderation_info->hasForwardRevision($entity) && $entity->hasLinkTemplate('latest-version')) {
$entity_type_id = $entity->getEntityTypeId();
$form_state->setRedirect("entity.$entity_type_id.latest_version", [$entity_type_id => $entity->id()]);
}
}
}
@@ -0,0 +1,155 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\content_moderation\Plugin\WorkflowType\ContentModeration;
use Drupal\workflows\WorkflowInterface;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form for configuring moderation usage on a given entity bundle.
*/
class BundleModerationConfigurationForm extends EntityForm {
/**
* Entity Type Manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* {@inheritdoc}
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('entity_type.manager'));
}
/**
* {@inheritdoc}
*
* Blank out the base form ID so that form alters that use the base form ID to
* target both add and edit forms don't pick up this form.
*/
public function getBaseFormId() {
return NULL;
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
/* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle */
$bundle = $this->getEntity();
$bundle_of_entity_type = $this->entityTypeManager->getDefinition($bundle->getEntityType()->getBundleOf());
/* @var \Drupal\workflows\WorkflowInterface[] $workflows */
$workflows = $this->entityTypeManager->getStorage('workflow')->loadMultiple();
$options = array_map(function (WorkflowInterface $workflow) {
return $workflow->label();
}, array_filter($workflows, function (WorkflowInterface $workflow) {
return $workflow->status() && $workflow->getTypePlugin() instanceof ContentModeration;
}));
$selected_workflow = array_reduce($workflows, function ($carry, WorkflowInterface $workflow) use ($bundle_of_entity_type, $bundle) {
$plugin = $workflow->getTypePlugin();
if ($plugin instanceof ContentModeration && $plugin->appliesToEntityTypeAndBundle($bundle_of_entity_type->id(), $bundle->id())) {
return $workflow->id();
}
return $carry;
});
$form['workflow'] = [
'#type' => 'select',
'#title' => $this->t('Select the workflow to apply'),
'#default_value' => $selected_workflow,
'#options' => $options,
'#required' => FALSE,
'#empty_value' => '',
];
$form['original_workflow'] = [
'#type' => 'value',
'#value' => $selected_workflow,
];
$form['bundle'] = [
'#type' => 'value',
'#value' => $bundle->id(),
];
$form['entity_type'] = [
'#type' => 'value',
'#value' => $bundle_of_entity_type->id(),
];
// Add a special message when moderation is being disabled.
if ($selected_workflow) {
$form['enable_workflow_note'] = [
'#type' => 'item',
'#description' => $this->t('After disabling moderation, any existing forward drafts will be accessible via the "Revisions" tab.'),
'#access' => !empty($selected_workflow)
];
}
return parent::form($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// If moderation is enabled, revisions MUST be enabled as well. Otherwise we
// can't have forward revisions.
drupal_set_message($this->t('Your settings have been saved.'));
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$entity_type_id = $form_state->getValue('entity_type');
$bundle_id = $form_state->getValue('bundle');
$new_workflow_id = $form_state->getValue('workflow');
$original_workflow_id = $form_state->getValue('original_workflow');
if ($new_workflow_id === $original_workflow_id) {
// Nothing to do.
return;
}
if ($original_workflow_id) {
/* @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entityTypeManager->getStorage('workflow')->load($original_workflow_id);
$workflow->getTypePlugin()->removeEntityTypeAndBundle($entity_type_id, $bundle_id);
$workflow->save();
}
if ($new_workflow_id) {
/* @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entityTypeManager->getStorage('workflow')->load($new_workflow_id);
$workflow->getTypePlugin()->addEntityTypeAndBundle($entity_type_id, $bundle_id);
$workflow->save();
}
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Save'),
'#submit' => ['::submitForm', '::save'],
];
return $actions;
}
}
@@ -0,0 +1,150 @@
<?php
namespace Drupal\content_moderation\Form;
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\StateTransitionValidation;
use Drupal\workflows\Transition;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The EntityModerationForm provides a simple UI for changing moderation state.
*/
class EntityModerationForm extends FormBase {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* The moderation state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidation
*/
protected $validation;
/**
* EntityModerationForm constructor.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
* @param \Drupal\content_moderation\StateTransitionValidation $validation
* The moderation state transition validation service.
*/
public function __construct(ModerationInformationInterface $moderation_info, StateTransitionValidation $validation) {
$this->moderationInfo = $moderation_info;
$this->validation = $validation;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('content_moderation.moderation_information'),
$container->get('content_moderation.state_transition_validation')
);
}
/**
* {@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('Status'),
'#markup' => $workflow->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('Moderate'),
'#options' => $target_states,
];
$form['revision_log'] = [
'#type' => 'textfield',
'#title' => $this->t('Log message'),
'#size' => 30,
];
$form['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Apply'),
];
$form['#theme'] = ['entity_moderation_form'];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var ContentEntityInterface $entity */
$entity = $form_state->get('entity');
$new_state = $form_state->getValue('new_state');
$entity->set('moderation_state', $new_state);
if ($entity instanceof RevisionLogInterface) {
$entity->setRevisionLogMessage($form_state->getValue('revision_log'));
$entity->setRevisionUserId($this->currentUser()->id());
}
$entity->save();
drupal_set_message($this->t('The moderation state has been updated.'));
$new_state = $this->moderationInfo->getWorkflowForEntity($entity)->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
// forward revision. Redirect to the canonical URL instead, since that will
// still exist.
if ($new_state->isDefaultRevisionState()) {
$form_state->setRedirectUrl($entity->toUrl('canonical'));
}
}
}
@@ -0,0 +1,151 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* General service for moderation-related questions about Entity API.
*/
class ModerationInformation implements ModerationInformationInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The 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;
}
return $this->shouldModerateEntitiesOfBundle($entity->getEntityType(), $entity->bundle());
}
/**
* {@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 getLatestRevision($entity_type_id, $entity_id) {
if ($latest_revision_id = $this->getLatestRevisionId($entity_type_id, $entity_id)) {
return $this->entityTypeManager->getStorage($entity_type_id)->loadRevision($latest_revision_id);
}
}
/**
* {@inheritdoc}
*/
public function getLatestRevisionId($entity_type_id, $entity_id) {
if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) {
$revision_ids = $storage->getQuery()
->allRevisions()
->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id)
->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC')
->range(0, 1)
->execute();
if ($revision_ids) {
return array_keys($revision_ids)[0];
}
}
}
/**
* {@inheritdoc}
*/
public function getDefaultRevisionId($entity_type_id, $entity_id) {
if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) {
$revision_ids = $storage->getQuery()
->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id)
->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC')
->range(0, 1)
->execute();
if ($revision_ids) {
return array_keys($revision_ids)[0];
}
}
}
/**
* {@inheritdoc}
*/
public function isLatestRevision(ContentEntityInterface $entity) {
return $entity->getRevisionId() == $this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id());
}
/**
* {@inheritdoc}
*/
public function hasForwardRevision(ContentEntityInterface $entity) {
return $this->isModeratedEntity($entity)
&& !($this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id()) == $this->getDefaultRevisionId($entity->getEntityTypeId(), $entity->id()));
}
/**
* {@inheritdoc}
*/
public function isLiveRevision(ContentEntityInterface $entity) {
$workflow = $this->getWorkflowForEntity($entity);
return $this->isLatestRevision($entity)
&& $entity->isDefaultRevision()
&& $entity->moderation_state->value
&& $workflow->getState($entity->moderation_state->value)->isPublishedState();
}
/**
* {@inheritdoc}
*/
public function getWorkflowForEntity(ContentEntityInterface $entity) {
$bundles = $this->bundleInfo->getBundleInfo($entity->getEntityTypeId());
if (isset($bundles[$entity->bundle()]['workflow'])) {
return $this->entityTypeManager->getStorage('workflow')->load($bundles[$entity->bundle()]['workflow']);
};
return NULL;
}
}
@@ -0,0 +1,140 @@
<?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);
/**
* Loads the latest revision of a specific entity.
*
* @param string $entity_type_id
* The entity type ID.
* @param int $entity_id
* The entity ID.
*
* @return \Drupal\Core\Entity\ContentEntityInterface|null
* The latest entity revision or NULL, if the entity type / entity doesn't
* exist.
*/
public function getLatestRevision($entity_type_id, $entity_id);
/**
* Returns the revision ID of the latest revision of the given entity.
*
* @param string $entity_type_id
* The entity type ID.
* @param int $entity_id
* The entity ID.
*
* @return int
* The revision ID of the latest revision for the specified entity, or
* NULL if there is no such entity.
*/
public function getLatestRevisionId($entity_type_id, $entity_id);
/**
* Returns the revision ID of the default revision for the specified entity.
*
* @param string $entity_type_id
* The entity type ID.
* @param int $entity_id
* The entity ID.
*
* @return int
* The revision ID of the default revision, or NULL if the entity was
* not found.
*/
public function getDefaultRevisionId($entity_type_id, $entity_id);
/**
* Determines if an entity is a latest revision.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* A revisionable content entity.
*
* @return bool
* TRUE if the specified object is the latest revision of its entity,
* FALSE otherwise.
*/
public function isLatestRevision(ContentEntityInterface $entity);
/**
* Determines if a forward revision exists for the specified entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity which may or may not have a forward revision.
*
* @return bool
* TRUE if this entity has forward revisions available, FALSE otherwise.
*/
public function hasForwardRevision(ContentEntityInterface $entity);
/**
* Determines if an entity is "live".
*
* A "live" entity revision is one whose latest revision is also the default,
* and whose moderation state, if any, is a published state.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to check.
*
* @return bool
* TRUE if the specified entity is a live revision, FALSE otherwise.
*/
public function isLiveRevision(ContentEntityInterface $entity);
/**
* 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);
}
@@ -0,0 +1,109 @@
<?php
namespace Drupal\content_moderation\ParamConverter;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\ParamConverter\EntityConverter;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Symfony\Component\Routing\Route;
/**
* Defines a class for making sure the edit-route loads the current draft.
*/
class EntityRevisionConverter extends EntityConverter {
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* EntityRevisionConverter constructor.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager, needed by the parent class.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation info utility service.
*
* @todo: If the parent class is ever cleaned up to use EntityTypeManager
* instead of Entity manager, this method will also need to be adjusted.
*/
public function __construct(EntityManagerInterface $entity_manager, ModerationInformationInterface $moderation_info) {
parent::__construct($entity_manager);
$this->moderationInformation = $moderation_info;
}
/**
* {@inheritdoc}
*/
public function applies($definition, $name, Route $route) {
return $this->hasForwardRevisionFlag($definition) || $this->isEditFormPage($route);
}
/**
* Determines if the route definition includes a forward-revision flag.
*
* This is a custom flag defined by the Content Moderation module to load
* forward revisions rather than the default revision on a given route.
*
* @param array $definition
* The parameter definition provided in the route options.
*
* @return bool
* TRUE if the forward revision flag is set, FALSE otherwise.
*/
protected function hasForwardRevisionFlag(array $definition) {
return (isset($definition['load_forward_revision']) && $definition['load_forward_revision']);
}
/**
* Determines if a given route is the edit-form for an entity.
*
* @param \Symfony\Component\Routing\Route $route
* The route definition.
*
* @return bool
* Returns TRUE if the route is the edit form of an entity, FALSE otherwise.
*/
protected function isEditFormPage(Route $route) {
if ($default = $route->getDefault('_entity_form')) {
// If no operation is provided, use 'default'.
$default .= '.default';
list($entity_type_id, $operation) = explode('.', $default);
if (!$this->entityManager->hasDefinition($entity_type_id)) {
return FALSE;
}
$entity_type = $this->entityManager->getDefinition($entity_type_id);
return $operation == 'edit' && $entity_type && $entity_type->isRevisionable();
}
}
/**
* {@inheritdoc}
*/
public function convert($value, $definition, $name, array $defaults) {
$entity = parent::convert($value, $definition, $name, $defaults);
if ($entity && $this->moderationInformation->isModeratedEntity($entity) && !$this->moderationInformation->isLatestRevision($entity)) {
$entity_type_id = $this->getEntityTypeFromDefaults($definition, $name, $defaults);
$latest_revision = $this->moderationInformation->getLatestRevision($entity_type_id, $value);
// If the entity type is translatable, ensure we return the proper
// translation object for the current context.
if ($latest_revision instanceof EntityInterface && $entity instanceof TranslatableInterface) {
$latest_revision = $this->entityManager->getTranslationFromContext($latest_revision, NULL, ['operation' => 'entity_upcast']);
}
if ($latest_revision instanceof EntityInterface && $latest_revision->isRevisionTranslationAffected()) {
$entity = $latest_revision;
}
}
return $entity;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\workflows\Entity\Workflow;
/**
* Defines a class for dynamic permissions based on transitions.
*/
class Permissions {
use StringTranslationTrait;
/**
* Returns an array of transition permissions.
*
* @return array
* The transition permissions.
*/
public function transitionPermissions() {
$permissions = [];
/** @var \Drupal\workflows\WorkflowInterface $workflow */
foreach (Workflow::loadMultipleByType('content_moderation') as $id => $workflow) {
foreach ($workflow->getTransitions() as $transition) {
$permissions['use ' . $workflow->id() . ' transition ' . $transition->id()] = [
'title' => $this->t('Use %transition transition from %workflow workflow.', [
'%transition' => $transition->label(),
'%workflow' => $workflow->label(),
]),
];
}
}
return $permissions;
}
}
@@ -0,0 +1,66 @@
<?php
namespace Drupal\content_moderation\Plugin\Action;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\Plugin\Action\PublishNode;
use Drupal\content_moderation\ModerationInformationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Alternate action plugin that can opt-out of modifying moderated entities.
*
* @see \Drupal\node\Plugin\Action\PublishNode
*/
class ModerationOptOutPublishNode extends PublishNode implements ContainerFactoryPluginInterface {
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* ModerationOptOutPublishNode constructor.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $moderation_info) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->moderationInfo = $moderation_info;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration, $plugin_id, $plugin_definition,
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function access($entity, AccountInterface $account = NULL, $return_as_object = FALSE) {
/** @var \Drupal\node\NodeInterface $entity */
if ($entity && $this->moderationInfo->isModeratedEntity($entity)) {
drupal_set_message($this->t("@bundle @label were skipped as they are under moderation and may not be directly published.", ['@bundle' => node_get_type_label($entity), '@label' => $entity->getEntityType()->getPluralLabel()]), 'warning');
$result = AccessResult::forbidden();
return $return_as_object ? $result : $result->isAllowed();
}
return parent::access($entity, $account, $return_as_object);
}
}
@@ -0,0 +1,66 @@
<?php
namespace Drupal\content_moderation\Plugin\Action;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\Plugin\Action\UnpublishNode;
use Drupal\content_moderation\ModerationInformationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Alternate action plugin that can opt-out of modifying moderated entities.
*
* @see \Drupal\node\Plugin\Action\UnpublishNode
*/
class ModerationOptOutUnpublishNode extends UnpublishNode implements ContainerFactoryPluginInterface {
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* ModerationOptOutUnpublishNode constructor.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $moderation_info) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->moderationInfo = $moderation_info;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration, $plugin_id, $plugin_definition,
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function access($entity, AccountInterface $account = NULL, $return_as_object = FALSE) {
/** @var \Drupal\node\NodeInterface $entity */
if ($entity && $this->moderationInfo->isModeratedEntity($entity)) {
drupal_set_message($this->t("@bundle @label were skipped as they are under moderation and may not be directly unpublished.", ['@bundle' => node_get_type_label($entity), '@label' => $entity->getEntityType()->getPluralLabel()]), 'warning');
$result = AccessResult::forbidden();
return $return_as_object ? $result : $result->isAllowed();
}
return parent::access($entity, $account, $return_as_object);
}
}
@@ -0,0 +1,108 @@
<?php
namespace Drupal\content_moderation\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Generates moderation-related local tasks.
*/
class DynamicLocalTasks extends DeriverBase implements ContainerDeriverInterface {
use StringTranslationTrait;
/**
* The base plugin ID.
*
* @var string
*/
protected $basePluginId;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* Creates an FieldUiLocalTask object.
*
* @param string $base_plugin_id
* The base plugin ID.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The translation manager.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information service.
*/
public function __construct($base_plugin_id, EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation, ModerationInformationInterface $moderation_information) {
$this->entityTypeManager = $entity_type_manager;
$this->stringTranslation = $string_translation;
$this->basePluginId = $base_plugin_id;
$this->moderationInfo = $moderation_information;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$base_plugin_id,
$container->get('entity_type.manager'),
$container->get('string_translation'),
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
$this->derivatives = [];
foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
if ($this->moderationInfo->canModerateEntitiesOfEntityType($entity_type)) {
$bundle_id = $entity_type->getBundleEntityType();
$this->derivatives["$bundle_id.moderation_tab"] = [
'route_name' => "entity.$bundle_id.moderation",
'title' => $this->t('Manage moderation'),
// @todo - are we sure they all have an edit_form?
'base_route' => "entity.$bundle_id.edit_form",
'weight' => 30,
] + $base_plugin_definition;
}
}
$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;
}
}
@@ -0,0 +1,77 @@
<?php
namespace Drupal\content_moderation\Plugin\Field\FieldFormatter;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'content_moderation_state' formatter.
*
* @FieldFormatter(
* id = "content_moderation_state",
* label = @Translation("Content moderation state"),
* field_types = {
* "string",
* }
* )
*/
class ContentModerationStateFormatter extends FormatterBase implements ContainerFactoryPluginInterface {
/**
* 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->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';
}
}
@@ -0,0 +1,214 @@
<?php
namespace Drupal\content_moderation\Plugin\Field\FieldWidget;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsSelectWidget;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\content_moderation\ModerationInformation;
use Drupal\content_moderation\StateTransitionValidation;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'moderation_state_default' widget.
*
* @FieldWidget(
* id = "moderation_state_default",
* label = @Translation("Moderation state"),
* field_types = {
* "string"
* }
* )
*/
class ModerationStateWidget extends OptionsSelectWidget implements ContainerFactoryPluginInterface {
/**
* 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\StateTransitionValidation
*/
protected $validator;
/**
* Constructs a new ModerationStateWidget object.
*
* @param string $plugin_id
* Plugin id.
* @param mixed $plugin_definition
* Plugin definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* Field definition.
* @param array $settings
* Field settings.
* @param array $third_party_settings
* Third party settings.
* @param \Drupal\Core\Session\AccountInterface $current_user
* Current user service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager.
* @param \Drupal\content_moderation\ModerationInformation $moderation_information
* Moderation information service.
* @param \Drupal\content_moderation\StateTransitionValidation $validator
* Moderation state transition validation service
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, ModerationInformation $moderation_information, StateTransitionValidation $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 formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
/** @var ContentEntityInterface $entity */
$entity = $items->getEntity();
if (!$this->moderationInformation->isModeratedEntity($entity)) {
// @todo https://www.drupal.org/node/2779933 write a test for this.
return $element + ['#access' => FALSE];
}
$workflow = $this->moderationInformation->getWorkflowForEntity($entity);
$default = $items->get($delta)->value ? $workflow->getState($items->get($delta)->value) : $workflow->getTypePlugin()->getInitialState($workflow, $entity);
/** @var \Drupal\workflows\Transition[] $transitions */
$transitions = $this->validator->getValidTransitions($entity, $this->currentUser);
$target_states = [];
foreach ($transitions as $transition) {
$target_states[$transition->to()->id()] = $transition->label();
}
// @todo https://www.drupal.org/node/2779933 write a test for this.
$element += [
'#access' => FALSE,
'#type' => 'select',
'#options' => $target_states,
'#default_value' => $default->id(),
'#published' => $default->isPublishedState(),
'#key_column' => $this->column,
];
$element['#element_validate'][] = [get_class($this), 'validateElement'];
// Use the dropbutton.
$element['#process'][] = [get_called_class(), 'processActions'];
return $element;
}
/**
* Entity builder updating the node moderation state with the submitted value.
*
* @param string $entity_type_id
* The entity type identifier.
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity updated with the submitted values.
* @param array $form
* The complete form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public static function updateStatus($entity_type_id, ContentEntityInterface $entity, array $form, FormStateInterface $form_state) {
$element = $form_state->getTriggeringElement();
if (isset($element['#moderation_state'])) {
$entity->moderation_state->value = $element['#moderation_state'];
}
}
/**
* Process callback to alter action buttons.
*/
public static function processActions($element, FormStateInterface $form_state, array &$form) {
// We'll steal most of the button configuration from the default submit
// button. However, NodeForm also hides that button for admins (as it adds
// its own, too), so we have to restore it.
$default_button = $form['actions']['submit'];
$default_button['#access'] = TRUE;
// Add a custom button for each transition we're allowing. The #dropbutton
// property tells FAPI to cluster them all together into a single widget.
$options = $element['#options'];
$entity = $form_state->getFormObject()->getEntity();
$translatable = !$entity->isNew() && $entity->isTranslatable();
foreach ($options as $id => $label) {
$button = [
'#dropbutton' => 'save',
'#moderation_state' => $id,
'#weight' => -10,
];
$button['#value'] = $translatable
? t('Save and @transition (this translation)', ['@transition' => $label])
: t('Save and @transition', ['@transition' => $label]);
$form['actions']['moderation_state_' . $id] = $button + $default_button;
}
// Hide the default buttons, including the specialty ones added by
// NodeForm.
foreach (['publish', 'unpublish', 'submit'] as $key) {
$form['actions'][$key]['#access'] = FALSE;
unset($form['actions'][$key]['#dropbutton']);
}
// Setup a callback to translate the button selection back into field
// widget, so that it will get saved properly.
$form['#entity_builders']['update_moderation_state'] = [get_called_class(), 'updateStatus'];
return $element;
}
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
return $field_definition->getName() === 'moderation_state';
}
}
@@ -0,0 +1,120 @@
<?php
namespace Drupal\content_moderation\Plugin\Field;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Field\FieldItemList;
/**
* 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 {
/**
* 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($workflow, $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\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 = \Drupal::service('entity.query')->get('content_moderation_state')
->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())
->allRevisions()
->sort('revision_id', 'DESC')
->execute();
if (empty($revisions)) {
return NULL;
}
/** @var \Drupal\content_moderation\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);
}
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.');
}
$this->computeModerationFieldItemList();
return isset($this->list[$index]) ? $this->list[$index] : NULL;
}
/**
* {@inheritdoc}
*/
public function getIterator() {
$this->computeModerationFieldItemList();
return parent::getIterator();
}
/**
* Recalculate the moderation field item list.
*/
protected function computeModerationFieldItemList() {
// Compute the value of the moderation state.
$index = 0;
if (!isset($this->list[$index]) || $this->list[$index]->isEmpty()) {
$moderation_state = $this->getModerationStateId();
// Do not store NULL values in the static cache.
if ($moderation_state) {
$this->list[$index] = $this->createItem($index, $moderation_state);
}
}
}
}
@@ -0,0 +1,19 @@
<?php
namespace Drupal\content_moderation\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* Verifies that nodes have a valid moderation state.
*
* @Constraint(
* id = "ModerationState",
* label = @Translation("Valid moderation state", context = "Validation")
* )
*/
class ModerationStateConstraint extends Constraint {
public $message = 'Invalid state transition from %from to %to';
}
@@ -0,0 +1,127 @@
<?php
namespace Drupal\content_moderation\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\content_moderation\StateTransitionValidation;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks if a moderation state transition is valid.
*/
class ModerationStateConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The state transition validation.
*
* @var \Drupal\content_moderation\StateTransitionValidation
*/
protected $validation;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
private $entityTypeManager;
/**
* The moderation info.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* Creates a new ModerationStateConstraintValidator instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_moderation\StateTransitionValidation $validation
* The state transition validation.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, StateTransitionValidation $validation, ModerationInformationInterface $moderation_information) {
$this->validation = $validation;
$this->entityTypeManager = $entity_type_manager;
$this->moderationInformation = $moderation_information;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('content_moderation.state_transition_validation'),
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\Core\Entity\EntityInterface $entity */
$entity = $value->getEntity();
// Ignore entities that are not subject to moderation anyway.
if (!$this->moderationInformation->isModeratedEntity($entity)) {
return;
}
// Ignore entities that are being created for the first time.
if ($entity->isNew()) {
return;
}
// Ignore entities that are being moderated for the first time, such as
// when they existed before moderation was enabled for this entity type.
if ($this->isFirstTimeModeration($entity)) {
return;
}
$original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id());
if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
$original_entity = $original_entity->getTranslation($entity->language()->getId());
}
$workflow = $this->moderationInformation->getWorkflowForEntity($entity);
$new_state = $workflow->getState($entity->moderation_state->value) ?: $workflow->getInitialState();
$original_state = $workflow->getState($original_entity->moderation_state->value);
// @todo - what if $new_state references something that does not exist or
// is null.
if (!$original_state->canTransitionTo($new_state->id())) {
$this->context->addViolation($constraint->message, ['%from' => $original_state->label(), '%to' => $new_state->label()]);
}
}
/**
* Determines if this entity is being moderated for the first time.
*
* If the previous version of the entity has no moderation state, we assume
* that means it predates the presence of moderation states.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being moderated.
*
* @return bool
* TRUE if this is the entity's first time being moderated, FALSE otherwise.
*/
protected function isFirstTimeModeration(EntityInterface $entity) {
$original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id());
if ($original_entity) {
$original_id = $original_entity->moderation_state;
}
return !($entity->moderation_state && $original_entity && $original_id);
}
}
@@ -0,0 +1,303 @@
<?php
namespace Drupal\content_moderation\Plugin\WorkflowType;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\content_moderation\ContentModerationState;
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 = @Translation("Content moderation"),
* required_states = {
* "draft",
* "published",
* },
* )
*/
class ContentModeration extends WorkflowTypeBase implements ContainerFactoryPluginInterface {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Creates an instance of the ContentModeration WorkflowType plugin.
*/
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')
);
}
/**
* {@inheritdoc}
*/
public function initializeWorkflow(WorkflowInterface $workflow) {
$workflow
->addState('draft', $this->t('Draft'))
->setStateWeight('draft', -5)
->addState('published', $this->t('Published'))
->setStateWeight('published', 0)
->addTransition('create_new_draft', $this->t('Create New Draft'), ['draft', 'published'], 'draft')
->addTransition('publish', $this->t('Publish'), ['draft', 'published'], 'published');
return $workflow;
}
/**
* {@inheritdoc}
*/
public function checkWorkflowAccess(WorkflowInterface $entity, $operation, AccountInterface $account) {
if ($operation === 'view') {
return AccessResult::allowedIfHasPermission($account, 'view content moderation');
}
return parent::checkWorkflowAccess($entity, $operation, $account);
}
/**
* {@inheritdoc}
*/
public function decorateState(StateInterface $state) {
if (isset($this->configuration['states'][$state->id()])) {
$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 buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) {
/** @var \Drupal\content_moderation\ContentModerationState $state */
$is_required_state = isset($state) ? in_array($state->id(), $this->getRequiredStates(), TRUE) : FALSE;
$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;
}
/**
* Gets the entity types the workflow is applied to.
*
* @return string[]
* The entity types the workflow is applied to.
*/
public function getEntityTypes() {
return array_keys($this->configuration['entity_types']);
}
/**
* 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) {
return isset($this->configuration['entity_types'][$entity_type_id]) ? $this->configuration['entity_types'][$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) {
return in_array($bundle_id, $this->getBundlesForEntityType($entity_type_id), TRUE);
}
/**
* 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) {
$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]);
}
}
}
/**
* 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) {
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() {
// This plugin does not store anything per transition.
return [
'states' => [
'draft' => [
'published' => FALSE,
'default_revision' => FALSE,
],
'published' => [
'published' => TRUE,
'default_revision' => TRUE,
],
],
'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(WorkflowInterface $workflow, $entity = NULL) {
if ($entity instanceof EntityPublishedInterface) {
return $workflow->getState($entity->isPublished() && !$entity->isNew() ? 'published' : 'draft');
}
return parent::getInitialState($workflow);
}
}
@@ -0,0 +1,136 @@
<?php
namespace Drupal\content_moderation\Plugin\views\filter;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\views\Plugin\views\filter\FilterPluginBase;
use Drupal\views\Plugin\ViewsHandlerManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Filter to show only the latest revision of an entity.
*
* @ingroup views_filter_handlers
*
* @ViewsFilter("latest_revision")
*/
class LatestRevision extends FilterPluginBase implements ContainerFactoryPluginInterface {
/**
* Entity Type Manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Views Handler Plugin Manager.
*
* @var \Drupal\views\Plugin\ViewsHandlerManager
*/
protected $joinHandler;
/**
* Database Connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* Constructs a new LatestRevision.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity Type Manager Service.
* @param \Drupal\views\Plugin\ViewsHandlerManager $join_handler
* Views Handler Plugin Manager.
* @param \Drupal\Core\Database\Connection $connection
* Database Connection.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, ViewsHandlerManager $join_handler, Connection $connection) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->joinHandler = $join_handler;
$this->connection = $connection;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration, $plugin_id, $plugin_definition,
$container->get('entity_type.manager'),
$container->get('plugin.manager.views.join'),
$container->get('database')
);
}
/**
* {@inheritdoc}
*/
public function adminSummary() {
}
/**
* {@inheritdoc}
*/
protected function operatorForm(&$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function canExpose() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function query() {
// The table doesn't exist until a moderated node has been saved at least
// once. Just in case, disable this filter until then. Note that this means
// the view will still show all revisions, not just latest, but this is
// sufficiently edge-case-y that it's probably not worth the time to
// handle more robustly.
if (!$this->connection->schema()->tableExists('content_revision_tracker')) {
return;
}
$table = $this->ensureMyTable();
/** @var \Drupal\views\Plugin\views\query\Sql $query */
$query = $this->query;
$definition = $this->entityTypeManager->getDefinition($this->getEntityType());
$keys = $definition->getKeys();
$definition = [
'table' => 'content_revision_tracker',
'type' => 'INNER',
'field' => 'entity_id',
'left_table' => $table,
'left_field' => $keys['id'],
'extra' => [
['left_field' => $keys['langcode'], 'field' => 'langcode'],
['left_field' => $keys['revision'], 'field' => 'revision_id'],
['field' => 'entity_type', 'value' => $this->getEntityType()],
],
];
$join = $this->joinHandler->createInstance('standard', $definition);
$query->ensureTable('content_revision_tracker', $this->relationship, $join);
}
}
@@ -0,0 +1,152 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\Core\Database\SchemaObjectExistsException;
/**
* Tracks metadata about revisions across entities.
*/
class RevisionTracker implements RevisionTrackerInterface {
/**
* The name of the SQL table we use for tracking.
*
* @var string
*/
protected $tableName;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* Constructs a new RevisionTracker.
*
* @param \Drupal\Core\Database\Connection $connection
* The database connection.
* @param string $table
* The table that should be used for tracking.
*/
public function __construct(Connection $connection, $table = 'content_revision_tracker') {
$this->connection = $connection;
$this->tableName = $table;
}
/**
* {@inheritdoc}
*/
public function setLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id) {
try {
$this->recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
}
catch (DatabaseExceptionWrapper $e) {
$this->ensureTableExists();
$this->recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
}
return $this;
}
/**
* Records the latest revision of a given entity.
*
* @param string $entity_type_id
* The machine name of the type of entity.
* @param string $entity_id
* The Entity ID in question.
* @param string $langcode
* The langcode of the revision we're saving. Each language has its own
* effective tree of entity revisions, so in different languages
* different revisions will be "latest".
* @param int $revision_id
* The revision ID that is now the latest revision.
*
* @return int
* One of the valid returns from a merge query's execute method.
*/
protected function recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id) {
return $this->connection->merge($this->tableName)
->keys([
'entity_type' => $entity_type_id,
'entity_id' => $entity_id,
'langcode' => $langcode,
])
->fields([
'revision_id' => $revision_id,
])
->execute();
}
/**
* Checks if the table exists and create it if not.
*
* @return bool
* TRUE if the table was created, FALSE otherwise.
*/
protected function ensureTableExists() {
try {
if (!$this->connection->schema()->tableExists($this->tableName)) {
$this->connection->schema()->createTable($this->tableName, $this->schemaDefinition());
return TRUE;
}
}
catch (SchemaObjectExistsException $e) {
// If another process has already created the table, attempting to
// recreate it will throw an exception. In this case just catch the
// exception and do nothing.
return TRUE;
}
return FALSE;
}
/**
* Defines the schema for the tracker table.
*
* @return array
* The schema API definition for the SQL storage table.
*/
protected function schemaDefinition() {
$schema = [
'description' => 'Tracks the latest revision for any entity',
'fields' => [
'entity_type' => [
'description' => 'The entity type',
'type' => 'varchar_ascii',
'length' => 255,
'not null' => TRUE,
'default' => '',
],
'entity_id' => [
'description' => 'The entity ID',
'type' => 'int',
'length' => 255,
'not null' => TRUE,
'default' => 0,
],
'langcode' => [
'description' => 'The language of the entity revision',
'type' => 'varchar',
'length' => 12,
'not null' => TRUE,
'default' => '',
],
'revision_id' => [
'description' => 'The latest revision ID for this entity',
'type' => 'int',
'not null' => TRUE,
'default' => 0,
],
],
'primary key' => ['entity_type', 'entity_id', 'langcode'],
];
return $schema;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Drupal\content_moderation;
/**
* Tracks metadata about revisions across content entities.
*/
interface RevisionTrackerInterface {
/**
* Sets the latest revision of a given entity.
*
* @param string $entity_type_id
* The machine name of the type of entity.
* @param string $entity_id
* The Entity ID in question.
* @param string $langcode
* The langcode of the revision we're saving. Each language has its own
* effective tree of entity revisions, so in different languages
* different revisions will be "latest".
* @param int $revision_id
* The revision ID that is now the latest revision.
*
* @return static
*/
public function setLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
}
@@ -0,0 +1,121 @@
<?php
namespace Drupal\content_moderation\Routing;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Dynamic route provider for the Content moderation module.
*
* Provides the following routes:
* - The latest version tab, showing the latest revision of an entity, not the
* default one.
*/
class EntityModerationRouteProvider implements EntityRouteProviderInterface, EntityHandlerInterface {
/**
* The entity manager.
*
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected $entityFieldManager;
/**
* Constructs a new DefaultHtmlRouteProvider.
*
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_manager
* The entity manager.
*/
public function __construct(EntityFieldManagerInterface $entity_manager) {
$this->entityFieldManager = $entity_manager;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$container->get('entity_field.manager')
);
}
/**
* {@inheritdoc}
*/
public function getRoutes(EntityTypeInterface $entity_type) {
$collection = new RouteCollection();
if ($moderation_route = $this->getLatestVersionRoute($entity_type)) {
$entity_type_id = $entity_type->id();
$collection->add("entity.{$entity_type_id}.latest_version", $moderation_route);
}
return $collection;
}
/**
* Gets the moderation-form route.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
*
* @return \Symfony\Component\Routing\Route|null
* The generated route, if available.
*/
protected function getLatestVersionRoute(EntityTypeInterface $entity_type) {
if ($entity_type->hasLinkTemplate('latest-version') && $entity_type->hasViewBuilderClass()) {
$entity_type_id = $entity_type->id();
$route = new Route($entity_type->getLinkTemplate('latest-version'));
$route
->addDefaults([
'_entity_view' => "{$entity_type_id}.full",
'_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::title',
])
// If the entity type is a node, unpublished content will be visible
// if the user has the "view all unpublished content" permission.
->setRequirement('_entity_access', "{$entity_type_id}.view")
->setRequirement('_content_moderation_latest_version', 'TRUE')
->setOption('_content_moderation_entity_type', $entity_type_id)
->setOption('parameters', [
$entity_type_id => [
'type' => 'entity:' . $entity_type_id,
'load_forward_revision' => 1,
],
]);
// Entity types with serial IDs can specify this in their route
// requirements, improving the matching process.
if ($this->getEntityTypeIdKeyType($entity_type) === 'integer') {
$route->setRequirement($entity_type_id, '\d+');
}
return $route;
}
}
/**
* Gets the type of the ID key for a given entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* An entity type.
*
* @return string|null
* The type of the ID key for a given entity type, or NULL if the entity
* type does not support fields.
*/
protected function getEntityTypeIdKeyType(EntityTypeInterface $entity_type) {
if (!$entity_type->entityClassImplements(FieldableEntityInterface::class)) {
return NULL;
}
$field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
return $field_storage_definitions[$entity_type->getKey('id')]->getType();
}
}
@@ -0,0 +1,59 @@
<?php
namespace Drupal\content_moderation\Routing;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Provides the moderation configuration routes for config entities.
*/
class EntityTypeModerationRouteProvider implements EntityRouteProviderInterface {
/**
* {@inheritdoc}
*/
public function getRoutes(EntityTypeInterface $entity_type) {
$collection = new RouteCollection();
if ($moderation_route = $this->getModerationFormRoute($entity_type)) {
$entity_type_id = $entity_type->id();
$collection->add("entity.{$entity_type_id}.moderation", $moderation_route);
}
return $collection;
}
/**
* Gets the moderation-form route.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
*
* @return \Symfony\Component\Routing\Route|null
* The generated route, if available.
*/
protected function getModerationFormRoute(EntityTypeInterface $entity_type) {
if ($entity_type->hasLinkTemplate('moderation-form') && $entity_type->getFormClass('moderation')) {
$entity_type_id = $entity_type->id();
$route = new Route($entity_type->getLinkTemplate('moderation-form'));
// @todo Come up with a new permission.
$route
->setDefaults([
'_entity_form' => "{$entity_type_id}.moderation",
'_title' => 'Moderation',
])
->setRequirement('_permission', 'administer content moderation')
->setOption('parameters', [
$entity_type_id => ['type' => 'entity:' . $entity_type_id],
]);
return $route;
}
}
}
@@ -0,0 +1,50 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\workflows\Transition;
/**
* 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->getState($entity->moderation_state->value) : $workflow->getTypePlugin()->getInitialState($workflow, $entity);
return array_filter($current_state->getTransitions(), function(Transition $transition) use ($workflow, $user) {
return $user->hasPermission('use ' . $workflow->id() . ' transition ' . $transition->id());
});
}
}
@@ -0,0 +1,26 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Validates whether a certain state transition is allowed.
*/
interface StateTransitionValidationInterface {
/**
* Gets a list of 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);
}
@@ -0,0 +1,264 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Provides the content_moderation views integration.
*/
class ViewsData {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The moderation information.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* Creates a new ViewsData instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_information) {
$this->entityTypeManager = $entity_type_manager;
$this->moderationInformation = $moderation_information;
}
/**
* Returns the views data.
*
* @return array
* The views data.
*/
public function getViewsData() {
$data = [];
$data['content_revision_tracker']['table']['group'] = $this->t('Content moderation (tracker)');
$data['content_revision_tracker']['entity_type'] = [
'title' => $this->t('Entity type'),
'field' => [
'id' => 'standard',
],
'filter' => [
'id' => 'string',
],
'argument' => [
'id' => 'string',
],
'sort' => [
'id' => 'standard',
],
];
$data['content_revision_tracker']['entity_id'] = [
'title' => $this->t('Entity ID'),
'field' => [
'id' => 'standard',
],
'filter' => [
'id' => 'numeric',
],
'argument' => [
'id' => 'numeric',
],
'sort' => [
'id' => 'standard',
],
];
$data['content_revision_tracker']['langcode'] = [
'title' => $this->t('Entity language'),
'field' => [
'id' => 'standard',
],
'filter' => [
'id' => 'language',
],
'argument' => [
'id' => 'language',
],
'sort' => [
'id' => 'standard',
],
];
$data['content_revision_tracker']['revision_id'] = [
'title' => $this->t('Latest revision ID'),
'field' => [
'id' => 'standard',
],
'filter' => [
'id' => 'numeric',
],
'argument' => [
'id' => 'numeric',
],
'sort' => [
'id' => 'standard',
],
];
$entity_types_with_moderation = array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $type) {
return $this->moderationInformation->canModerateEntitiesOfEntityType($type);
});
// Add a join for each entity type to the content_revision_tracker table.
foreach ($entity_types_with_moderation as $entity_type_id => $entity_type) {
/** @var \Drupal\views\EntityViewsDataInterface $views_data */
// We need the views_data handler in order to get the table name later.
if ($this->entityTypeManager->hasHandler($entity_type_id, 'views_data') && $views_data = $this->entityTypeManager->getHandler($entity_type_id, 'views_data')) {
// Add a join from the entity base table to the revision tracker table.
$base_table = $views_data->getViewsTableForEntityType($entity_type);
$data['content_revision_tracker']['table']['join'][$base_table] = [
'left_field' => $entity_type->getKey('id'),
'field' => 'entity_id',
'extra' => [
[
'field' => 'entity_type',
'value' => $entity_type_id,
],
],
];
// Some entity types might not be translatable.
if ($entity_type->hasKey('langcode')) {
$data['content_revision_tracker']['table']['join'][$base_table]['extra'][] = [
'field' => 'langcode',
'left_field' => $entity_type->getKey('langcode'),
'operation' => '=',
];
}
// Add a relationship between the revision tracker table to the latest
// revision on the entity revision table.
$data['content_revision_tracker']['latest_revision__' . $entity_type_id] = [
'title' => $this->t('@label latest revision', ['@label' => $entity_type->getLabel()]),
'group' => $this->t('@label revision', ['@label' => $entity_type->getLabel()]),
'relationship' => [
'id' => 'standard',
'label' => $this->t('@label latest revision', ['@label' => $entity_type->getLabel()]),
'base' => $this->getRevisionViewsTableForEntityType($entity_type),
'base field' => $entity_type->getKey('revision'),
'relationship field' => 'revision_id',
'extra' => [
[
'left_field' => 'entity_type',
'value' => $entity_type_id,
],
],
],
];
// Some entity types might not be translatable.
if ($entity_type->hasKey('langcode')) {
$data['content_revision_tracker']['latest_revision__' . $entity_type_id]['relationship']['extra'][] = [
'left_field' => 'langcode',
'field' => $entity_type->getKey('langcode'),
'operation' => '=',
];
}
}
}
// Provides a relationship from moderated entity to its moderation state
// entity.
$content_moderation_state_entity_type = \Drupal::entityTypeManager()->getDefinition('content_moderation_state');
$content_moderation_state_entity_base_table = $content_moderation_state_entity_type->getDataTable() ?: $content_moderation_state_entity_type->getBaseTable();
$content_moderation_state_entity_revision_base_table = $content_moderation_state_entity_type->getRevisionDataTable() ?: $content_moderation_state_entity_type->getRevisionTable();
foreach ($entity_types_with_moderation as $entity_type_id => $entity_type) {
$table = $entity_type->getDataTable() ?: $entity_type->getBaseTable();
$data[$table]['moderation_state'] = [
'title' => t('Moderation state'),
'relationship' => [
'id' => 'standard',
'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
'base' => $content_moderation_state_entity_base_table,
'base field' => 'content_entity_id',
'relationship field' => $entity_type->getKey('id'),
'extra' => [
[
'field' => 'content_entity_type_id',
'value' => $entity_type_id,
],
],
],
'field' => ['default_formatter' => 'content_moderation_state'],
];
$revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
$data[$revision_table]['moderation_state'] = [
'title' => t('Moderation state'),
'relationship' => [
'id' => 'standard',
'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
'base' => $content_moderation_state_entity_revision_base_table,
'base field' => 'content_entity_revision_id',
'relationship field' => $entity_type->getKey('revision'),
'extra' => [
[
'field' => 'content_entity_type_id',
'value' => $entity_type_id,
],
],
],
'field' => ['default_formatter' => 'content_moderation_state'],
];
}
return $data;
}
/**
* Alters the table and field information from hook_views_data().
*
* @param array $data
* An array of all information about Views tables and fields, collected from
* hook_views_data(), passed by reference.
*
* @see hook_views_data()
*/
public function alterViewsData(array &$data) {
$entity_types_with_moderation = array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $type) {
return $this->moderationInformation->canModerateEntitiesOfEntityType($type);
});
foreach ($entity_types_with_moderation as $type) {
$data[$type->getRevisionTable()]['latest_revision'] = [
'title' => t('Is Latest Revision'),
'help' => t('Restrict the view to only revisions that are the latest revision of their entity.'),
'filter' => ['id' => 'latest_revision'],
];
}
}
/**
* Gets the table of an entity type to be used as revision table in views.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
*
* @return string
* The revision base table.
*/
protected function getRevisionViewsTableForEntityType(EntityTypeInterface $entity_type) {
return $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
}
}
@@ -0,0 +1,8 @@
{{ attach_library('content_moderation/content_moderation') }}
<ul class="entity-moderation-form">
<li>{{ form.current }}</li>
<li>{{ form.new_state }}</li>
<li>{{ form.revision_log }}</li>
<li>{{ form.submit }}</li>
</ul>
{{ form|without('current', 'new_state', 'revision_log', 'submit') }}
@@ -0,0 +1,15 @@
name: 'Content moderation test local task'
type: module
description: 'Provides a local task for testing.'
package: Testing
# version: VERSION
# core: 8.x
dependencies:
- content_moderation
- node
# Information added by Drupal.org packaging script on 2017-08-16
version: '8.3.7'
core: '8.x'
project: 'drupal'
datestamp: 1502903957
@@ -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'
@@ -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'
@@ -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!'];
}
}
@@ -0,0 +1,407 @@
langcode: en
status: true
dependencies:
config:
- system.menu.main
module:
- content_moderation
- user
id: latest
label: Latest
module: views
description: ''
tag: ''
base_table: node_field_revision
base_field: vid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'view all revisions'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: full
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous: ' Previous'
next: 'Next '
first: '« First'
last: 'Last »'
quantity: 9
style:
type: table
row:
type: fields
fields:
nid:
id: nid
table: node_field_revision
field: nid
relationship: none
group_type: group
admin_label: ''
label: 'Node ID'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: nid
plugin_id: field
vid:
id: vid
table: node_field_revision
field: vid
relationship: none
group_type: group
admin_label: ''
label: 'Revision ID'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: vid
plugin_id: field
title:
id: title
table: node_field_revision
field: title
entity_type: node
entity_field: title
alter:
alter_text: false
make_link: false
absolute: false
trim: false
word_boundary: false
ellipsis: false
strip_tags: false
html: false
hide_empty: false
empty_zero: false
settings:
link_to_entity: false
plugin_id: field
relationship: none
group_type: group
admin_label: ''
label: Title
exclude: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_alter_empty: true
click_sort_column: value
type: string
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
moderation_state:
id: moderation_state
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: 'Moderation state'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: content_moderation_state
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
filters:
latest_revision:
id: latest_revision
table: node_revision
field: latest_revision
relationship: none
group_type: group
admin_label: ''
operator: '='
value: ''
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: latest_revision
sorts: { }
title: Latest
header: { }
footer: { }
empty: { }
relationships:
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state'
required: false
entity_type: node
plugin_id: standard
arguments: { }
display_extenders: { }
cache_metadata:
max-age: 0
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
page_1:
display_plugin: page
id: page_1
display_title: Page
position: 1
display_options:
display_extenders: { }
path: latest
menu:
type: normal
title: Drafts
description: ''
expanded: false
parent: ''
weight: 0
context: '0'
menu_name: main
cache_metadata:
max-age: 0
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
@@ -0,0 +1,401 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
- node
- user
id: test_content_moderation_base_table_test
label: test_content_moderation_base_table_test
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous: ‹‹
next: ››
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: nid
plugin_id: field
moderation_state:
id: moderation_state
table: content_moderation_state_field_data
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: content_moderation_state
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
moderation_state_1:
id: moderation_state_1
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: content_moderation_state
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
moderation_state_2:
id: moderation_state_2
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state_1
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: string
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
filters: { }
sorts:
created:
id: created
table: node_field_data
field: created
order: DESC
entity_type: node
entity_field: created
plugin_id: date
relationship: none
group_type: group
admin_label: ''
exposed: false
expose:
label: ''
granularity: second
vid:
id: vid
table: node_field_data
field: vid
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: vid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships:
moderation_state:
id: moderation_state
table: node_field_data
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state'
required: false
entity_type: node
plugin_id: standard
moderation_state_1:
id: moderation_state_1
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state (revision)'
required: false
entity_type: node
plugin_id: standard
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
@@ -0,0 +1,447 @@
langcode: en
status: true
dependencies:
module:
- node
- user
id: test_content_moderation_latest_revision
label: test_content_moderation_latest_revision
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous: ‹‹
next: ››
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: nid
plugin_id: field
revision_id:
id: revision_id
table: content_revision_tracker
field: revision_id
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
plugin_id: standard
title:
id: title
table: node_field_revision
field: title
relationship: latest_revision__node
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: string
settings:
link_to_entity: false
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: title
plugin_id: field
moderation_state:
id: moderation_state
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: string
settings: { }
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
moderation_state_1:
id: moderation_state_1
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state_1
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: string
settings: { }
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
filters: { }
sorts:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: nid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships:
latest_revision__node:
id: latest_revision__node
table: content_revision_tracker
field: latest_revision__node
relationship: none
group_type: group
admin_label: 'Content latest revision'
required: false
plugin_id: standard
moderation_state_1:
id: moderation_state_1
table: node_field_revision
field: moderation_state
relationship: latest_revision__node
group_type: group
admin_label: 'Content moderation state (latest revision)'
required: false
entity_type: node
plugin_id: standard
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state'
required: false
entity_type: node
plugin_id: standard
arguments: { }
display_extenders: { }
rendering_language: '***LANGUAGE_entity_default***'
cache_metadata:
max-age: -1
contexts:
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
@@ -0,0 +1,315 @@
langcode: en
status: true
dependencies:
module:
- user
id: test_content_moderation_revision_test
label: test_content_moderation_revision_test
module: views
description: ''
tag: ''
base_table: node_field_revision
base_field: vid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'view all revisions'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous: ‹‹
next: ››
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
vid:
id: vid
table: node_field_revision
field: vid
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: vid
plugin_id: field
moderation_state:
id: moderation_state
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: string
settings: { }
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
revision_id:
id: revision_id
table: content_moderation_state_field_revision
field: revision_id
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: revision_id
plugin_id: field
filters: { }
sorts:
vid:
id: vid
table: node_field_revision
field: vid
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: vid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships:
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state'
required: false
entity_type: node
plugin_id: standard
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
@@ -0,0 +1,16 @@
name: 'Content moderation test views'
type: module
description: 'Provides default views for views Content moderation tests.'
package: Testing
# version: VERSION
# core: 8.x
dependencies:
- content_moderation
- node
- views
# Information added by Drupal.org packaging script on 2017-08-16
version: '8.3.7'
core: '8.x'
project: 'drupal'
datestamp: 1502903957
@@ -0,0 +1,78 @@
<?php
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
*/
public static $modules = [
'content_moderation',
'node',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$admin = $this->drupalCreateUser([
'administer workflows',
]);
$this->drupalLogin($admin);
}
/**
* Test creating a new workflow using the content moderation plugin.
*/
public function testNewWorkflow() {
$entity_bundle_info = \Drupal::service('entity_type.bundle.info');
$this->drupalPostForm('admin/config/workflow/workflows/add', [
'label' => 'Test Workflow',
'id' => 'test_workflow',
'workflow_type' => 'content_moderation',
], 'Save');
// Make sure the test workflow includes the default states and transitions.
$this->assertSession()->pageTextContains('Draft');
$this->assertSession()->pageTextContains('Published');
$this->assertSession()->pageTextContains('Create New Draft');
$this->assertSession()->pageTextContains('Publish');
// 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[content_moderation][published]' => TRUE,
'type_settings[content_moderation][default_revision]' => FALSE,
], 'Save');
$this->assertSession()->pageTextContains('Created Test State state.');
// Ensure that the published settings cannot be changed.
$this->drupalGet('admin/config/workflow/workflows/manage/test_workflow/state/published');
$this->assertSession()->fieldDisabled('type_settings[content_moderation][published]');
$this->assertSession()->fieldDisabled('type_settings[content_moderation][default_revision]');
// Ensure that the draft settings cannot be changed.
$this->drupalGet('admin/config/workflow/workflows/manage/test_workflow/state/draft');
$this->assertSession()->fieldDisabled('type_settings[content_moderation][published]');
$this->assertSession()->fieldDisabled('type_settings[content_moderation][default_revision]');
}
}
@@ -0,0 +1,130 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\BrowserTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the "Latest Revision" views filter.
*
* @group content_moderation
*/
class LatestRevisionViewsFilterTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'content_moderation_test_views',
'content_moderation',
];
/**
* Tests view shows the correct node IDs.
*/
public function testViewShowsCorrectNids() {
$this->createNodeType('Test', 'test');
$permissions = [
'access content',
'view all revisions',
];
$editor1 = $this->drupalCreateUser($permissions);
$this->drupalLogin($editor1);
// Make a pre-moderation node.
/** @var Node $node_0 */
$node_0 = Node::create([
'type' => 'test',
'title' => 'Node 0 - Rev 1',
'uid' => $editor1->id(),
]);
$node_0->save();
// Now enable moderation for subsequent nodes.
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'test');
$workflow->save();
// Make a node that is only ever in Draft.
/** @var Node $node_1 */
$node_1 = Node::create([
'type' => 'test',
'title' => 'Node 1 - Rev 1',
'uid' => $editor1->id(),
]);
$node_1->moderation_state->value = 'draft';
$node_1->save();
// Make a node that is in Draft, then Published.
/** @var Node $node_2 */
$node_2 = Node::create([
'type' => 'test',
'title' => 'Node 2 - Rev 1',
'uid' => $editor1->id(),
]);
$node_2->moderation_state->value = 'draft';
$node_2->save();
$node_2->setTitle('Node 2 - Rev 2');
$node_2->moderation_state->value = 'published';
$node_2->save();
// Make a node that is in Draft, then Published, then Draft.
/** @var Node $node_3 */
$node_3 = Node::create([
'type' => 'test',
'title' => 'Node 3 - Rev 1',
'uid' => $editor1->id(),
]);
$node_3->moderation_state->value = 'draft';
$node_3->save();
$node_3->setTitle('Node 3 - Rev 2');
$node_3->moderation_state->value = 'published';
$node_3->save();
$node_3->setTitle('Node 3 - Rev 3');
$node_3->moderation_state->value = 'draft';
$node_3->save();
// Now show the View, and confirm that only the correct titles are showing.
$this->drupalGet('/latest');
$page = $this->getSession()->getPage();
$this->assertEquals(200, $this->getSession()->getStatusCode());
$this->assertTrue($page->hasContent('Node 1 - Rev 1'));
$this->assertTrue($page->hasContent('Node 2 - Rev 2'));
$this->assertTrue($page->hasContent('Node 3 - Rev 3'));
$this->assertFalse($page->hasContent('Node 2 - Rev 1'));
$this->assertFalse($page->hasContent('Node 3 - Rev 1'));
$this->assertFalse($page->hasContent('Node 3 - Rev 2'));
$this->assertFalse($page->hasContent('Node 0 - Rev 1'));
}
/**
* Creates a new node type.
*
* @param string $label
* The human-readable label of the type to create.
* @param string $machine_name
* The machine name of the type to create.
*
* @return NodeType
* The node type just created.
*/
protected function createNodeType($label, $machine_name) {
/** @var NodeType $node_type */
$node_type = NodeType::create([
'type' => $machine_name,
'label' => $label,
]);
$node_type->save();
return $node_type;
}
}
@@ -0,0 +1,137 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\Entity\Node;
use Drupal\simpletest\ContentTypeCreationTrait;
use Drupal\Tests\BrowserTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Test the content moderation actions.
*
* @group content_moderation
*/
class ModerationActionsTest extends BrowserTestBase {
use ContentTypeCreationTrait;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'content_moderation',
'node',
'views',
];
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$moderated_bundle = $this->createContentType(['type' => 'moderated_bundle']);
$moderated_bundle->save();
$standard_bundle = $this->createContentType(['type' => 'standard_bundle']);
$standard_bundle->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'moderated_bundle');
$workflow->save();
$admin = $this->drupalCreateUser([
'access content overview',
'administer nodes',
'bypass node access',
]);
$this->drupalLogin($admin);
}
/**
* Test the node status actions report moderation status to users correctly.
*
* @dataProvider nodeStatusActionsTestCases
*/
public function testNodeStatusActions($action, $bundle, $warning_appears, $starting_status, $final_status) {
// 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->drupalPostForm('admin/content', [
'node_bulk_form[0]' => TRUE,
'action' => $action,
], 'Apply to selected items');
if ($warning_appears) {
if ($action == 'node_publish_action') {
$this->assertSession()
->elementContains('css', '.messages--warning', node_get_type_label($node) . ' content items were skipped as they are under moderation and may not be directly published.');
}
else {
$this->assertSession()
->elementContains('css', '.messages--warning', node_get_type_label($node) . ' content items were skipped as they are under moderation and may not be directly unpublished.');
}
}
else {
$this->assertSession()->elementNotExists('css', '.messages--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 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,
],
];
}
}
@@ -0,0 +1,208 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the moderation form, specifically on nodes.
*
* @group content_moderation
*/
class ModerationFormTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE);
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
}
/**
* Tests the moderation form that shows on the latest version page.
*
* The latest version page only shows if there is a forward revision. There
* is only a forward revision if a draft revision is created on a node where
* the default revision is not a published moderation state.
*
* @see \Drupal\content_moderation\EntityOperations
* @see \Drupal\Tests\content_moderation\Functional\ModerationStateBlockTest::testCustomBlockModeration
*/
public function testModerationForm() {
// Create new moderated content in draft.
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
], t('Save and Create New Draft'));
$node = $this->drupalGetNodeByTitle('Some moderated content');
$canonical_path = sprintf('node/%d', $node->id());
$edit_path = sprintf('node/%d/edit', $node->id());
$latest_version_path = sprintf('node/%d/latest', $node->id());
$this->assertTrue($this->adminUser->hasPermission('edit any moderated_content content'));
// The canonical view should have a moderation form, because it is not the
// live revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertField('edit-new-state', 'The node view page has a moderation form.');
// The latest version page should not show, because there is no forward
// revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Update the draft.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Second version of the content.',
], t('Save and Create New Draft'));
// The canonical view should have a moderation form, because it is not the
// live revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertField('edit-new-state', 'The node view page has a moderation form.');
// The latest version page should not show, because there is still no
// forward revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Publish the draft.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Third version of the content.',
], t('Save and Publish'));
// The published view should not have a moderation form, because it is the
// live revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertNoField('edit-new-state', 'The node view page has no moderation form.');
// The latest version page should not show, because there is still no
// forward revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Make a forward revision.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Fourth version of the content.',
], t('Save and Create New Draft'));
// The published view should not have a moderation form, because it is the
// live revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertNoField('edit-new-state', 'The node view page has no moderation form.');
// The latest version page should show the moderation form and have "Draft"
// status, because the forward revision is in "Draft".
$this->drupalGet($latest_version_path);
$this->assertResponse(200);
$this->assertField('edit-new-state', 'The latest-version page has a moderation form.');
$this->assertText('Draft', 'Correct status found on the latest-version page.');
// Submit the moderation form to change status to published.
$this->drupalPostForm($latest_version_path, [
'new_state' => 'published',
], t('Apply'));
// The latest version page should not show, because there is no
// forward revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
}
/**
* Test moderation non-bundle entity type.
*/
public function testNonBundleModerationForm() {
$this->drupalLogin($this->rootUser);
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_mulrevpub', 'entity_test_mulrevpub');
$workflow->save();
// Create new moderated content in draft.
$this->drupalPostForm('entity_test_mulrevpub/add', [], t('Save and Create New Draft'));
// The latest version page should not show, because there is no forward
// revision.
$this->drupalGet('/entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(403);
// Update the draft.
$this->drupalPostForm('entity_test_mulrevpub/manage/1/edit', [], t('Save and Create New Draft'));
// The latest version page should not show, because there is still no
// forward revision.
$this->drupalGet('/entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(403);
// Publish the draft.
$this->drupalPostForm('entity_test_mulrevpub/manage/1/edit', [], t('Save and Publish'));
// The published view should not have a moderation form, because it is the
// default revision.
$this->drupalGet('entity_test_mulrevpub/manage/1');
$this->assertResponse(200);
$this->assertNoText('Status', 'The node view page has no moderation form.');
// The latest version page should not show, because there is still no
// forward revision.
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(403);
// Make a forward revision.
$this->drupalPostForm('entity_test_mulrevpub/manage/1/edit', [], t('Save and Create New Draft'));
// The published view should not have a moderation form, because it is the
// default revision.
$this->drupalGet('entity_test_mulrevpub/manage/1');
$this->assertResponse(200);
$this->assertNoText('Status', 'The node view page has no moderation form.');
// The latest version page should show the moderation form and have "Draft"
// status, because the forward revision is in "Draft".
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(200);
$this->assertText('Status', 'Form text found on the latest-version page.');
$this->assertText('Draft', 'Correct status found on the latest-version page.');
// Submit the moderation form to change status to published.
$this->drupalPostForm('entity_test_mulrevpub/manage/1/latest', [
'new_state' => 'published',
], t('Apply'));
// The latest version page should not show, because there is no
// forward revision.
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(403);
}
/**
* Tests the revision author is updated when the moderation form is used.
*/
public function testModerationFormSetsRevisionAuthor() {
// Create new moderated content in published.
$node = $this->createNode(['type' => 'moderated_content', 'moderation_state' => 'published']);
// Make a forward revision.
$node->title = $this->randomMachineName();
$node->moderation_state->value = 'draft';
$node->save();
$another_user = $this->drupalCreateUser($this->permissions);
$this->grantUserPermissionToCreateContentOfType($another_user, 'moderated_content');
$this->drupalLogin($another_user);
$this->drupalPostForm(sprintf('node/%d/latest', $node->id()), [
'new_state' => 'published',
], t('Apply'));
$this->drupalGet(sprintf('node/%d/revisions', $node->id()));
$this->assertText('by ' . $another_user->getAccountName());
}
}
@@ -0,0 +1,215 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
/**
* Test content_moderation functionality with localization and translation.
*
* @group content_moderation
*/
class ModerationLocaleTest extends ModerationStateTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'node',
'content_moderation',
'locale',
'content_translation',
];
/**
* Tests article translations can be moderated separately.
*/
public function testTranslateModeratedContent() {
$this->drupalLogin($this->rootUser);
// Enable moderation on Article node type.
$this->createContentTypeFromUi('Article', 'article', TRUE);
// Add French language.
$edit = [
'predefined_langcode' => 'fr',
];
$this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language'));
// Enable content translation on articles.
$this->drupalGet('admin/config/regional/content-language');
$edit = [
'entity_types[node]' => TRUE,
'settings[node][article][translatable]' => TRUE,
'settings[node][article][settings][language][language_alterable]' => TRUE,
];
$this->drupalPostForm(NULL, $edit, t('Save configuration'));
// Adding languages requires a container rebuild in the test running
// environment so that multilingual services are used.
$this->rebuildContainer();
// Create a published article in English.
$edit = [
'title[0][value]' => 'Published English node',
'langcode[0][value]' => 'en',
];
$this->drupalPostForm('node/add/article', $edit, t('Save and Publish'));
$this->assertText(t('Article Published English node has been created.'));
$english_node = $this->drupalGetNodeByTitle('Published English node');
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink(t('Add'));
$edit = [
'title[0][value]' => 'French node Draft',
];
$this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
// Here the error has occurred "The website encountered an unexpected error.
// Please try again later."
// If the translation has got lost.
$this->assertText(t('Article French node Draft has been updated.'));
// Create an article in English.
$edit = [
'title[0][value]' => 'English node',
'langcode[0][value]' => 'en',
];
$this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
$this->assertText(t('Article English node has been created.'));
$english_node = $this->drupalGetNodeByTitle('English node');
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink(t('Add'));
$edit = [
'title[0][value]' => 'French node',
];
$this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
$this->assertText(t('Article French node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('English node', TRUE);
// Publish the English article and check that the translation stays
// unpublished.
$this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
$this->assertText(t('Article English node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('English node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual('French node', $french_node->label());
$this->assertEqual($english_node->moderation_state->value, 'published');
$this->assertTrue($english_node->isPublished());
$this->assertEqual($french_node->moderation_state->value, 'draft');
$this->assertFalse($french_node->isPublished());
// Create another article with its translation. This time we will publish
// the translation first.
$edit = [
'title[0][value]' => 'Another node',
];
$this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
$this->assertText(t('Article Another node has been created.'));
$english_node = $this->drupalGetNodeByTitle('Another node');
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink(t('Add'));
$edit = [
'title[0][value]' => 'Translated node',
];
$this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
$this->assertText(t('Article Translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
// Publish the translation and check that the source language version stays
// unpublished.
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
$this->assertText(t('Article Translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->value, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($english_node->moderation_state->value, 'draft');
$this->assertFalse($english_node->isPublished());
// Now check that we can create a new draft of the translation.
$edit = [
'title[0][value]' => 'New draft of translated node',
];
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', $edit, t('Save and Create New Draft (this translation)'));
$this->assertText(t('Article New draft of translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->value, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($french_node->getTitle(), 'Translated node', 'The default revision of the published translation remains the same.');
// Publish the draft.
$edit = [
'new_state' => 'published',
];
$this->drupalPostForm('fr/node/' . $english_node->id() . '/latest', $edit, t('Apply'));
$this->assertText(t('The moderation state has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->value, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($french_node->getTitle(), 'New draft of translated node', 'The draft has replaced the published revision.');
// Publish the English article before testing the archive transition.
$this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
$this->assertText(t('Article Another node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$this->assertEqual($english_node->moderation_state->value, 'published');
// Archive the node and its translation.
$this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)'));
$this->assertText(t('Article Another node has been updated.'));
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)'));
$this->assertText(t('Article New draft of translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($english_node->moderation_state->value, 'archived');
$this->assertFalse($english_node->isPublished());
$this->assertEqual($french_node->moderation_state->value, 'archived');
$this->assertFalse($french_node->isPublished());
// Create another article with its translation. This time publishing english
// after creating a forward french revision.
$edit = [
'title[0][value]' => 'An english node',
];
$this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
$this->assertText(t('Article An english node has been created.'));
$english_node = $this->drupalGetNodeByTitle('An english node');
$this->assertFalse($english_node->isPublished());
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink(t('Add'));
$edit = [
'title[0][value]' => 'A french node',
];
$this->drupalPostForm(NULL, $edit, t('Save and Publish (this translation)'));
$english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$this->assertFalse($english_node->isPublished());
// Create a forward revision
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Create New Draft (this translation)'));
$english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$this->assertFalse($english_node->isPublished());
// Publish the english node and the default french node not the latest
// french node should be used.
$this->drupalPostForm('/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
$english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$this->assertTrue($english_node->isPublished());
}
}
@@ -0,0 +1,90 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\simpletest\ContentTypeCreationTrait;
use Drupal\Tests\BrowserTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Test revision revert.
*
* @group content_moderation
*/
class ModerationRevisionRevertTest extends BrowserTestBase {
use ContentTypeCreationTrait;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'content_moderation',
'node',
];
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$moderated_bundle = $this->createContentType(['type' => 'moderated_bundle']);
$moderated_bundle->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'moderated_bundle');
$workflow->save();
$admin = $this->drupalCreateUser([
'access content overview',
'administer nodes',
'bypass node access',
'view all revisions',
'view content moderation',
'use editorial transition create_new_draft',
'use editorial transition publish',
]);
$this->drupalLogin($admin);
}
/**
* Test that reverting a revision works.
*/
public function testEditingAfterRevertRevision() {
// Create a draft.
$this->drupalPostForm('node/add/moderated_bundle', ['title[0][value]' => 'First draft node'], t('Save and Create New Draft'));
// Now make it published.
$this->drupalPostForm('node/1/edit', ['title[0][value]' => 'Published node'], t('Save and Publish'));
// 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->click('.moderation-state-draft > input');
// 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');
}
}
@@ -0,0 +1,110 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\BrowserTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the view access control handler for moderation state entities.
*
* @group content_moderation
*/
class ModerationStateAccessTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'content_moderation_test_views',
'content_moderation',
];
/**
* Test the view operation access handler with the view permission.
*/
public function testViewShowsCorrectStates() {
$node_type_id = 'test';
$this->createNodeType('Test', $node_type_id);
$permissions = [
'access content',
'view all revisions',
'view content moderation',
];
$editor1 = $this->drupalCreateUser($permissions);
$this->drupalLogin($editor1);
$node_1 = Node::create([
'type' => $node_type_id,
'title' => 'Draft node',
'uid' => $editor1->id(),
]);
$node_1->moderation_state->value = 'draft';
$node_1->save();
$node_2 = Node::create([
'type' => $node_type_id,
'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',
'administer content moderation',
];
$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'));
}
/**
* Creates a new node type.
*
* @param string $label
* The human-readable label of the type to create.
* @param string $machine_name
* The machine name of the type to create.
*
* @return NodeType
* The node type just created.
*/
protected function createNodeType($label, $machine_name) {
/** @var NodeType $node_type */
$node_type = NodeType::create([
'type' => $machine_name,
'label' => $label,
]);
$node_type->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', $machine_name);
$workflow->save();
return $node_type;
}
}
@@ -0,0 +1,133 @@
<?php
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}
*/
protected function setUp() {
parent::setUp();
// Create the "basic" block type.
$bundle = BlockContentType::create([
'id' => 'basic',
'label' => 'basic',
'revision' => FALSE,
]);
$bundle->save();
// Add the body field to it.
block_content_add_body_field($bundle->id());
}
/**
* Tests moderating custom blocks.
*
* Blocks and any non-node-type-entities do not have a concept of
* "published". As such, we must use the "default revision" to know what is
* going to be "published", i.e. visible to the user.
*
* The one exception is a block that has never been "published". When a block
* is first created, it becomes the "default revision". For each edit of the
* block after that, Content Moderation checks the "default revision" to
* see if it is set to a published moderation state. If it is not, the entity
* being saved will become the "default revision".
*
* The test below is intended, in part, to make this behavior clear.
*
* @see \Drupal\content_moderation\EntityOperations::entityPresave
* @see \Drupal\content_moderation\Tests\ModerationFormTest::testModerationForm
*/
public function testCustomBlockModeration() {
$this->drupalLogin($this->rootUser);
$this->drupalGet('admin/structure/block/block-content/types');
$this->assertLinkByHref('admin/structure/block/block-content/manage/basic/moderation');
$this->drupalGet('admin/structure/block/block-content/manage/basic');
$this->assertLinkByHref('admin/structure/block/block-content/manage/basic/moderation');
$this->drupalGet('admin/structure/block/block-content/manage/basic/moderation');
// Enable moderation for custom blocks at
// admin/structure/block/block-content/manage/basic/moderation.
$edit = ['workflow' => 'editorial'];
$this->drupalPostForm(NULL, $edit, t('Save'));
$this->assertText(t('Your settings have been saved.'));
// Create a custom block at block/add and save it as draft.
$body = 'Body of moderated block';
$edit = [
'info[0][value]' => 'Moderated block',
'body[0][value]' => $body,
];
$this->drupalPostForm('block/add', $edit, t('Save and Create New Draft'));
$this->assertText(t('basic Moderated block has been created.'));
// Place the block in the Sidebar First region.
$instance = [
'id' => 'moderated_block',
'settings[label]' => $edit['info[0][value]'],
'region' => 'sidebar_first',
];
$block = BlockContent::load(1);
$url = 'admin/structure/block/add/block_content:' . $block->uuid() . '/' . $this->config('system.theme')->get('default');
$this->drupalPostForm($url, $instance, t('Save block'));
// Navigate to home page and check that the block is visible. It should be
// visible because it is the default revision.
$this->drupalGet('');
$this->assertText($body);
// Update the block.
$updated_body = 'This is the new body value';
$edit = [
'body[0][value]' => $updated_body,
];
$this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft'));
$this->assertText(t('basic Moderated block has been updated.'));
// Navigate to the home page and check that the block shows the updated
// content. It should show the updated content because the block's default
// revision is not a published moderation state.
$this->drupalGet('');
$this->assertText($updated_body);
// Publish the block so we can create a forward revision.
$this->drupalPostForm('block/' . $block->id(), [], t('Save and Publish'));
// Create a forward revision.
$forward_revision_body = 'This is the forward revision body value';
$edit = [
'body[0][value]' => $forward_revision_body,
];
$this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft'));
$this->assertText(t('basic Moderated block has been updated.'));
// Navigate to home page and check that the forward revision doesn't show,
// since it should not be set as the default revision.
$this->drupalGet('');
$this->assertText($updated_body);
// Open the latest tab and publish the new draft.
$edit = [
'new_state' => 'published',
];
$this->drupalPostForm('block/' . $block->id() . '/latest', $edit, t('Apply'));
$this->assertText(t('The moderation state has been updated.'));
// Navigate to home page and check that the forward revision is now the
// default revision and therefore visible.
$this->drupalGet('');
$this->assertText($forward_revision_body);
}
}
@@ -0,0 +1,141 @@
<?php
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
*/
class ModerationStateNodeTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
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() {
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'moderated content',
], t('Save and Create New Draft'));
$node = $this->getNodeByTitle('moderated content');
if (!$node) {
$this->fail('Test node was not saved correctly.');
}
$this->assertEqual('draft', $node->moderation_state->value);
$path = 'node/' . $node->id() . '/edit';
// Set up published revision.
$this->drupalPostForm($path, [], t('Save and Publish'));
\Drupal::entityTypeManager()->getStorage('node')->resetCache([$node->id()]);
/* @var \Drupal\node\NodeInterface $node */
$node = \Drupal::entityTypeManager()->getStorage('node')->load($node->id());
$this->assertTrue($node->isPublished());
$this->assertEqual('published', $node->moderation_state->value);
// Verify that the state field is not shown.
$this->assertNoText('Published');
// Delete the node.
$this->drupalPostForm('node/' . $node->id() . '/delete', [], t('Delete'));
$this->assertText(t('The Moderated content moderated content has been deleted.'));
// Disable content moderation.
$this->drupalPostForm('admin/structure/types/manage/moderated_content/moderation', ['workflow' => ''], t('Save'));
$this->drupalGet('admin/structure/types/manage/moderated_content/moderation');
$this->assertOptionSelected('edit-workflow', '');
// 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->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'non-moderated content',
], t('Save and publish'));
$node = $this->getNodeByTitle('non-moderated content');
if (!$node) {
$this->fail('Non-moderated test node was not saved correctly.');
}
$this->assertEqual(NULL, $node->moderation_state->value);
}
/**
* Tests edit form destinations.
*/
public function testFormSaveDestination() {
// Create new moderated content in draft.
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
], t('Save and Create New Draft'));
$node = $this->drupalGetNodeByTitle('Some moderated content');
$edit_path = sprintf('node/%d/edit', $node->id());
// After saving, we should be at the canonical URL and viewing the first
// revision.
$this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertText('First version of the content.');
// Create a new draft; after saving, we should still be on the canonical
// URL, but viewing the second revision.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Second version of the content.',
], t('Save and Create New Draft'));
$this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertText('Second version of the content.');
// Make a new published revision; after saving, we should be at the
// canonical URL.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Third version of the content.',
], t('Save and Publish'));
$this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertText('Third version of the content.');
// Make a new forward revision; after saving, we should be on the "Latest
// version" tab.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Fourth version of the content.',
], t('Save and Create New Draft'));
$this->assertUrl(Url::fromRoute('entity.node.latest_version', ['node' => $node->id()]));
$this->assertText('Fourth version of the content.');
}
/**
* Tests pagers aren't broken by content_moderation.
*/
public function testPagers() {
// Create 51 nodes to force the pager.
foreach (range(1, 51) as $delta) {
Node::create([
'type' => 'moderated_content',
'uid' => $this->adminUser->id(),
'title' => 'Node ' . $delta,
'status' => 1,
'moderation_state' => 'published',
])->save();
}
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/content');
$element = $this->cssSelect('nav.pager li.is-active a');
$url = $element[0]->getAttribute('href');
$query = [];
parse_str(parse_url($url, PHP_URL_QUERY), $query);
$this->assertEqual(0, $query['page']);
}
}
@@ -0,0 +1,94 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
/**
* Tests moderation state node type integration.
*
* @group content_moderation
*/
class ModerationStateNodeTypeTest extends ModerationStateTestBase {
/**
* A node type without moderation state disabled.
*/
public function testNotModerated() {
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Not moderated', 'not_moderated');
$this->assertText('The content type Not moderated has been added.');
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated');
$this->drupalGet('node/add/not_moderated');
$this->assertRaw('Save as unpublished');
$this->drupalPostForm(NULL, [
'title[0][value]' => 'Test',
], t('Save and publish'));
$this->assertText('Not moderated Test has been created.');
}
/**
* Tests enabling moderation on an existing node-type, with content.
*/
public function testEnablingOnExistingContent() {
$editor_permissions = [
'administer content moderation',
'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->drupalPostForm(NULL, [
'title[0][value]' => 'Test',
], t('Save and publish'));
$this->assertText('Not moderated Test has been created.');
// Now enable moderation state, ensuring all the expected links and tabs are
// present.
$this->drupalGet('admin/structure/types');
$this->assertLinkByHref('admin/structure/types/manage/not_moderated/moderation');
$this->drupalGet('admin/structure/types/manage/not_moderated');
$this->assertLinkByHref('admin/structure/types/manage/not_moderated/moderation');
$this->drupalGet('admin/structure/types/manage/not_moderated/moderation');
$this->assertOptionSelected('edit-workflow', '');
$this->assertNoLink('Delete');
$edit['workflow'] = 'editorial';
$this->drupalPostForm(NULL, $edit, t('Save'));
// And make sure it works.
$nodes = \Drupal::entityTypeManager()->getStorage('node')
->loadByProperties(['title' => 'Test']);
if (empty($nodes)) {
$this->fail('Could not load node with title Test');
return;
}
$node = reset($nodes);
$this->drupalGet('node/' . $node->id());
$this->assertResponse(200);
$this->assertLinkByHref('node/' . $node->id() . '/edit');
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertResponse(200);
$this->assertRaw('Save and Create New Draft');
$this->assertNoRaw('Save and Publish');
$this->drupalLogin($editor_with_publish);
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertResponse(200);
$this->assertRaw('Save and Create New Draft');
$this->assertRaw('Save and Publish');
}
}
@@ -0,0 +1,144 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Core\Session\AccountInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
/**
* Defines a base class for moderation state tests.
*/
abstract class ModerationStateTestBase extends BrowserTestBase {
/**
* Profile to use.
*/
protected $profile = 'testing';
/**
* Admin user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* Permissions to grant admin user.
*
* @var array
*/
protected $permissions = [
'administer content moderation',
'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',
];
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'content_moderation',
'block',
'block_content',
'node',
'entity_test',
];
/**
* Sets the test up.
*/
protected function setUp() {
parent::setUp();
$this->adminUser = $this->drupalCreateUser($this->permissions);
$this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']);
$this->drupalPlaceBlock('page_title_block');
$this->drupalPlaceBlock('local_actions_block', ['id' => 'actions_block']);
}
/**
* 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->drupalPostForm(NULL, $edit, t('Save content type'));
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.
*/
protected function enableModerationThroughUi($content_type_id, $workflow_id = 'editorial') {
$edit['workflow'] = $workflow_id;
$this->drupalPostForm('admin/structure/types/manage/' . $content_type_id . '/moderation', $edit, t('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();
}
/**
* 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();
}
}
@@ -0,0 +1,138 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
/**
* Tests permission access control around nodes.
*
* @group content_moderation
*/
class NodeAccessTest extends ModerationStateTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'content_moderation',
'block',
'block_content',
'node',
'node_access_test_empty',
];
/**
* Permissions to grant admin user.
*
* @var array
*/
protected $permissions = [
'administer content moderation',
'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() {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE);
$this->grantUserPermissionToCreateContentOfType($this->adminUser, '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() {
$this->drupalLogin($this->adminUser);
// Create a node to test with.
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'moderated content',
], t('Save and Create New Draft'));
$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->assertResponse(403);
$this->drupalGet($latest_path);
$this->assertResponse(403);
$this->drupalGet($view_path);
$this->assertResponse(200);
// Publish the node.
$this->drupalLogin($this->adminUser);
$this->drupalPostForm($edit_path, [], t('Save and Publish'));
// Ensure access works correctly for anonymous users.
$this->drupalLogout();
$this->drupalGet($edit_path);
$this->assertResponse(403);
$this->drupalGet($latest_path);
$this->assertResponse(403);
$this->drupalGet($view_path);
$this->assertResponse(200);
// Create a forward revision for the 'Latest revision' tab.
$this->drupalLogin($this->adminUser);
$this->drupalPostForm($edit_path, [
'title[0][value]' => 'moderated content revised',
], t('Save and Create New Draft'));
$this->drupalLogin($user);
$this->drupalGet($edit_path);
$this->assertResponse(403);
$this->drupalGet($latest_path);
$this->assertResponse(200);
$this->drupalGet($view_path);
$this->assertResponse(200);
// Now make another user, who should not be able to see forward revisions.
$user = $this->createUser([
'use editorial transition create_new_draft',
]);
$this->drupalLogin($user);
$this->drupalGet($edit_path);
$this->assertResponse(403);
$this->drupalGet($latest_path);
$this->assertResponse(403);
$this->drupalGet($view_path);
$this->assertResponse(200);
}
}
@@ -0,0 +1,121 @@
<?php
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
*/
public static $modules = [
'workflows',
'content_moderation',
'workflow_type_test',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('workflow');
}
/**
* Test permissions generated by content moderation.
*
* @dataProvider permissionsTestCases
*/
public function testPermissions($workflow, $permissions) {
Workflow::create($workflow)->save();
$this->assertEquals($permissions, (new Permissions())->transitionPermissions());
}
/**
* Test cases for ::testPermissions
*
* @return array
* Content moderation permissions based test cases.
*/
public function permissionsTestCases() {
return [
'Simple Content Moderation Workflow' => [
[
'id' => 'simple_workflow',
'label' => 'Simple Workflow',
'type' => 'content_moderation',
'transitions' => [
'publish' => [
'label' => 'Publish',
'from' => ['draft'],
'to' => 'published',
'weight' => 0,
],
'unpublish' => [
'label' => 'Unpublish',
'from' => ['published'],
'to' => 'draft',
'weight' => 0,
],
],
'states' => [
'draft' => [
'label' => 'Draft',
'weight' => -5,
],
'published' => [
'label' => 'Published',
'weight' => 0,
],
],
],
[
'use simple_workflow transition publish' => [
'title' => 'Use <em class="placeholder">Publish</em> transition from <em class="placeholder">Simple Workflow</em> workflow.',
],
'use simple_workflow transition unpublish' => [
'title' => 'Use <em class="placeholder">Unpublish</em> transition from <em class="placeholder">Simple Workflow</em> 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,
],
],
],
[]
],
];
}
}
@@ -0,0 +1,54 @@
<?php
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
*/
public static $modules = [
'content_moderation',
'workflows',
'user',
];
/**
* The content_moderation_state access control handler.
*
* @var \Drupal\Core\Entity\EntityAccessControlHandlerInterface
*/
protected $accessControlHandler;
/**
* {@inheritdoc}
*/
protected function setUp() {
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() {
$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());
}
}
@@ -0,0 +1,142 @@
<?php
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\workflows\Entity\Workflow;
/**
* Test the ContentModerationState storage schema.
*
* @coversDefaultClass \Drupal\content_moderation\ContentModerationStateStorageSchema
* @group content_moderation
*/
class ContentModerationStateStorageSchemaTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'content_moderation',
'user',
'system',
'text',
'workflows',
'entity_test',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
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',
])->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
}
/**
* Test the ContentModerationState unique keys.
*
* @covers ::getEntitySchema
*/
public function testUniqueKeys() {
// 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.
*/
protected function assertStorageException(array $values, $has_exception) {
$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);
}
}
@@ -0,0 +1,463 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\content_moderation\Entity\ContentModerationState;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\entity_test\Entity\EntityTestBundle;
use Drupal\entity_test\Entity\EntityTestWithBundle;
use Drupal\Core\Entity\EntityInterface;
use Drupal\KernelTests\KernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\workflows\Entity\Workflow;
/**
* Tests links between a content entity and a content_moderation_state entity.
*
* @group content_moderation
*/
class ContentModerationStateTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'entity_test',
'node',
'block_content',
'content_moderation',
'user',
'system',
'language',
'content_translation',
'text',
'workflows',
];
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('entity_test_with_bundle');
$this->installEntitySchema('entity_test_rev');
$this->installEntitySchema('entity_test_no_bundle');
$this->installEntitySchema('entity_test_mulrevpub');
$this->installEntitySchema('block_content');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$this->entityTypeManager = $this->container->get('entity_type.manager');
}
/**
* Tests basic monolingual content moderation through the API.
*
* @dataProvider basicModerationTestCases
*/
public function testBasicModeration($entity_type_id) {
// Make the 'entity_test_with_bundle' entity type revisionable.
if ($entity_type_id == 'entity_test_with_bundle') {
$this->setEntityTestWithBundleKeys(['revision' => 'revision_id']);
}
$entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
$bundle_id = $entity_type_id;
$bundle_entity_type_id = $this->entityTypeManager->getDefinition($entity_type_id)->getBundleEntityType();
if ($bundle_entity_type_id) {
$bundle_entity_type_definition = $this->entityTypeManager->getDefinition($bundle_entity_type_id);
$entity_type_storage = $this->entityTypeManager->getStorage($bundle_entity_type_id);
$entity_type = $entity_type_storage->create([
$bundle_entity_type_definition->getKey('id') => 'example',
]);
$entity_type->save();
$bundle_id = $entity_type->id();
}
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle($entity_type_id, $bundle_id);
$workflow->save();
$entity = $entity_storage->create([
'title' => 'Test title',
$this->entityTypeManager->getDefinition($entity_type_id)->getKey('bundle') => $bundle_id,
]);
if ($entity instanceof EntityPublishedInterface) {
$entity->setUnpublished();
}
$entity->save();
$entity = $this->reloadEntity($entity);
$this->assertEquals('draft', $entity->moderation_state->value);
$entity->moderation_state->value = 'published';
$entity->save();
$entity = $this->reloadEntity($entity);
$this->assertEquals('published', $entity->moderation_state->value);
// Change the state without saving the node.
$content_moderation_state = ContentModerationState::load(1);
$content_moderation_state->set('moderation_state', 'draft');
$content_moderation_state->setNewRevision(TRUE);
$content_moderation_state->save();
$entity = $this->reloadEntity($entity, 3);
$this->assertEquals('draft', $entity->moderation_state->value);
if ($entity instanceof EntityPublishedInterface) {
$this->assertFalse($entity->isPublished());
}
// Get the default revision.
$entity = $this->reloadEntity($entity);
if ($entity instanceof EntityPublishedInterface) {
$this->assertTrue((bool) $entity->isPublished());
}
$this->assertEquals(2, $entity->getRevisionId());
$entity->moderation_state->value = 'published';
$entity->save();
$entity = $this->reloadEntity($entity, 4);
$this->assertEquals('published', $entity->moderation_state->value);
// Get the default revision.
$entity = $this->reloadEntity($entity);
if ($entity instanceof EntityPublishedInterface) {
$this->assertTrue((bool) $entity->isPublished());
}
$this->assertEquals(4, $entity->getRevisionId());
// Update the node to archived which will then be the default revision.
$entity->moderation_state->value = 'archived';
$entity->save();
// Revert to the previous (published) revision.
$previous_revision = $entity_storage->loadRevision(4);
$previous_revision->isDefaultRevision(TRUE);
$previous_revision->setNewRevision(TRUE);
$previous_revision->save();
// Get the default revision.
$entity = $this->reloadEntity($entity);
$this->assertEquals('published', $entity->moderation_state->value);
if ($entity instanceof EntityPublishedInterface) {
$this->assertTrue($entity->isPublished());
}
// Set an invalid moderation state.
$this->setExpectedException(EntityStorageException::class);
$entity->moderation_state->value = 'foobar';
$entity->save();
}
/**
* Test cases for basic moderation test.
*/
public function basicModerationTestCases() {
return [
'Nodes' => [
'node',
],
'Block content' => [
'block_content',
],
'Test Entity with Bundle' => [
'entity_test_with_bundle',
],
'Test entity - revisions, data table, and published interface' => [
'entity_test_mulrevpub',
],
'Entity Test with revisions' => [
'entity_test_rev',
],
'Entity without bundle' => [
'entity_test_no_bundle',
],
];
}
/**
* Tests basic multilingual content moderation through the API.
*/
public function testMultilingualModeration() {
// Enable French.
ConfigurableLanguage::createFromLangcode('fr')->save();
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$english_node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
// Revision 1 (en).
$english_node
->setUnpublished()
->save();
$this->assertEquals('draft', $english_node->moderation_state->value);
$this->assertFalse($english_node->isPublished());
// Create a French translation.
$french_node = $english_node->addTranslation('fr', ['title' => 'French title']);
$french_node->setUnpublished();
// Revision 1 (fr).
$french_node->save();
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertEquals('draft', $french_node->moderation_state->value);
$this->assertFalse($french_node->isPublished());
// Move English node to create another draft.
$english_node = $this->reloadEntity($english_node);
$english_node->moderation_state->value = 'draft';
// Revision 2 (en, fr).
$english_node->save();
$english_node = $this->reloadEntity($english_node);
$this->assertEquals('draft', $english_node->moderation_state->value);
// French node should still be in draft.
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertEquals('draft', $french_node->moderation_state->value);
// Publish the French node.
$french_node->moderation_state->value = 'published';
// Revision 3 (en, fr).
$french_node->save();
$french_node = $this->reloadEntity($french_node)->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$this->assertEquals('published', $french_node->moderation_state->value);
$this->assertTrue($french_node->isPublished());
$english_node = $french_node->getTranslation('en');
$this->assertEquals('draft', $english_node->moderation_state->value);
// Publish the English node.
$english_node->moderation_state->value = 'published';
// Revision 4 (en, fr).
$english_node->save();
$english_node = $this->reloadEntity($english_node);
$this->assertTrue($english_node->isPublished());
// Move the French node back to draft.
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$french_node->moderation_state->value = 'draft';
// Revision 5 (en, fr).
$french_node->save();
$french_node = $this->reloadEntity($english_node, 5)->getTranslation('fr');
$this->assertFalse($french_node->isPublished());
$this->assertTrue($french_node->getTranslation('en')->isPublished());
// Republish the French node.
$french_node->moderation_state->value = 'published';
// Revision 6 (en, fr).
$french_node->save();
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
// Change the EN state without saving the node.
$content_moderation_state = ContentModerationState::load(1);
$content_moderation_state->set('moderation_state', 'draft');
$content_moderation_state->setNewRevision(TRUE);
// Revision 7 (en, fr).
$content_moderation_state->save();
$english_node = $this->reloadEntity($french_node, $french_node->getRevisionId() + 1);
$this->assertEquals('draft', $english_node->moderation_state->value);
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertEquals('published', $french_node->moderation_state->value);
// This should unpublish the French node.
$content_moderation_state = ContentModerationState::load(1);
$content_moderation_state = $content_moderation_state->getTranslation('fr');
$content_moderation_state->set('moderation_state', 'draft');
$content_moderation_state->setNewRevision(TRUE);
// Revision 8 (en, fr).
$content_moderation_state->save();
$english_node = $this->reloadEntity($english_node, $english_node->getRevisionId());
$this->assertEquals('draft', $english_node->moderation_state->value);
$french_node = $this->reloadEntity($english_node, '8')->getTranslation('fr');
$this->assertEquals('draft', $french_node->moderation_state->value);
// Switching the moderation state to an unpublished state should update the
// entity.
$this->assertFalse($french_node->isPublished());
// Get the default english node.
$english_node = $this->reloadEntity($english_node);
$this->assertTrue($english_node->isPublished());
$this->assertEquals(6, $english_node->getRevisionId());
}
/**
* Tests that a non-translatable entity type with a langcode can be moderated.
*/
public function testNonTranslatableEntityTypeModeration() {
// Make the 'entity_test_with_bundle' entity type revisionable.
$this->setEntityTestWithBundleKeys(['revision' => 'revision_id']);
// Create a test bundle.
$entity_test_bundle = EntityTestBundle::create([
'id' => 'example',
]);
$entity_test_bundle->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_with_bundle', 'example');
$workflow->save();
// Check that the tested entity type is not translatable.
$entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_with_bundle');
$this->assertFalse($entity_type->isTranslatable(), 'The test entity type is not translatable.');
// Create a test entity.
$entity_test_with_bundle = EntityTestWithBundle::create([
'type' => 'example'
]);
$entity_test_with_bundle->save();
$this->assertEquals('draft', $entity_test_with_bundle->moderation_state->value);
$entity_test_with_bundle->moderation_state->value = 'published';
$entity_test_with_bundle->save();
$this->assertEquals('published', EntityTestWithBundle::load($entity_test_with_bundle->id())->moderation_state->value);
}
/**
* Tests that a non-translatable entity type without a langcode can be
* moderated.
*/
public function testNonLangcodeEntityTypeModeration() {
// Make the 'entity_test_with_bundle' entity type revisionable and unset
// the langcode entity key.
$this->setEntityTestWithBundleKeys(['revision' => 'revision_id'], ['langcode']);
// Create a test bundle.
$entity_test_bundle = EntityTestBundle::create([
'id' => 'example',
]);
$entity_test_bundle->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_with_bundle', 'example');
$workflow->save();
// Check that the tested entity type is not translatable.
$entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_with_bundle');
$this->assertFalse($entity_type->isTranslatable(), 'The test entity type is not translatable.');
// Create a test entity.
$entity_test_with_bundle = EntityTestWithBundle::create([
'type' => 'example'
]);
$entity_test_with_bundle->save();
$this->assertEquals('draft', $entity_test_with_bundle->moderation_state->value);
$entity_test_with_bundle->moderation_state->value = 'published';
$entity_test_with_bundle->save();
$this->assertEquals('published', EntityTestWithBundle::load($entity_test_with_bundle->id())->moderation_state->value);
}
/**
* Set the keys on the test entity type.
*
* @param array $keys
* The entity keys to override
* @param array $remove_keys
* Keys to remove.
*/
protected function setEntityTestWithBundleKeys($keys, $remove_keys = []) {
$entity_type = clone \Drupal::entityTypeManager()->getDefinition('entity_test_with_bundle');
$original_keys = $entity_type->getKeys();
foreach ($remove_keys as $remove_key) {
unset($original_keys[$remove_key]);
}
$entity_type->set('entity_keys', $keys + $original_keys);
\Drupal::state()->set('entity_test_with_bundle.entity_type', $entity_type);
\Drupal::entityDefinitionUpdateManager()->applyUpdates();
}
/**
* Tests the dependencies of the workflow when using content moderation.
*/
public function testWorkflowDependencies() {
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = Workflow::load('editorial');
// Test both a config and non-config based bundle and entity type.
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_no_bundle', 'entity_test_no_bundle');
$workflow->save();
$this->assertEquals([
'module' => [
'content_moderation',
'entity_test',
],
'config' => [
'node.type.example',
],
], $workflow->getDependencies());
$this->assertEquals([
'entity_test_no_bundle',
'entity_test_rev',
'node'
], $workflow->getTypePlugin()->getEntityTypes());
// Delete the node type and ensure it is removed from the workflow.
$node_type->delete();
$workflow = Workflow::load('editorial');
$entity_types = $workflow->getTypePlugin()->getEntityTypes();
$this->assertFalse(in_array('node', $entity_types));
// Uninstall entity test and ensure it's removed from the workflow.
$this->container->get('config.manager')->uninstall('module', 'entity_test');
$workflow = Workflow::load('editorial');
$entity_types = $workflow->getTypePlugin()->getEntityTypes();
$this->assertEquals([], $entity_types);
}
/**
* Reloads the entity after clearing the static cache.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to reload.
* @param int|bool $revision_id
* The specific revision ID to load. Defaults FALSE and just loads the
* default revision.
*
* @return \Drupal\Core\Entity\EntityInterface
* The reloaded entity.
*/
protected function reloadEntity(EntityInterface $entity, $revision_id = FALSE) {
$storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId());
$storage->resetCache([$entity->id()]);
if ($revision_id) {
return $storage->loadRevision($revision_id);
}
return $storage->load($entity->id());
}
}
@@ -0,0 +1,104 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the API of the ContentModeration workflow type plugin.
*
* @group content_moderation
*
* @coversDefaultClass \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration
*/
class ContentModerationWorkflowTypeApiTest extends KernelTestBase {
/**
* A workflow for testing.
*
* @var \Drupal\workflows\Entity\Workflow;
*/
protected $workflow;
/**
* Modules to install.
*
* @var array
*/
public static $modules = [
'workflows',
'content_moderation',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->workflow = Workflow::create(['id' => 'test', 'type' => 'content_moderation']);
$this->workflow
->addState('draft', 'Draft')
->addState('published', 'Published');
}
/**
* @covers ::getBundlesForEntityType
* @covers ::addEntityTypeAndBundle
* @covers ::removeEntityTypeAndBundle
*/
public function testGetBundlesForEntityType() {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $workflow_plugin */
$workflow_plugin = $this->workflow->getTypePlugin();
// The content moderation plugin does not validate the existence of the
// entity type or bundle.
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_node'));
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertEquals(['fake_page'], $workflow_plugin->getBundlesForEntityType('fake_node'));
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_block'));
$workflow_plugin->removeEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_node'));
}
/**
* @covers ::appliesToEntityTypeAndBundle
* @covers ::addEntityTypeAndBundle
* @covers ::removeEntityTypeAndBundle
*/
public function testAppliesToEntityTypeAndBundle() {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $workflow_plugin */
$workflow_plugin = $this->workflow->getTypePlugin();
// The content moderation plugin does not validate the existence of the
// entity type or bundle.
$this->assertFalse($workflow_plugin->appliesToEntityTypeAndBundle('fake_node', 'fake_page'));
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertTrue($workflow_plugin->appliesToEntityTypeAndBundle('fake_node', 'fake_page'));
$this->assertFalse($workflow_plugin->appliesToEntityTypeAndBundle('fake_block', 'fake_custom'));
$workflow_plugin->removeEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertFalse($workflow_plugin->appliesToEntityTypeAndBundle('fake_node', 'fake_page'));
}
/**
* @covers ::addEntityTypeAndBundle
*/
public function testAddEntityTypeAndBundle() {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $workflow_plugin */
$workflow_plugin = $this->workflow->getTypePlugin();
// The bundles are intentionally added in reverse alphabetical order.
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_page');
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_article');
// Add another entity type that comes alphabetically before 'fake_node'.
$workflow_plugin->addEntityTypeAndBundle('fake_block', 'fake_custom');
// The entity type keys and bundle values should be sorted alphabetically.
// The bundle array index should not reflect the order in which they are
// added.
$this->assertSame(
['fake_block' => ['fake_custom'], 'fake_node' => ['fake_article', 'fake_page']],
$workflow_plugin->getConfiguration()['entity_types']
);
}
}
@@ -0,0 +1,109 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the correct default revision is set.
*
* @group content_moderation
*/
class DefaultRevisionStateTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'entity_test',
'node',
'block_content',
'content_moderation',
'user',
'system',
'language',
'content_translation',
'text',
'workflows',
];
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('entity_test_with_bundle');
$this->installEntitySchema('entity_test_rev');
$this->installEntitySchema('entity_test_mulrevpub');
$this->installEntitySchema('block_content');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$this->entityTypeManager = $this->container->get('entity_type.manager');
}
/**
* Tests a translatable Node.
*/
public function testMultilingual() {
// Enable French.
ConfigurableLanguage::createFromLangcode('fr')->save();
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$this->container->get('content_translation.manager')->setEnabled('node', 'example', TRUE);
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$english_node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
// Revision 1 (en).
$english_node
->setUnpublished()
->save();
$this->assertEquals('draft', $english_node->moderation_state->value);
$this->assertFalse($english_node->isPublished());
$this->assertTrue($english_node->isDefaultRevision());
// Revision 2 (fr)
$french_node = $english_node->addTranslation('fr', ['title' => 'French title']);
$french_node->moderation_state->value = 'published';
$french_node->save();
$this->assertTrue($french_node->isPublished());
$this->assertTrue($french_node->isDefaultRevision());
// Revision 3 (fr)
$node = Node::load($english_node->id())->getTranslation('fr');
$node->moderation_state->value = 'draft';
$node->save();
$this->assertFalse($node->isPublished());
$this->assertFalse($node->isDefaultRevision());
// Revision 4 (en)
$latest_revision = $this->entityTypeManager->getStorage('node')->loadRevision(3);
$latest_revision->moderation_state->value = 'draft';
$latest_revision->save();
$this->assertFalse($latest_revision->isPublished());
$this->assertFalse($latest_revision->isDefaultRevision());
}
}
@@ -0,0 +1,183 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\workflows\Entity\Workflow;
/**
* @coversDefaultClass \Drupal\content_moderation\EntityOperations
*
* @group content_moderation
*/
class EntityOperationsTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'content_moderation',
'node',
'user',
'system',
'workflows',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('node');
$this->installSchema('node', 'node_access');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$this->createNodeType();
}
/**
* Creates a page node type to test with, ensuring that it's moderated.
*/
protected function createNodeType() {
$node_type = NodeType::create([
'type' => 'page',
'label' => 'Page',
]);
$node_type->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'page');
$workflow->save();
}
/**
* Verifies that the process of saving forward-revisions works as expected.
*/
public function testForwardRevisions() {
// Create a new node in draft.
$page = Node::create([
'type' => 'page',
'title' => 'A',
]);
$page->moderation_state->value = 'draft';
$page->save();
$id = $page->id();
// Verify the entity saved correctly, and that the presence of forward
// revisions doesn't affect the default node load.
/** @var Node $page */
$page = Node::load($id);
$this->assertEquals('A', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertFalse($page->isPublished());
// Moderate the entity to published.
$page->setTitle('B');
$page->moderation_state->value = 'published';
$page->save();
// Verify the entity is now published and public.
$page = Node::load($id);
$this->assertEquals('B', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertTrue($page->isPublished());
// Make a new forward-revision in Draft.
$page->setTitle('C');
$page->moderation_state->value = 'draft';
$page->save();
// Verify normal loads return the still-default previous version.
$page = Node::load($id);
$this->assertEquals('B', $page->getTitle());
// Verify we can load the forward revision, even if the mechanism is kind
// of gross. Note: revisionIds() is only available on NodeStorageInterface,
// so this won't work for non-nodes. We'd need to use entity queries. This
// is a core bug that should get fixed.
$storage = \Drupal::entityTypeManager()->getStorage('node');
$revision_ids = $storage->revisionIds($page);
sort($revision_ids);
$latest = end($revision_ids);
$page = $storage->loadRevision($latest);
$this->assertEquals('C', $page->getTitle());
$page->setTitle('D');
$page->moderation_state->value = 'published';
$page->save();
// Verify normal loads return the still-default previous version.
$page = Node::load($id);
$this->assertEquals('D', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertTrue($page->isPublished());
// Now check that we can immediately add a new published revision over it.
$page->setTitle('E');
$page->moderation_state->value = 'published';
$page->save();
$page = Node::load($id);
$this->assertEquals('E', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertTrue($page->isPublished());
}
/**
* Verifies that a newly-created node can go straight to published.
*/
public function testPublishedCreation() {
// Create a new node in draft.
$page = Node::create([
'type' => 'page',
'title' => 'A',
]);
$page->moderation_state->value = 'published';
$page->save();
$id = $page->id();
// Verify the entity saved correctly.
/** @var Node $page */
$page = Node::load($id);
$this->assertEquals('A', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertTrue($page->isPublished());
}
/**
* Verifies that an unpublished state may be made the default revision.
*/
public function testArchive() {
$page = Node::create([
'type' => 'page',
'title' => $this->randomString(),
]);
$page->moderation_state->value = 'published';
$page->save();
$id = $page->id();
// The newly-created page should already be published.
$page = Node::load($id);
$this->assertTrue($page->isPublished());
// When the page is moderated to the archived state, then the latest
// revision should be the default revision, and it should be unpublished.
$page->moderation_state->value = 'archived';
$page->save();
$new_revision_id = $page->getRevisionId();
$storage = \Drupal::entityTypeManager()->getStorage('node');
$new_revision = $storage->loadRevision($new_revision_id);
$this->assertFalse($new_revision->isPublished());
$this->assertTrue($new_revision->isDefaultRevision());
}
}
@@ -0,0 +1,101 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\workflows\Entity\Workflow;
/**
* @coversDefaultClass \Drupal\content_moderation\ParamConverter\EntityRevisionConverter
* @group content_moderation
*/
class EntityRevisionConverterTest extends KernelTestBase {
public static $modules = [
'user',
'entity_test',
'system',
'content_moderation',
'node',
'workflows',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('entity_test');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installSchema('system', 'router');
$this->installSchema('system', 'sequences');
$this->installSchema('node', 'node_access');
\Drupal::service('router.builder')->rebuild();
}
/**
* @covers ::convert
*/
public function testConvertNonRevisionableEntityType() {
$entity_test = EntityTest::create([
'name' => 'test',
]);
$entity_test->save();
/** @var \Symfony\Component\Routing\RouterInterface $router */
$router = \Drupal::service('router.no_access_checks');
$result = $router->match('/entity_test/' . $entity_test->id());
$this->assertInstanceOf(EntityTest::class, $result['entity_test']);
$this->assertEquals($entity_test->getRevisionId(), $result['entity_test']->getRevisionId());
}
/**
* @covers ::convert
*/
public function testConvertWithRevisionableEntityType() {
$this->installConfig(['content_moderation']);
$node_type = NodeType::create([
'type' => 'article',
]);
$node_type->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'article');
$workflow->save();
$revision_ids = [];
$node = Node::create([
'title' => 'test',
'type' => 'article',
]);
$node->moderation_state->value = 'published';
$node->save();
$revision_ids[] = $node->getRevisionId();
$node->setNewRevision(TRUE);
$node->save();
$revision_ids[] = $node->getRevisionId();
$node->setNewRevision(TRUE);
$node->moderation_state->value = 'draft';
$node->save();
$revision_ids[] = $node->getRevisionId();
/** @var \Symfony\Component\Routing\RouterInterface $router */
$router = \Drupal::service('router.no_access_checks');
$result = $router->match('/node/' . $node->id() . '/edit');
$this->assertInstanceOf(Node::class, $result['node']);
$this->assertEquals($revision_ids[2], $result['node']->getRevisionId());
$this->assertFalse($result['node']->isDefaultRevision());
}
}
@@ -0,0 +1,176 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\workflows\Entity\Workflow;
/**
* @coversDefaultClass \Drupal\content_moderation\Plugin\Validation\Constraint\ModerationStateConstraintValidator
* @group content_moderation
*/
class EntityStateChangeValidationTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'content_moderation',
'user',
'system',
'language',
'content_translation',
'workflows',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
}
/**
* Test valid transitions.
*
* @covers ::validate
*/
public function testValidTransition() {
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$node->moderation_state->value = 'draft';
$node->save();
$node->moderation_state->value = 'published';
$this->assertCount(0, $node->validate());
$node->save();
$this->assertEquals('published', $node->moderation_state->value);
}
/**
* Test invalid transitions.
*
* @covers ::validate
*/
public function testInvalidTransition() {
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$node->moderation_state->value = 'draft';
$node->save();
$node->moderation_state->value = 'archived';
$violations = $node->validate();
$this->assertCount(1, $violations);
$this->assertEquals('Invalid state transition from <em class="placeholder">Draft</em> to <em class="placeholder">Archived</em>', $violations->get(0)->getMessage());
}
/**
* Tests that content without prior moderation information can be moderated.
*/
public function testLegacyContent() {
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
/** @var \Drupal\node\NodeInterface $node */
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$node->save();
$nid = $node->id();
// Enable moderation for our node type.
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$node = Node::load($nid);
// Having no previous state should not break validation.
$violations = $node->validate();
$this->assertCount(0, $violations);
// Having no previous state should not break saving the node.
$node->setTitle('New');
$node->save();
}
/**
* Tests that content without prior moderation information can be translated.
*/
public function testLegacyMultilingualContent() {
// Enable French.
ConfigurableLanguage::createFromLangcode('fr')->save();
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
/** @var \Drupal\node\NodeInterface $node */
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
'langcode' => 'en',
]);
$node->save();
$nid = $node->id();
$node = Node::load($nid);
// Creating a translation shouldn't break, even though there's no previous
// moderated revision for the new language.
$node_fr = $node->addTranslation('fr');
$node_fr->setTitle('Francais');
$node_fr->save();
// Enable moderation for our node type.
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
// Reload the French version of the node.
$node = Node::load($nid);
$node_fr = $node->getTranslation('fr');
/** @var \Drupal\node\NodeInterface $node_fr */
$node_fr->setTitle('Nouveau');
$node_fr->save();
}
}
@@ -0,0 +1,63 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\content_moderation\Entity\Handler\ModerationHandler;
use Drupal\content_moderation\EntityTypeInfo;
use Drupal\KernelTests\KernelTestBase;
/**
* @coversDefaultClass \Drupal\content_moderation\EntityTypeInfo
*
* @group content_moderation
*/
class EntityTypeInfoTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'content_moderation',
'entity_test',
];
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity type info class.
*
* @var \Drupal\content_moderation\EntityTypeInfo
*/
protected $entityTypeInfo;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->entityTypeInfo = $this->container->get('class_resolver')->getInstanceFromDefinition(EntityTypeInfo::class);
$this->entityTypeManager = $this->container->get('entity_type.manager');
}
/**
* @covers ::entityBaseFieldInfo
*/
public function testEntityBaseFieldInfo() {
$definition = $this->entityTypeManager->getDefinition('entity_test');
$definition->setHandlerClass('moderation', ModerationHandler::class);
$base_fields = $this->entityTypeInfo->entityBaseFieldInfo($definition);
$this->assertFalse($base_fields['moderation_state']->isReadOnly());
$this->assertTrue($base_fields['moderation_state']->isComputed());
$this->assertTrue($base_fields['moderation_state']->isTranslatable());
}
}
@@ -0,0 +1,88 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\entity_test\Entity\EntityTestRev;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the correct initial states are set on install.
*
* @group content_moderation
*/
class InitialStateTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'entity_test',
'node',
'user',
'system',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('entity_test_rev');
}
/**
* Tests the correct initial state.
*/
public function testInitialState() {
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
// Test with an entity type that implements EntityPublishedInterface.
$unpublished_node = Node::create([
'type' => 'example',
'title' => 'Unpublished node',
'status' => 0,
]);
$unpublished_node->save();
$published_node = Node::create([
'type' => 'example',
'title' => 'Published node',
'status' => 1,
]);
$published_node->save();
// Test with an entity type that doesn't implement EntityPublishedInterface.
$entity_test = EntityTestRev::create();
$entity_test->save();
\Drupal::service('module_installer')->install(['content_moderation'], TRUE);
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev');
$workflow->save();
$loaded_unpublished_node = Node::load($unpublished_node->id());
$loaded_published_node = Node::load($published_node->id());
$loaded_entity_test = EntityTestRev::load($entity_test->id());
$this->assertEquals('draft', $loaded_unpublished_node->moderation_state->value);
$this->assertEquals('published', $loaded_published_node->moderation_state->value);
$this->assertEquals('draft', $loaded_entity_test->moderation_state->value);
$presave_node = Node::create([
'type' => 'example',
'title' => 'Presave node',
]);
$this->assertEquals('draft', $presave_node->moderation_state->value);
}
}
@@ -0,0 +1,82 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\workflows\Entity\Workflow;
/**
* @coversDefaultClass \Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList
*
* @group content_moderation
*/
class ModerationStateFieldItemListTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'content_moderation',
'user',
'system',
'language',
'workflows',
];
/**
* @var \Drupal\node\NodeInterface
*/
protected $testNode;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$this->testNode = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$this->testNode->save();
\Drupal::entityTypeManager()->getStorage('node')->resetCache();
$this->testNode = Node::load($this->testNode->id());
}
/**
* Test the field item list when accessing an index.
*/
public function testArrayIndex() {
$this->assertFalse($this->testNode->isPublished());
$this->assertEquals('draft', $this->testNode->moderation_state[0]->value);
}
/**
* Test the field item list when iterating.
*/
public function testArrayIteration() {
$states = [];
foreach ($this->testNode->moderation_state as $item) {
$states[] = $item->value;
}
$this->assertEquals(['draft'], $states);
}
}
@@ -0,0 +1,90 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\Core\Render\RenderContext;
use Drupal\entity_test\Entity\EntityTestRev;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Test the state field formatter.
*
* @group content_moderation
*/
class StateFormatterTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'workflows',
'content_moderation',
'entity_test',
'user',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('entity_test_rev');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev');
$workflow->save();
}
/**
* Test the embed field.
*
* @dataProvider formatterTestCases
*/
public function testStateFieldFormatter($field_value, $formatter_settings, $expected_output) {
$entity = EntityTestRev::create([
'moderation_state' => $field_value,
]);
$entity->save();
$field_output = $this->container->get('renderer')->executeInRenderContext(new RenderContext(), function() use ($entity, $formatter_settings) {
return $entity->moderation_state->view($formatter_settings);
});
$this->assertEquals($expected_output, $field_output[0]);
}
/**
* Test cases for ::
*/
public function formatterTestCases() {
return [
'Draft State' => [
'draft',
[
'type' => 'content_moderation_state',
'settings' => [],
],
[
'#markup' => 'Draft',
],
],
'Published State' => [
'published',
[
'type' => 'content_moderation_state',
'settings' => [],
],
[
'#markup' => 'Published',
],
],
];
}
}
@@ -0,0 +1,176 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\entity_test\Entity\EntityTestMulRevPub;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\views\Kernel\ViewsKernelTestBase;
use Drupal\views\Views;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the views integration of content_moderation.
*
* @group content_moderation
*/
class ViewsDataIntegrationTest extends ViewsKernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'content_moderation_test_views',
'node',
'content_moderation',
'workflows',
'entity_test',
];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE) {
parent::setUp($import_test_views);
$this->installEntitySchema('node');
$this->installEntitySchema('entity_test_mulrevpub');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installSchema('node', 'node_access');
$this->installConfig('content_moderation_test_views');
$this->installConfig('content_moderation');
$node_type = NodeType::create([
'type' => 'page',
]);
$node_type->save();
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'page');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_mulrevpub', 'entity_test_mulrevpub');
$workflow->save();
}
/**
* Tests content_moderation_views_data().
*
* @see content_moderation_views_data()
*/
public function testViewsData() {
$node = Node::create([
'type' => 'page',
'title' => 'Test title first revision',
]);
$node->moderation_state->value = 'published';
$node->save();
// Create a totally unrelated entity to ensure the extra join information
// joins by the correct entity type.
$unrelated_entity = EntityTestMulRevPub::create([
'id' => $node->id(),
]);
$unrelated_entity->save();
$this->assertEquals($unrelated_entity->id(), $node->id());
$revision = clone $node;
$revision->setNewRevision(TRUE);
$revision->isDefaultRevision(FALSE);
$revision->title->value = 'Test title second revision';
$revision->moderation_state->value = 'draft';
$revision->save();
$view = Views::getView('test_content_moderation_latest_revision');
$view->execute();
// Ensure that the content_revision_tracker contains the right latest
// revision ID.
// Also ensure that the relationship back to the revision table contains the
// right latest revision.
$expected_result = [
[
'nid' => $node->id(),
'revision_id' => $revision->getRevisionId(),
'title' => $revision->label(),
'moderation_state_1' => 'draft',
'moderation_state' => 'published',
],
];
$this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid', 'content_revision_tracker_revision_id' => 'revision_id', 'moderation_state' => 'moderation_state', 'moderation_state_1' => 'moderation_state_1']);
}
/**
* Tests the join from the revision data table to the moderation state table.
*/
public function testContentModerationStateRevisionJoin() {
$node = Node::create([
'type' => 'page',
'title' => 'Test title first revision',
]);
$node->moderation_state->value = 'published';
$node->save();
$revision = clone $node;
$revision->setNewRevision(TRUE);
$revision->isDefaultRevision(FALSE);
$revision->title->value = 'Test title second revision';
$revision->moderation_state->value = 'draft';
$revision->save();
$view = Views::getView('test_content_moderation_revision_test');
$view->execute();
$expected_result = [
[
'vid' => $node->getRevisionId(),
'moderation_state' => 'published',
],
[
'vid' => $revision->getRevisionId(),
'moderation_state' => 'draft',
],
];
$this->assertIdenticalResultset($view, $expected_result, ['vid' => 'vid', 'moderation_state' => 'moderation_state']);
}
/**
* Tests the join from the data table to the moderation state table.
*/
public function testContentModerationStateBaseJoin() {
$node = Node::create([
'type' => 'page',
'title' => 'Test title first revision',
]);
$node->moderation_state->value = 'published';
$node->save();
$revision = clone $node;
$revision->setNewRevision(TRUE);
$revision->isDefaultRevision(FALSE);
$revision->title->value = 'Test title second revision';
$revision->moderation_state->value = 'draft';
$revision->save();
$view = Views::getView('test_content_moderation_base_table_test');
$view->execute();
$expected_result = [
[
'nid' => $node->id(),
// @todo I would have expected that the content_moderation_state default
// revision is the same one as in the node, but it isn't.
// Joins from the base table to the default revision of the
// content_moderation.
'moderation_state' => 'draft',
// Joins from the revision table to the default revision of the
// content_moderation.
'moderation_state_1' => 'draft',
// Joins from the revision table to the revision of the
// content_moderation.
'moderation_state_2' => 'published',
],
];
$this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid', 'moderation_state' => 'moderation_state', 'moderation_state_1' => 'moderation_state_1', 'moderation_state_2' => 'moderation_state_2']);
}
}
@@ -0,0 +1,72 @@
<?php
namespace Drupal\Tests\content_moderation\Unit;
use Drupal\content_moderation\ContentPreprocess;
use Drupal\Core\Routing\CurrentRouteMatch;
use Drupal\node\Entity\Node;
/**
* @coversDefaultClass \Drupal\content_moderation\ContentPreprocess
*
* @group content_moderation
*/
class ContentPreprocessTest extends \PHPUnit_Framework_TestCase {
/**
* @covers ::isLatestVersionPage
* @dataProvider routeNodeProvider
*/
public function testIsLatestVersionPage($route_name, $route_nid, $check_nid, $result, $message) {
$content_preprocess = new ContentPreprocess($this->setupCurrentRouteMatch($route_name, $route_nid));
$node = $this->setupNode($check_nid);
$this->assertEquals($result, $content_preprocess->isLatestVersionPage($node), $message);
}
/**
* Data provider for self::testIsLatestVersionPage().
*/
public function routeNodeProvider() {
return [
['entity.node.canonical', 1, 1, FALSE, 'Not on the latest version tab route.'],
['entity.node.latest_version', 1, 1, TRUE, 'On the latest version tab route, with the route node.'],
['entity.node.latest_version', 1, 2, FALSE, 'On the latest version tab route, with a different node.'],
];
}
/**
* Mock the current route matching object.
*
* @param string $route_name
* The route to mock.
* @param int $nid
* The node ID for mocking.
*
* @return \Drupal\Core\Routing\CurrentRouteMatch
* The mocked current route match object.
*/
protected function setupCurrentRouteMatch($route_name, $nid) {
$route_match = $this->prophesize(CurrentRouteMatch::class);
$route_match->getRouteName()->willReturn($route_name);
$route_match->getParameter('node')->willReturn($this->setupNode($nid));
return $route_match->reveal();
}
/**
* Mock a node object.
*
* @param int $nid
* The node ID to mock.
*
* @return \Drupal\node\Entity\Node
* The mocked node.
*/
protected function setupNode($nid) {
$node = $this->prophesize(Node::class);
$node->id()->willReturn($nid);
return $node->reveal();
}
}
@@ -0,0 +1,132 @@
<?php
namespace Drupal\Tests\content_moderation\Unit;
use Drupal\block_content\Entity\BlockContent;
use Drupal\Core\Access\AccessResultAllowed;
use Drupal\Core\Access\AccessResultForbidden;
use Drupal\Core\Access\AccessResultNeutral;
use Drupal\Core\Cache\Context\CacheContextsManager;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\Entity\Node;
use Drupal\content_moderation\Access\LatestRevisionCheck;
use Drupal\content_moderation\ModerationInformation;
use Drupal\user\EntityOwnerInterface;
use Prophecy\Argument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Routing\Route;
/**
* @coversDefaultClass \Drupal\content_moderation\Access\LatestRevisionCheck
* @group content_moderation
*/
class LatestRevisionCheckTest extends \PHPUnit_Framework_TestCase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Initialize Drupal container since the cache context manager is needed.
$contexts_manager = $this->prophesize(CacheContextsManager::class);
$contexts_manager->assertValidTokens(Argument::any())->willReturn(TRUE);
$builder = new ContainerBuilder();
$builder->set('cache_contexts_manager', $contexts_manager->reveal());
\Drupal::setContainer($builder);
}
/**
* Test the access check of the LatestRevisionCheck service.
*
* @param string $entity_class
* The class of the entity to mock.
* @param string $entity_type
* The machine name of the entity to mock.
* @param bool $has_forward
* Whether this entity should have a forward revision in the system.
* @param array $account_permissions
* An array of permissions the account has.
* @param bool $is_owner
* Indicates if the user should be the owner of the entity.
* @param string $result_class
* The AccessResult class that should result. One of AccessResultAllowed,
* AccessResultForbidden, AccessResultNeutral.
*
* @dataProvider accessSituationProvider
*/
public function testLatestAccessPermissions($entity_class, $entity_type, $has_forward, array $account_permissions, $is_owner, $result_class) {
/** @var \Drupal\Core\Session\AccountInterface $account */
$account = $this->prophesize(AccountInterface::class);
$possible_permissions = [
'view latest version',
'view any unpublished content',
'view own unpublished content',
];
foreach ($possible_permissions as $permission) {
$account->hasPermission($permission)->willReturn(in_array($permission, $account_permissions));
}
$account->id()->willReturn(42);
/** @var \Drupal\Core\Entity\EntityInterface $entity */
$entity = $this->prophesize($entity_class);
$entity->getCacheContexts()->willReturn([]);
$entity->getCacheTags()->willReturn([]);
$entity->getCacheMaxAge()->willReturn(0);
if (is_subclass_of($entity_class, EntityOwnerInterface::class)) {
$entity->getOwnerId()->willReturn($is_owner ? 42 : 3);
}
/** @var \Drupal\content_moderation\ModerationInformation $mod_info */
$mod_info = $this->prophesize(ModerationInformation::class);
$mod_info->hasForwardRevision($entity->reveal())->willReturn($has_forward);
$route = $this->prophesize(Route::class);
$route->getOption('_content_moderation_entity_type')->willReturn($entity_type);
$route_match = $this->prophesize(RouteMatch::class);
$route_match->getParameter($entity_type)->willReturn($entity->reveal());
$lrc = new LatestRevisionCheck($mod_info->reveal());
/** @var \Drupal\Core\Access\AccessResult $result */
$result = $lrc->access($route->reveal(), $route_match->reveal(), $account->reveal());
$this->assertInstanceOf($result_class, $result);
}
/**
* Data provider for testLastAccessPermissions().
*/
public function accessSituationProvider() {
return [
// Node with global permissions and latest version.
[Node::class, 'node', TRUE, ['view latest version', 'view any unpublished content'], FALSE, AccessResultAllowed::class],
// Node with global permissions and no latest version.
[Node::class, 'node', FALSE, ['view latest version', 'view any unpublished content'], FALSE, AccessResultForbidden::class],
// Node with own content permissions and latest version.
[Node::class, 'node', TRUE, ['view latest version', 'view own unpublished content'], TRUE, AccessResultAllowed::class],
// Node with own content permissions and no latest version.
[Node::class, 'node', FALSE, ['view latest version', 'view own unpublished content'], FALSE, AccessResultForbidden::class],
// Node with own content permissions and latest version, but no perms to
// view latest version.
[Node::class, 'node', TRUE, ['view own unpublished content'], TRUE, AccessResultNeutral::class],
// Node with own content permissions and no latest version, but no perms
// to view latest version.
[Node::class, 'node', TRUE, ['view own unpublished content'], FALSE, AccessResultNeutral::class],
// Block with forward revision, and permissions to view any.
[BlockContent::class, 'block_content', TRUE, ['view latest version', 'view any unpublished content'], FALSE, AccessResultAllowed::class],
// Block with no forward revision.
[BlockContent::class, 'block_content', FALSE, ['view latest version', 'view any unpublished content'], FALSE, AccessResultForbidden::class],
// Block with forward revision, but no permission to view any.
[BlockContent::class, 'block_content', TRUE, ['view latest version', 'view own unpublished content'], FALSE, AccessResultNeutral::class],
// Block with no forward revision.
[BlockContent::class, 'block_content', FALSE, ['view latest version', 'view own unpublished content'], FALSE, AccessResultForbidden::class],
];
}
}
@@ -0,0 +1,132 @@
<?php
namespace Drupal\Tests\content_moderation\Unit;
use Drupal\content_moderation\Entity\Handler\ModerationHandler;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\ContentEntityType;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\content_moderation\ModerationInformation;
use Drupal\workflows\WorkflowInterface;
/**
* @coversDefaultClass \Drupal\content_moderation\ModerationInformation
* @group content_moderation
*/
class ModerationInformationTest extends \PHPUnit_Framework_TestCase {
/**
* Builds a mock user.
*
* @return AccountInterface
* The mocked user.
*/
protected function getUser() {
return $this->prophesize(AccountInterface::class)->reveal();
}
/**
* Returns a mock Entity Type Manager.
*
* @return EntityTypeManagerInterface
* The mocked entity type manager.
*/
protected function getEntityTypeManager() {
$entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
return $entity_type_manager->reveal();
}
/**
* Sets up content moderation and entity manager mocking.
*
* @param string $bundle
* The bundle ID.
* @param string|null $workflow
* The workflow ID. If nul no workflow information is added to the bundle.
*
* @return \Drupal\Core\Entity\EntityTypeManagerInterface
* The mocked entity type manager.
*/
public function setupModerationBundleInfo($bundle, $workflow = NULL) {
$bundle_info_array = [];
if ($workflow) {
$bundle_info_array['workflow'] = $workflow;
}
$bundle_info = $this->prophesize(EntityTypeBundleInfoInterface::class);
$bundle_info->getBundleInfo("test_entity_type")->willReturn([$bundle => $bundle_info_array]);
return $bundle_info->reveal();
}
/**
* @dataProvider providerWorkflow
* @covers ::isModeratedEntity
*/
public function testIsModeratedEntity($workflow, $expected) {
$moderation_information = new ModerationInformation($this->getEntityTypeManager(), $this->setupModerationBundleInfo('test_bundle', $workflow));
$entity_type = new ContentEntityType([
'id' => 'test_entity_type',
'bundle_entity_type' => 'entity_test_bundle',
'handlers' => ['moderation' => ModerationHandler::class],
]);
$entity = $this->prophesize(ContentEntityInterface::class);
$entity->getEntityType()->willReturn($entity_type);
$entity->bundle()->willReturn('test_bundle');
$this->assertEquals($expected, $moderation_information->isModeratedEntity($entity->reveal()));
}
/**
* @dataProvider providerWorkflow
* @covers ::getWorkflowForEntity
*/
public function testGetWorkflowForEntity($workflow) {
$entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
if ($workflow) {
$workflow_entity = $this->prophesize(WorkflowInterface::class)->reveal();
$workflow_storage = $this->prophesize(EntityStorageInterface::class);
$workflow_storage->load('workflow')->willReturn($workflow_entity)->shouldBeCalled();
$entity_type_manager->getStorage('workflow')->willReturn($workflow_storage->reveal());
}
else {
$workflow_entity = NULL;
}
$moderation_information = new ModerationInformation($entity_type_manager->reveal(), $this->setupModerationBundleInfo('test_bundle', $workflow));
$entity = $this->prophesize(ContentEntityInterface::class);
$entity->getEntityTypeId()->willReturn('test_entity_type');
$entity->bundle()->willReturn('test_bundle');
$this->assertEquals($workflow_entity, $moderation_information->getWorkflowForEntity($entity->reveal()));
}
/**
* @dataProvider providerWorkflow
* @covers ::shouldModerateEntitiesOfBundle
*/
public function testShouldModerateEntities($workflow, $expected) {
$entity_type = new ContentEntityType([
'id' => 'test_entity_type',
'bundle_entity_type' => 'entity_test_bundle',
'handlers' => ['moderation' => ModerationHandler::class],
]);
$moderation_information = new ModerationInformation($this->getEntityTypeManager(), $this->setupModerationBundleInfo('test_bundle', $workflow));
$this->assertEquals($expected, $moderation_information->shouldModerateEntitiesOfBundle($entity_type, 'test_bundle'));
}
/**
* Data provider for several tests.
*/
public function providerWorkflow() {
return [
[NULL, FALSE],
['workflow', TRUE],
];
}
}
@@ -0,0 +1,101 @@
<?php
namespace Drupal\Tests\content_moderation\Unit;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\content_moderation\StateTransitionValidation;
use Drupal\workflows\Entity\Workflow;
use Drupal\workflows\WorkflowTypeInterface;
use Drupal\workflows\WorkflowTypeManager;
use Prophecy\Argument;
/**
* @coversDefaultClass \Drupal\content_moderation\StateTransitionValidation
* @group content_moderation
*/
class StateTransitionValidationTest extends \PHPUnit_Framework_TestCase {
/**
* Verifies user-aware transition validation.
*
* @param string $from_id
* The state to transition from.
* @param string $to_id
* The state to transition to.
* @param string $permission
* The permission to give the user, or not.
* @param bool $allowed
* Whether or not to grant a user this permission.
* @param bool $result
* Whether getValidTransitions() is expected to have the.
*
* @dataProvider userTransitionsProvider
*/
public function testUserSensitiveValidTransitions($from_id, $to_id, $permission, $allowed, $result) {
$user = $this->prophesize(AccountInterface::class);
// The one listed permission will be returned as instructed; Any others are
// always denied.
$user->hasPermission($permission)->willReturn($allowed);
$user->hasPermission(Argument::type('string'))->willReturn(FALSE);
$entity = $this->prophesize(ContentEntityInterface::class);
$entity = $entity->reveal();
$entity->moderation_state = new \stdClass();
$entity->moderation_state->value = $from_id;
$validator = new StateTransitionValidation($this->setUpModerationInformation($entity));
$has_transition = FALSE;
foreach ($validator->getValidTransitions($entity, $user->reveal()) as $transition) {
if ($transition->to()->id() === $to_id) {
$has_transition = TRUE;
break;
}
}
$this->assertSame($result, $has_transition);
}
protected function setUpModerationInformation(ContentEntityInterface $entity) {
// Create a container so that the plugin manager and workflow type can be
// mocked.
$container = new ContainerBuilder();
$workflow_type = $this->prophesize(WorkflowTypeInterface::class);
$workflow_type->decorateState(Argument::any())->willReturnArgument(0);
$workflow_type->decorateTransition(Argument::any())->willReturnArgument(0);
$workflow_manager = $this->prophesize(WorkflowTypeManager::class);
$workflow_manager->createInstance('content_moderation', Argument::any())->willReturn($workflow_type->reveal());
$container->set('plugin.manager.workflows.type', $workflow_manager->reveal());
\Drupal::setContainer($container);
$workflow = new Workflow(['id' => 'process', 'type' => 'content_moderation'], 'workflow');
$workflow
->addState('draft', 'draft')
->addState('needs_review', 'needs_review')
->addState('published', 'published')
->addTransition('draft', 'draft', ['draft'], 'draft')
->addTransition('review', 'review', ['draft'], 'needs_review')
->addTransition('publish', 'publish', ['needs_review', 'published'], 'published');
$moderation_info = $this->prophesize(ModerationInformationInterface::class);
$moderation_info->getWorkflowForEntity($entity)->willReturn($workflow);
return $moderation_info->reveal();
}
/**
* Data provider for the user transition test.
*/
public function userTransitionsProvider() {
// The user has the right permission, so let it through.
$ret[] = ['draft', 'draft', 'use process transition draft', TRUE, TRUE];
// The user doesn't have the right permission, block it.
$ret[] = ['draft', 'draft', 'use process transition draft', FALSE, FALSE];
// The user has some other permission that doesn't matter.
$ret[] = ['draft', 'draft', 'use process transition review', TRUE, FALSE];
return $ret;
}
}