Move into nested docroot

This commit is contained in:
Rob Davies 2017-02-13 15:31:17 +00:00
parent 83a0d3a149
commit c8b70abde9
13405 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,85 @@
<?php
namespace Drupal\content_moderation\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Symfony\Component\Routing\Route;
/**
* Access check for the entity moderation tab.
*/
class LatestRevisionCheck implements AccessInterface {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* Constructs a new LatestRevisionCheck.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information service.
*/
public function __construct(ModerationInformationInterface $moderation_information) {
$this->moderationInfo = $moderation_information;
}
/**
* Checks that there is a forward revision available.
*
* This checker assumes the presence of an '_entity_access' requirement key
* in the same form as used by EntityAccessCheck.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @see \Drupal\Core\Entity\EntityAccessCheck
*/
public function access(Route $route, RouteMatchInterface $route_match) {
// This tab should not show up unless there's a reason to show it.
$entity = $this->loadEntity($route, $route_match);
return $this->moderationInfo->hasForwardRevision($entity)
? AccessResult::allowed()->addCacheableDependency($entity)
: AccessResult::forbidden()->addCacheableDependency($entity);
}
/**
* Returns the default revision of the entity this route is for.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* returns the Entity in question.
*
* @throws \Exception
* A generic exception is thrown if the entity couldn't be loaded. This
* almost always implies a developer error, so it should get turned into
* an HTTP 500.
*/
protected function loadEntity(Route $route, RouteMatchInterface $route_match) {
$entity_type = $route->getOption('_content_moderation_entity_type');
if ($entity = $route_match->getParameter($entity_type)) {
if ($entity instanceof EntityInterface) {
return $entity;
}
}
throw new \Exception(sprintf('%s is not a valid entity route. The LatestRevisionCheck access checker may only be used with a route that has a single entity parameter.', $route_match->getRouteName()));
}
}

View file

@ -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 {
}

View file

@ -0,0 +1,29 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema;
/**
* Defines the content moderation state schema handler.
*/
class ContentModerationStateStorageSchema extends SqlContentEntityStorageSchema {
/**
* {@inheritdoc}
*/
protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $reset = FALSE) {
$schema = parent::getEntitySchema($entity_type, $reset);
// Creates an index to ensure that the lookup in
// \Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList::getModerationState()
// is performant.
$schema['content_moderation_state_field_data']['indexes'] += array(
'content_moderation_state__lookup' => array('content_entity_type_id', 'content_entity_id', 'content_entity_revision_id'),
);
return $schema;
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,178 @@
<?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",
* },
* 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['moderation_state'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Moderation state'))
->setDescription(t('The moderation state of the referenced content.'))
->setSetting('target_type', 'moderation_state')
->setRequired(TRUE)
->setTranslatable(TRUE)
->setRevisionable(TRUE)
->addConstraint('ModerationState', []);
$fields['content_entity_type_id'] = BaseFieldDefinition::create('string')
->setLabel(t('Content entity type ID'))
->setDescription(t('The ID of the content entity type this moderation state is for.'))
->setRequired(TRUE)
->setRevisionable(TRUE);
$fields['content_entity_id'] = BaseFieldDefinition::create('integer')
->setLabel(t('Content entity ID'))
->setDescription(t('The ID of the content entity this moderation state is for.'))
->setRequired(TRUE)
->setRevisionable(TRUE);
// @todo https://www.drupal.org/node/2779931 Add constraint that enforces
// unique content_entity_type_id, content_entity_id and
// content_entity_revision_id.
$fields['content_entity_revision_id'] = BaseFieldDefinition::create('integer')
->setLabel(t('Content entity revision ID'))
->setDescription(t('The revision ID of the content entity this moderation state is for.'))
->setRequired(TRUE)
->setRevisionable(TRUE);
return $fields;
}
/**
* {@inheritdoc}
*/
public function getOwner() {
return $this->get('uid')->entity;
}
/**
* {@inheritdoc}
*/
public function getOwnerId() {
return $this->getEntityKey('uid');
}
/**
* {@inheritdoc}
*/
public function setOwnerId($uid) {
$this->set('uid', $uid);
return $this;
}
/**
* {@inheritdoc}
*/
public function setOwner(UserInterface $account) {
$this->set('uid', $account->id());
return $this;
}
/**
* Creates or updates an entity's moderation state whilst saving that entity.
*
* @param \Drupal\content_moderation\Entity\ContentModerationState $content_moderation_state
* The content moderation entity content entity to create or save.
*
* @internal
* This method should only be called as a result of saving the related
* content entity.
*/
public static function updateOrCreateFromEntity(ContentModerationState $content_moderation_state) {
$content_moderation_state->realSave();
}
/**
* Default value callback for the 'uid' base field definition.
*
* @see \Drupal\content_moderation\Entity\ContentModerationState::baseFieldDefinitions()
*
* @return array
* An array of default values.
*/
public static function getCurrentUserId() {
return array(\Drupal::currentUser()->id());
}
/**
* {@inheritdoc}
*/
public function save() {
$related_entity = \Drupal::entityTypeManager()
->getStorage($this->content_entity_type_id->value)
->loadRevision($this->content_entity_revision_id->value);
if ($related_entity instanceof TranslatableInterface) {
$related_entity = $related_entity->getTranslation($this->activeLangcode);
}
$related_entity->moderation_state->target_id = $this->moderation_state->target_id;
return $related_entity->save();
}
/**
* Saves an entity permanently.
*
* When saving existing entities, the entity is assumed to be complete,
* partial updates of entities are not supported.
*
* @return int
* Either SAVED_NEW or SAVED_UPDATED, depending on the operation performed.
*
* @throws \Drupal\Core\Entity\EntityStorageException
* In case of failures an exception is thrown.
*/
protected function realSave() {
return parent::save();
}
}

View file

@ -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.');
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Common customizations for most/all entities.
*
* This class is intended primarily as a base class.
*/
class ModerationHandler implements ModerationHandlerInterface, EntityHandlerInterface {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static();
}
/**
* {@inheritdoc}
*/
public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
// This is probably not necessary if configuration is setup correctly.
$entity->setNewRevision(TRUE);
$entity->isDefaultRevision($default_revision);
}
/**
* {@inheritdoc}
*/
public function onBundleModerationConfigurationFormSubmit(ConfigEntityInterface $bundle) {
// The Revisions portion of Entity API is not uniformly applied or
// consistent. Until that's fixed, we'll make a best-attempt to apply it to
// the common entity patterns so as to avoid every entity type needing to
// implement this method, although some will still need to do so for now.
// This is the API that should be universal, but isn't yet.
// @see \Drupal\node\Entity\NodeType
if (method_exists($bundle, 'setNewRevision')) {
$bundle->setNewRevision(TRUE);
}
// This is the raw property used by NodeType, and likely others.
elseif ($bundle->get('new_revision') !== NULL) {
$bundle->set('new_revision', TRUE);
}
// This is the raw property used by BlockContentType, and maybe others.
elseif ($bundle->get('revision') !== NULL) {
$bundle->set('revision', TRUE);
}
$bundle->save();
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Defines operations that need to vary by entity type.
*
* Much of the logic contained in this handler is an indication of flaws
* in the Entity API that are insufficiently standardized between entity types.
* Hopefully over time functionality can be removed from this interface.
*/
interface ModerationHandlerInterface {
/**
* Operates on 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);
/**
* Operates on the bundle definition that has been marked as moderated.
*
* Note: The values on the EntityModerationForm itself are already saved
* so do not need to be saved here. If any changes are made to the bundle
* object here it is this method's responsibility to call save() on it.
*
* The most common use case is to force revisions on for this bundle if
* moderation is enabled. That, sadly, does not have a common API in core.
*
* @param \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle
* The bundle definition that is being saved.
*/
public function onBundleModerationConfigurationFormSubmit(ConfigEntityInterface $bundle);
/**
* Alters entity forms to enforce revision handling.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form id.
*
* @see hook_form_alter()
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id);
/**
* Alters bundle forms to enforce revision handling.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form id.
*
* @see hook_form_alter()
*/
public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id);
}

View file

@ -0,0 +1,66 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Customizations for node entities.
*/
class NodeModerationHandler extends ModerationHandler {
/**
* {@inheritdoc}
*/
public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
if ($this->shouldModerate($entity, $published_state)) {
parent::onPresave($entity, $default_revision, $published_state);
// Only nodes have a concept of published.
/** @var \Drupal\node\NodeInterface $entity */
$entity->setPublished($published_state);
}
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
$form['revision']['#disabled'] = TRUE;
$form['revision']['#default_value'] = TRUE;
$form['revision']['#description'] = $this->t('Revisions are required.');
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
/* @var \Drupal\node\Entity\NodeType $entity */
$entity = $form_state->getFormObject()->getEntity();
if ($entity->getThirdPartySetting('content_moderation', 'enabled', FALSE)) {
// Force the revision checkbox on.
$form['workflow']['options']['#default_value']['revision'] = 'revision';
$form['workflow']['options']['revision']['#disabled'] = TRUE;
}
}
/**
* Check if an entity's default revision and/or state needs adjusting.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to check.
* @param bool $published_state
* Whether the state being transitioned to is a published state or not.
*
* @return bool
* TRUE when either the default revision or the state needs to be updated.
*/
protected function shouldModerate(ContentEntityInterface $entity, $published_state) {
// @todo clarify the first condition.
// First condition is needed so you can add a translation.
// Second condition checks to see if the published status has changed.
return $entity->isDefaultTranslation() || $entity->isPublished() !== $published_state;
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace Drupal\content_moderation\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\content_moderation\ModerationStateInterface;
/**
* Defines the Moderation state entity.
*
* @ConfigEntityType(
* id = "moderation_state",
* label = @Translation("Moderation state"),
* handlers = {
* "access" = "Drupal\content_moderation\ModerationStateAccessControlHandler",
* "list_builder" = "Drupal\content_moderation\ModerationStateListBuilder",
* "form" = {
* "add" = "Drupal\content_moderation\Form\ModerationStateForm",
* "edit" = "Drupal\content_moderation\Form\ModerationStateForm",
* "delete" = "Drupal\content_moderation\Form\ModerationStateDeleteForm"
* },
* "route_provider" = {
* "html" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
* },
* },
* config_prefix = "state",
* admin_permission = "administer moderation states",
* entity_keys = {
* "id" = "id",
* "label" = "label",
* "uuid" = "uuid",
* "weight" = "weight",
* },
* links = {
* "add-form" = "/admin/config/workflow/moderation/states/add",
* "edit-form" = "/admin/config/workflow/moderation/states/{moderation_state}",
* "delete-form" = "/admin/config/workflow/moderation/states/{moderation_state}/delete",
* "collection" = "/admin/config/workflow/moderation/states"
* },
* config_export = {
* "id",
* "label",
* "published",
* "default_revision",
* "weight",
* },
* )
*/
class ModerationState extends ConfigEntityBase implements ModerationStateInterface {
/**
* The Moderation state ID.
*
* @var string
*/
protected $id;
/**
* The Moderation state label.
*
* @var string
*/
protected $label;
/**
* Whether this state represents a published node.
*
* @var bool
*/
protected $published;
/**
* Relative weight of this state.
*
* @var int
*/
protected $weight;
/**
* Whether this state represents a default revision of the node.
*
* If this is a published state, then this property is ignored.
*
* @var bool
*/
protected $default_revision;
/**
* {@inheritdoc}
*/
public function isPublishedState() {
return $this->published;
}
/**
* {@inheritdoc}
*/
public function isDefaultRevisionState() {
return $this->published || $this->default_revision;
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace Drupal\content_moderation\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\content_moderation\ModerationStateTransitionInterface;
/**
* Defines the Moderation state transition entity.
*
* @ConfigEntityType(
* id = "moderation_state_transition",
* label = @Translation("Moderation state transition"),
* handlers = {
* "list_builder" = "Drupal\content_moderation\ModerationStateTransitionListBuilder",
* "form" = {
* "add" = "Drupal\content_moderation\Form\ModerationStateTransitionForm",
* "edit" = "Drupal\content_moderation\Form\ModerationStateTransitionForm",
* "delete" = "Drupal\content_moderation\Form\ModerationStateTransitionDeleteForm"
* },
* "route_provider" = {
* "html" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
* },
* },
* config_prefix = "state_transition",
* admin_permission = "administer moderation state transitions",
* entity_keys = {
* "id" = "id",
* "label" = "label",
* "uuid" = "uuid",
* "weight" = "weight"
* },
* links = {
* "add-form" = "/admin/config/workflow/moderation/transitions/add",
* "edit-form" = "/admin/config/workflow/moderation/transitions/{moderation_state_transition}",
* "delete-form" = "/admin/config/workflow/moderation/transitions/{moderation_state_transition}/delete",
* "collection" = "/admin/config/workflow/moderation/transitions"
* }
* )
*/
class ModerationStateTransition extends ConfigEntityBase implements ModerationStateTransitionInterface {
/**
* The Moderation state transition ID.
*
* @var string
*/
protected $id;
/**
* The Moderation state transition label.
*
* @var string
*/
protected $label;
/**
* ID of from state.
*
* @var string
*/
protected $stateFrom;
/**
* ID of to state.
*
* @var string
*/
protected $stateTo;
/**
* Relative weight of this transition.
*
* @var int
*/
protected $weight;
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
parent::calculateDependencies();
if ($this->stateFrom) {
$this->addDependency('config', ModerationState::load($this->stateFrom)->getConfigDependencyName());
}
if ($this->stateTo) {
$this->addDependency('config', ModerationState::load($this->stateTo)->getConfigDependencyName());
}
return $this;
}
/**
* {@inheritdoc}
*/
public function getFromState() {
return $this->stateFrom;
}
/**
* {@inheritdoc}
*/
public function getToState() {
return $this->stateTo;
}
/**
* {@inheritdoc}
*/
public function getWeight() {
return $this->weight;
}
}

View file

@ -0,0 +1,266 @@
<?php
namespace Drupal\content_moderation;
use Drupal\content_moderation\Entity\ContentModerationState;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\content_moderation\Form\EntityModerationForm;
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;
/**
* Constructs a new EntityOperations object.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* Moderation information service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager service.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The form builder.
* @param \Drupal\content_moderation\RevisionTrackerInterface $tracker
* The revision tracker.
*/
public function __construct(ModerationInformationInterface $moderation_info, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, RevisionTrackerInterface $tracker) {
$this->moderationInfo = $moderation_info;
$this->entityTypeManager = $entity_type_manager;
$this->formBuilder = $form_builder;
$this->tracker = $tracker;
}
/**
* {@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')
);
}
/**
* 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->target_id) {
$moderation_state = $this->entityTypeManager
->getStorage('moderation_state')
->load($entity->moderation_state->target_id);
$published_state = $moderation_state->isPublishedState();
// This entity is default if it is new, the default revision, or the
// default revision is not published.
$update_default_revision = $entity->isNew()
|| $moderation_state->isDefaultRevisionState()
|| !$this->isDefaultRevisionPublished($entity);
// Fire per-entity-type logic for handling the save process.
$this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->onPresave($entity, $update_default_revision, $published_state);
}
}
/**
* Hook bridge.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity that was just saved.
*
* @see hook_entity_insert()
*/
public function entityInsert(EntityInterface $entity) {
if ($this->moderationInfo->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->target_id;
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
if (!$moderation_state) {
$moderation_state = $this->entityTypeManager
->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle())
->getThirdPartySetting('content_moderation', 'default_moderation_state');
}
// @todo what if $entity->moderation_state->target_id is null at this point?
$entity_type_id = $entity->getEntityTypeId();
$entity_id = $entity->id();
$entity_revision_id = $entity->getRevisionId();
$storage = $this->entityTypeManager->getStorage('content_moderation_state');
$entities = $storage->loadByProperties([
'content_entity_type_id' => $entity_type_id,
'content_entity_id' => $entity_id,
]);
/** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */
$content_moderation_state = reset($entities);
if (!($content_moderation_state instanceof ContentModerationStateInterface)) {
$content_moderation_state = $storage->create([
'content_entity_type_id' => $entity_type_id,
'content_entity_id' => $entity_id,
]);
}
else {
// Create a new revision.
$content_moderation_state->setNewRevision(TRUE);
}
// Sync translations.
if ($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);
ContentModerationState::updateOrCreateFromEntity($content_moderation_state);
}
/**
* Set the latest revision.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The content entity to create content_moderation_state entity for.
*/
protected function setLatestRevision(EntityInterface $entity) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$this->tracker->setLatestRevision(
$entity->getEntityTypeId(),
$entity->id(),
$entity->language()->getId(),
$entity->getRevisionId()
);
}
/**
* Act on entities being assembled before rendering.
*
* This is a hook bridge.
*
* @see hook_entity_view()
* @see EntityFieldManagerInterface::getExtraFields()
*/
public function entityView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
if (!$this->moderationInfo->isModeratedEntity($entity)) {
return;
}
if (!$this->moderationInfo->isLatestRevision($entity)) {
return;
}
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
if ($entity->isDefaultRevision()) {
return;
}
$component = $display->getComponent('content_moderation_control');
if ($component) {
$build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity);
$build['content_moderation_control']['#weight'] = $component['weight'];
}
}
/**
* Check if the default revision for the given entity is published.
*
* The default revision is the same as the entity retrieved by "default" from
* the storage handler. If the entity is translated, use the default revision
* of the same language as the given entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being saved.
*
* @return bool
* TRUE if the default revision is published. FALSE otherwise.
*/
protected function isDefaultRevisionPublished(EntityInterface $entity) {
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
$default_revision = $storage->load($entity->id());
// Ensure we are comparing the same translation as the current entity.
if ($default_revision instanceof TranslatableInterface && $default_revision->isTranslatable()) {
// If there is no translation, then there is no default revision and is
// therefore not published.
if (!$default_revision->hasTranslation($entity->language()->getId())) {
return FALSE;
}
$default_revision = $default_revision->getTranslation($entity->language()->getId());
}
return $default_revision && $default_revision->moderation_state->entity->isPublishedState();
}
}

View file

@ -0,0 +1,408 @@
<?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\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 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.
*/
public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user) {
$this->stringTranslation = $translation;
$this->moderationInfo = $moderation_information;
$this->entityTypeManager = $entity_type_manager;
$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('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 ($this->filterNonRevisionableEntityTypes($entity_types) as $type_name => $type) {
$entity_types[$type_name] = $this->addModerationToEntityType($type);
$entity_types[$type->get('bundle_of')] = $this->addModerationToEntity($entity_types[$type->get('bundle_of')]);
}
}
/**
* Modifies an entity definition to include moderation support.
*
* This primarily just means an extra handler. A Generic one is provided,
* but individual entity types can provide their own as appropriate.
*
* @param \Drupal\Core\Entity\ContentEntityTypeInterface $type
* The content entity definition to modify.
*
* @return \Drupal\Core\Entity\ContentEntityTypeInterface
* The modified content entity definition.
*/
protected function addModerationToEntity(ContentEntityTypeInterface $type) {
if (!$type->hasHandlerClass('moderation')) {
$handler_class = !empty($this->moderationHandlers[$type->id()]) ? $this->moderationHandlers[$type->id()] : ModerationHandler::class;
$type->setHandlerClass('moderation', $handler_class);
}
if (!$type->hasLinkTemplate('latest-version') && $type->hasLinkTemplate('canonical')) {
$type->setLinkTemplate('latest-version', $type->getLinkTemplate('canonical') . '/latest');
}
// @todo Core forgot to add a direct way to manipulate route_provider, so
// we have to do it the sloppy way for now.
$providers = $type->getRouteProviderClasses() ?: [];
if (empty($providers['moderation'])) {
$providers['moderation'] = EntityModerationRouteProvider::class;
$type->setHandlerClass('route_provider', $providers);
}
return $type;
}
/**
* Configures moderation configuration support on a entity type definition.
*
* That "configuration support" includes a configuration form, a hypermedia
* link, and a route provider to tie it all together. There's also a
* moderation handler for per-entity-type variation.
*
* @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $type
* The config entity definition to modify.
*
* @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface
* The modified config entity definition.
*/
protected function addModerationToEntityType(ConfigEntityTypeInterface $type) {
if ($type->hasLinkTemplate('edit-form') && !$type->hasLinkTemplate('moderation-form')) {
$type->setLinkTemplate('moderation-form', $type->getLinkTemplate('edit-form') . '/moderation');
}
if (!$type->getFormClass('moderation')) {
$type->setFormClass('moderation', BundleModerationConfigurationForm::class);
}
// @todo Core forgot to add a direct way to manipulate route_provider, so
// we have to do it the sloppy way for now.
$providers = $type->getRouteProviderClasses() ?: [];
if (empty($providers['moderation'])) {
$providers['moderation'] = EntityTypeModerationRouteProvider::class;
$type->setHandlerClass('route_provider', $providers);
}
return $type;
}
/**
* Adds an operation on bundles that should have a Moderation form.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity on which to define an operation.
*
* @return array
* An array of operation definitions.
*
* @see hook_entity_operation()
*/
public function entityOperation(EntityInterface $entity) {
$operations = [];
$type = $entity->getEntityType();
$bundle_of = $type->getBundleOf();
if ($this->currentUser->hasPermission('administer moderation states') && $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() {
/** @var ConfigEntityTypeInterface $type */
foreach ($this->filterNonRevisionableEntityTypes($this->entityTypeManager->getDefinitions()) as $type_name => $type) {
$result = $this->entityTypeManager
->getStorage($type_name)
->getQuery()
->condition('third_party_settings.content_moderation.enabled', TRUE)
->execute();
foreach ($result as $bundle_name) {
yield ['entity' => $type->getBundleOf(), 'bundle' => $bundle_name];
}
}
}
/**
* Adds base field info to an entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* Entity type for adding base fields to.
*
* @return \Drupal\Core\Field\BaseFieldDefinition[]
* New fields added by moderation state.
*/
public function entityBaseFieldInfo(EntityTypeInterface $entity_type) {
if (!$this->moderationInfo->canModerateEntitiesOfEntityType($entity_type)) {
return [];
}
$fields = [];
$fields['moderation_state'] = BaseFieldDefinition::create('entity_reference')
->setLabel($this->t('Moderation state'))
->setDescription($this->t('The moderation state of this piece of content.'))
->setComputed(TRUE)
->setClass(ModerationStateFieldItemList::class)
->setSetting('target_type', 'moderation_state')
->setDisplayOptions('view', [
'label' => 'hidden',
'type' => 'hidden',
'weight' => -5,
])
->setDisplayOptions('form', [
'type' => 'moderation_state_default',
'weight' => 5,
'settings' => [],
])
->addConstraint('ModerationState', [])
->setDisplayConfigurable('form', FALSE)
->setDisplayConfigurable('view', FALSE)
->setReadOnly(FALSE)
->setTranslatable(TRUE);
return $fields;
}
/**
* Adds ModerationState constraint to bundles whose entities are moderated.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface[] $fields
* The array of bundle field definitions.
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param string $bundle
* The bundle.
*
* @see hook_entity_bundle_field_info_alter();
*/
public function entityBundleFieldInfoAlter(&$fields, EntityTypeInterface $entity_type, $bundle) {
if (!empty($fields['moderation_state']) && $this->moderationInfo->shouldModerateEntitiesOfBundle($entity_type, $bundle)) {
$fields['moderation_state']->addConstraint('ModerationState', []);
}
}
/**
* Alters bundle forms to enforce revision handling.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form id.
*
* @see hook_form_alter()
*/
public function 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()]);
}
}
/**
* Filters entity type lists to return only revisionable entity types.
*
* @param EntityTypeInterface[] $entity_types
* The master entity type list filter.
*
* @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface[]
* An array of revisionable entity types which are configuration entities.
*/
protected function filterNonRevisionableEntityTypes(array $entity_types) {
return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) {
return ($type instanceof ConfigEntityTypeInterface)
&& ($bundle_of = $type->get('bundle_of'))
&& $entity_types[$bundle_of]->isRevisionable();
});
}
}

View file

@ -0,0 +1,195 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\content_moderation\Entity\ModerationState;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form for configuring moderation usage on a given entity bundle.
*/
class BundleModerationConfigurationForm extends EntityForm {
/**
* Entity Type Manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* {@inheritdoc}
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('entity_type.manager'));
}
/**
* {@inheritdoc}
*
* Blank out the base form ID so that form alters that use the base form ID to
* target both add and edit forms don't pick up this form.
*/
public function getBaseFormId() {
return NULL;
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
/* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */
$bundle = $form_state->getFormObject()->getEntity();
$form['enable_moderation_state'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable moderation states.'),
'#description' => $this->t('Content of this type must transition through moderation states in order to be published.'),
'#default_value' => $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE),
];
// Add a special message when moderation is being disabled.
if ($bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)) {
$form['enable_moderation_state_note'] = [
'#type' => 'item',
'#description' => $this->t('After disabling moderation, any existing forward drafts will be accessible via the "Revisions" tab.'),
'#states' => [
'visible' => [
':input[name=enable_moderation_state]' => ['checked' => FALSE],
],
],
];
}
$states = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple();
$label = function(ModerationState $state) {
return $state->label();
};
$options_published = array_map($label, array_filter($states, function(ModerationState $state) {
return $state->isPublishedState();
}));
$options_unpublished = array_map($label, array_filter($states, function(ModerationState $state) {
return !$state->isPublishedState();
}));
$form['allowed_moderation_states_unpublished'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Allowed moderation states (Unpublished)'),
'#description' => $this->t('The allowed unpublished moderation states this content-type can be assigned.'),
'#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_unpublished)),
'#options' => $options_unpublished,
'#required' => TRUE,
'#states' => [
'visible' => [
':input[name=enable_moderation_state]' => ['checked' => TRUE],
],
],
];
$form['allowed_moderation_states_published'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Allowed moderation states (Published)'),
'#description' => $this->t('The allowed published moderation states this content-type can be assigned.'),
'#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_published)),
'#options' => $options_published,
'#required' => TRUE,
'#states' => [
'visible' => [
':input[name=enable_moderation_state]' => ['checked' => TRUE],
],
],
];
// The key of the array needs to be a user-facing string so we have to fully
// render the translatable string to a real string, or else PHP errors on an
// object used as an array key.
$options = [
$this->t('Unpublished')->render() => $options_unpublished,
$this->t('Published')->render() => $options_published,
];
$form['default_moderation_state'] = [
'#type' => 'select',
'#title' => $this->t('Default moderation state'),
'#options' => $options,
'#description' => $this->t('Select the moderation state for new content'),
'#default_value' => $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'),
'#states' => [
'visible' => [
':input[name=enable_moderation_state]' => ['checked' => TRUE],
],
],
];
$form['#entity_builders'][] = [$this, 'formBuilderCallback'];
return parent::form($form, $form_state);
}
/**
* Form builder callback.
*
* @todo This should be folded into the form method.
*
* @param string $entity_type_id
* The entity type identifier.
* @param \Drupal\Core\Entity\EntityInterface $bundle
* The bundle entity updated with the submitted values.
* @param array $form
* The complete form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public function formBuilderCallback($entity_type_id, EntityInterface $bundle, &$form, FormStateInterface $form_state) {
// @todo https://www.drupal.org/node/2779933 write a test for this.
if ($bundle instanceof ThirdPartySettingsInterface) {
$bundle->setThirdPartySetting('content_moderation', 'enabled', $form_state->getValue('enable_moderation_state'));
$bundle->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished'))));
$bundle->setThirdPartySetting('content_moderation', 'default_moderation_state', $form_state->getValue('default_moderation_state'));
}
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
if ($form_state->getValue('enable_moderation_state')) {
$allowed = array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished')));
if (($default = $form_state->getValue('default_moderation_state')) && !in_array($default, $allowed, TRUE)) {
$form_state->setErrorByName('default_moderation_state', $this->t('The default moderation state must be one of the allowed states.'));
}
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// If moderation is enabled, revisions MUST be enabled as well. Otherwise we
// can't have forward revisions.
if ($form_state->getValue('enable_moderation_state')) {
/* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */
$bundle = $form_state->getFormObject()->getEntity();
$this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->onBundleModerationConfigurationFormSubmit($bundle);
}
parent::submitForm($form, $form_state);
drupal_set_message($this->t('Your settings have been saved.'));
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\content_moderation\Entity\ModerationStateTransition;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\content_moderation\StateTransitionValidation;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The EntityModerationForm provides a simple UI for changing moderation state.
*/
class EntityModerationForm extends FormBase {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* The moderation state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidation
*/
protected $validation;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* EntityModerationForm constructor.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
* @param \Drupal\content_moderation\StateTransitionValidation $validation
* The moderation state transition validation service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(ModerationInformationInterface $moderation_info, StateTransitionValidation $validation, EntityTypeManagerInterface $entity_type_manager) {
$this->moderationInfo = $moderation_info;
$this->validation = $validation;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('content_moderation.moderation_information'),
$container->get('content_moderation.state_transition_validation'),
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'content_moderation_entity_moderation_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ContentEntityInterface $entity = NULL) {
/** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
$current_state = $entity->moderation_state->entity;
$transitions = $this->validation->getValidTransitions($entity, $this->currentUser());
// Exclude self-transitions.
$transitions = array_filter($transitions, function(ModerationStateTransition $transition) use ($current_state) {
return $transition->getToState() != $current_state->id();
});
$target_states = [];
/** @var ModerationStateTransition $transition */
foreach ($transitions as $transition) {
$target_states[$transition->getToState()] = $transition->label();
}
if (!count($target_states)) {
return $form;
}
if ($current_state) {
$form['current'] = [
'#type' => 'item',
'#title' => $this->t('Status'),
'#markup' => $current_state->label(),
];
}
// Persist the entity so we can access it in the submit handler.
$form_state->set('entity', $entity);
$form['new_state'] = [
'#type' => 'select',
'#title' => $this->t('Moderate'),
'#options' => $target_states,
];
$form['revision_log'] = [
'#type' => 'textfield',
'#title' => $this->t('Log message'),
'#size' => 30,
];
$form['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Apply'),
];
$form['#theme'] = ['entity_moderation_form'];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var ContentEntityInterface $entity */
$entity = $form_state->get('entity');
$new_state = $form_state->getValue('new_state');
// @todo should we just just be updating the content moderation state
// entity? That would prevent setting the revision log.
$entity->moderation_state->target_id = $new_state;
$entity->revision_log = $form_state->getValue('revision_log');
$entity->save();
drupal_set_message($this->t('The moderation state has been updated.'));
/** @var \Drupal\content_moderation\Entity\ModerationState $state */
$state = $this->entityTypeManager->getStorage('moderation_state')->load($new_state);
// The page we're on likely won't be visible if we just set the entity to
// the default state, as we hide that latest-revision tab if there is no
// forward revision. Redirect to the canonical URL instead, since that will
// still exist.
if ($state->isDefaultRevisionState()) {
$form_state->setRedirectUrl($entity->toUrl('canonical'));
}
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Core\Entity\EntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
/**
* Builds the form to delete Moderation state entities.
*/
class ModerationStateDeleteForm extends EntityConfirmFormBase {
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete %name?', array('%name' => $this->entity->label()));
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('entity.moderation_state.collection');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->entity->delete();
drupal_set_message($this->t(
'Moderation state %label deleted.',
['%label' => $this->entity->label()]
));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\content_moderation\Entity\ModerationState;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
/**
* Class ModerationStateForm.
*/
class ModerationStateForm extends EntityForm {
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/* @var \Drupal\content_moderation\ModerationStateInterface $moderation_state */
$moderation_state = $this->entity;
$form['label'] = array(
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#maxlength' => 255,
'#default_value' => $moderation_state->label(),
'#description' => $this->t('Label for the Moderation state.'),
'#required' => TRUE,
);
$form['id'] = array(
'#type' => 'machine_name',
'#default_value' => $moderation_state->id(),
'#machine_name' => array(
'exists' => [ModerationState::class, 'load'],
),
'#disabled' => !$moderation_state->isNew(),
);
$form['published'] = [
'#type' => 'checkbox',
'#title' => $this->t('Published'),
'#description' => $this->t('When content reaches this state it should be published.'),
'#default_value' => $moderation_state->isPublishedState(),
];
$form['default_revision'] = [
'#type' => 'checkbox',
'#title' => $this->t('Default revision'),
'#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'),
'#default_value' => $moderation_state->isDefaultRevisionState(),
// @todo Add form #state to force "make default" on when "published" is
// on for a state.
// @see https://www.drupal.org/node/2645614
];
return $form;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$moderation_state = $this->entity;
$status = $moderation_state->save();
switch ($status) {
case SAVED_NEW:
drupal_set_message($this->t('Created the %label Moderation state.', [
'%label' => $moderation_state->label(),
]));
break;
default:
drupal_set_message($this->t('Saved the %label Moderation state.', [
'%label' => $moderation_state->label(),
]));
}
$form_state->setRedirectUrl($moderation_state->toUrl('collection'));
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Core\Entity\EntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
/**
* Builds the form to delete Moderation state transition entities.
*/
class ModerationStateTransitionDeleteForm extends EntityConfirmFormBase {
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete %name?', array('%name' => $this->entity->label()));
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('entity.moderation_state_transition.collection');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->entity->delete();
drupal_set_message($this->t(
'Moderation transition %label deleted.',
['%label' => $this->entity->label()]
));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}

View file

@ -0,0 +1,151 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Query\QueryFactory;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Class ModerationStateTransitionForm.
*
* @package Drupal\content_moderation\Form
*/
class ModerationStateTransitionForm extends EntityForm {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity query factory.
*
* @var \Drupal\Core\Entity\Query\QueryFactory
*/
protected $queryFactory;
/**
* Constructs a new ModerationStateTransitionForm.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
* The entity query factory.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, QueryFactory $query_factory) {
$this->entityTypeManager = $entity_type_manager;
$this->queryFactory = $query_factory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static($container->get('entity_type.manager'), $container->get('entity.query'));
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/* @var \Drupal\content_moderation\ModerationStateTransitionInterface $moderation_state_transition */
$moderation_state_transition = $this->entity;
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#maxlength' => 255,
'#default_value' => $moderation_state_transition->label(),
'#description' => $this->t('Label for the Moderation state transition.'),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $moderation_state_transition->id(),
'#machine_name' => [
'exists' => '\Drupal\content_moderation\Entity\ModerationStateTransition::load',
],
'#disabled' => !$moderation_state_transition->isNew(),
];
$options = [];
foreach ($this->entityTypeManager->getStorage('moderation_state')
->loadMultiple() as $moderation_state) {
$options[$moderation_state->id()] = $moderation_state->label();
}
$form['container'] = [
'#type' => 'container',
'#attributes' => [
'class' => ['container-inline'],
],
];
$form['container']['stateFrom'] = [
'#type' => 'select',
'#title' => $this->t('Transition from'),
'#options' => $options,
'#required' => TRUE,
'#empty_option' => $this->t('-- Select --'),
'#default_value' => $moderation_state_transition->getFromState(),
];
$form['container']['stateTo'] = [
'#type' => 'select',
'#options' => $options,
'#required' => TRUE,
'#title' => $this->t('Transition to'),
'#empty_option' => $this->t('-- Select --'),
'#default_value' => $moderation_state_transition->getToState(),
];
// Make sure there's always at least a wide enough delta on weight to cover
// the current value or the total number of transitions. That way we
// never end up forcing a transition to change its weight needlessly.
$num_transitions = $this->queryFactory->get('moderation_state_transition')
->count()
->execute();
$delta = max(abs($moderation_state_transition->getWeight()), $num_transitions);
$form['weight'] = [
'#type' => 'weight',
'#delta' => $delta,
'#options' => $options,
'#title' => $this->t('Weight'),
'#default_value' => $moderation_state_transition->getWeight(),
'#description' => $this->t('Orders the transitions in moderation forms and the administrative listing. Heavier items will sink and the lighter items will be positioned nearer the top.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$moderation_state_transition = $this->entity;
$status = $moderation_state_transition->save();
switch ($status) {
case SAVED_NEW:
drupal_set_message($this->t('Created the %label Moderation state transition.', [
'%label' => $moderation_state_transition->label(),
]));
break;
default:
drupal_set_message($this->t('Saved the %label Moderation state transition.', [
'%label' => $moderation_state_transition->label(),
]));
}
$form_state->setRedirectUrl($moderation_state_transition->toUrl('collection'));
}
}

View file

@ -0,0 +1,132 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
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;
/**
* Creates a new ModerationInformation instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@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)) {
$bundle_entity = $this->entityTypeManager->getStorage($entity_type->getBundleEntityType())->load($bundle);
if ($bundle_entity) {
return $bundle_entity->getThirdPartySetting('content_moderation', 'enabled', FALSE);
}
}
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) {
return $this->isLatestRevision($entity)
&& $entity->isDefaultRevision()
&& $entity->moderation_state->entity
&& $entity->moderation_state->entity->isPublishedState();
}
}

View file

@ -0,0 +1,129 @@
<?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);
}

View file

@ -0,0 +1,31 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;
/**
* Access controller for the Moderation State entity.
*
* @see \Drupal\workbench_moderation\Entity\ModerationState.
*/
class ModerationStateAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
$admin_access = parent::checkAccess($entity, $operation, $account);
// Allow view with other permission.
if ($operation === 'view') {
return AccessResult::allowedIfHasPermission($account, 'view moderation states')->orIf($admin_access);
}
return $admin_access;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Provides an interface for defining Moderation state entities.
*/
interface ModerationStateInterface extends ConfigEntityInterface {
/**
* Determines if content updated to this state should be published.
*
* @return bool
* TRUE if content updated to this state should be published.
*/
public function isPublishedState();
/**
* Determines if content updated to this state should be the default revision.
*
* @return bool
* TRUE if content in this state should be the default revision.
*/
public function isDefaultRevisionState();
}

View file

@ -0,0 +1,40 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Config\Entity\DraggableListBuilder;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides a listing of Moderation state entities.
*/
class ModerationStateListBuilder extends DraggableListBuilder {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'moderation_state_admin_overview_form';
}
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Moderation state');
$header['id'] = $this->t('Machine name');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
$row['label'] = $entity->label();
$row['id']['#markup'] = $entity->id();
return $row + parent::buildRow($entity);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Provides an interface for defining Moderation state transition entities.
*/
interface ModerationStateTransitionInterface extends ConfigEntityInterface {
/**
* Gets the from state for the given transition.
*
* @return string
* The moderation state ID for the from state.
*/
public function getFromState();
/**
* Gets the to state for the given transition.
*
* @return string
* The moderation state ID for the to state.
*/
public function getToState();
/**
* Gets the weight for the given transition.
*
* @return int
* The weight of this transition.
*/
public function getWeight();
}

View file

@ -0,0 +1,173 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Config\Entity\DraggableListBuilder;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\user\RoleStorageInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a listing of Moderation state transition entities.
*/
class ModerationStateTransitionListBuilder extends DraggableListBuilder {
/**
* Moderation state entity storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $stateStorage;
/**
* The role storage.
*
* @var \Drupal\user\RoleStorageInterface
*/
protected $roleStorage;
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('entity.manager')->getStorage($entity_type->id()),
$container->get('entity.manager')->getStorage('moderation_state'),
$container->get('entity.manager')->getStorage('user_role')
);
}
/**
* Constructs a new ModerationStateTransitionListBuilder.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* Entity Type.
* @param \Drupal\Core\Entity\EntityStorageInterface $transition_storage
* Moderation state transition entity storage.
* @param \Drupal\Core\Entity\EntityStorageInterface $state_storage
* Moderation state entity storage.
* @param \Drupal\user\RoleStorageInterface $role_storage
* The role storage.
*/
public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $transition_storage, EntityStorageInterface $state_storage, RoleStorageInterface $role_storage) {
parent::__construct($entity_type, $transition_storage);
$this->stateStorage = $state_storage;
$this->roleStorage = $role_storage;
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'content_moderation_transition_list';
}
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['to'] = $this->t('To state');
$header['label'] = $this->t('Button label');
$header['roles'] = $this->t('Allowed roles');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
$row['to']['#markup'] = $this->stateStorage->load($entity->getToState())->label();
$row['label'] = $entity->label();
$row['roles']['#markup'] = implode(', ', user_role_names(FALSE, 'use ' . $entity->id() . ' transition'));
return $row + parent::buildRow($entity);
}
/**
* {@inheritdoc}
*/
public function render() {
$build = parent::render();
$build['item'] = [
'#type' => 'item',
'#markup' => $this->t('On this screen you can define <em>transitions</em>. Every time an entity is saved, it undergoes a transition. It is not possible to save an entity if it tries do a transition not defined here. Transitions do not necessarily mean a state change, it is possible to transition from a state to the same state but that transition needs to be defined here as well.'),
'#weight' => -5,
];
return $build;
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$this->entities = $this->load();
// Get all the moderation states and sort them by weight.
$states = $this->stateStorage->loadMultiple();
uasort($states, array($this->entityType->getClass(), 'sort'));
/** @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */
$groups = array_fill_keys(array_keys($states), []);
foreach ($this->entities as $entity) {
$groups[$entity->getFromState()][] = $entity;
}
foreach ($groups as $group_name => $entities) {
$form[$group_name] = [
'#type' => 'details',
'#title' => $this->t('From @state to...', ['@state' => $states[$group_name]->label()]),
// Make sure that the first group is always open.
'#open' => $group_name === array_keys($groups)[0],
];
$form[$group_name][$this->entitiesKey] = array(
'#type' => 'table',
'#header' => $this->buildHeader(),
'#empty' => t('There is no @label yet.', array('@label' => $this->entityType->getLabel())),
'#tabledrag' => array(
array(
'action' => 'order',
'relationship' => 'sibling',
'group' => 'weight',
),
),
);
$delta = 10;
// Change the delta of the weight field if have more than 20 entities.
if (!empty($this->weightKey)) {
$count = count($this->entities);
if ($count > 20) {
$delta = ceil($count / 2);
}
}
foreach ($entities as $entity) {
$row = $this->buildRow($entity);
if (isset($row['label'])) {
$row['label'] = array('#markup' => $row['label']);
}
if (isset($row['weight'])) {
$row['weight']['#delta'] = $delta;
}
$form[$group_name][$this->entitiesKey][$entity->id()] = $row;
}
}
$form['actions']['#type'] = 'actions';
$form['actions']['submit'] = array(
'#type' => 'submit',
'#value' => t('Save order'),
'#button_type' => 'primary',
);
return $form;
}
}

View file

@ -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, array('operation' => 'entity_upcast'));
}
if ($latest_revision->isRevisionTranslationAffected()) {
$entity = $latest_revision;
}
}
return $entity;
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\content_moderation\Entity\ModerationState;
use Drupal\content_moderation\Entity\ModerationStateTransition;
/**
* Defines a class for dynamic permissions based on transitions.
*/
class Permissions {
use StringTranslationTrait;
/**
* Returns an array of transition permissions.
*
* @return array
* The transition permissions.
*/
public function transitionPermissions() {
// @todo https://www.drupal.org/node/2779933 write a test for this.
$perms = [];
/* @var \Drupal\content_moderation\ModerationStateInterface[] $states */
$states = ModerationState::loadMultiple();
/* @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */
foreach (ModerationStateTransition::loadMultiple() as $id => $transition) {
$perms['use ' . $id . ' transition'] = [
'title' => $this->t('Use the %transition_name transition', [
'%transition_name' => $transition->label(),
]),
'description' => $this->t('Move content from %from state to %to state.', [
'%from' => $states[$transition->getFromState()]->label(),
'%to' => $states[$transition->getToState()]->label(),
]),
];
}
return $perms;
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace Drupal\content_moderation\Plugin\Action;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\Plugin\Action\PublishNode;
use Drupal\content_moderation\ModerationInformationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Alternate action plugin that can opt-out of modifying moderated entities.
*
* @see \Drupal\node\Plugin\Action\PublishNode
*/
class ModerationOptOutPublishNode extends PublishNode implements ContainerFactoryPluginInterface {
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* ModerationOptOutPublishNode constructor.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $moderation_info) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->moderationInfo = $moderation_info;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration, $plugin_id, $plugin_definition,
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function execute($entity = NULL) {
if ($entity && $this->moderationInfo->isModeratedEntity($entity)) {
drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.'));
return;
}
parent::execute($entity);
}
/**
* {@inheritdoc}
*/
public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = parent::access($object, $account, TRUE)
->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratedEntity($object))->addCacheableDependency($object));
return $return_as_object ? $result : $result->isAllowed();
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace Drupal\content_moderation\Plugin\Action;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\Plugin\Action\UnpublishNode;
use Drupal\content_moderation\ModerationInformationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Alternate action plugin that can opt-out of modifying moderated entities.
*
* @see \Drupal\node\Plugin\Action\UnpublishNode
*/
class ModerationOptOutUnpublishNode extends UnpublishNode implements ContainerFactoryPluginInterface {
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* ModerationOptOutUnpublishNode constructor.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $moderation_info) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->moderationInfo = $moderation_info;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration, $plugin_id, $plugin_definition,
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function execute($entity = NULL) {
if ($entity && $this->moderationInfo->isModeratedEntity($entity)) {
drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.'));
return;
}
parent::execute($entity);
}
/**
* {@inheritdoc}
*/
public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = parent::access($object, $account, TRUE)
->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratedEntity($object))->addCacheableDependency($object));
return $return_as_object ? $result : $result->isAllowed();
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,255 @@
<?php
namespace Drupal\content_moderation\Plugin\Field\FieldWidget;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsSelectWidget;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\content_moderation\ModerationInformation;
use Drupal\content_moderation\StateTransitionValidation;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'moderation_state_default' widget.
*
* @FieldWidget(
* id = "moderation_state_default",
* label = @Translation("Moderation state"),
* field_types = {
* "entity_reference"
* }
* )
*/
class ModerationStateWidget extends OptionsSelectWidget implements ContainerFactoryPluginInterface {
/**
* Current user service.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Moderation state transition entity query.
*
* @var \Drupal\Core\Entity\Query\QueryInterface
*/
protected $moderationStateTransitionEntityQuery;
/**
* Moderation state storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $moderationStateStorage;
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformation
*/
protected $moderationInformation;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Moderation state transition storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $moderationStateTransitionStorage;
/**
* Moderation state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidation
*/
protected $validator;
/**
* Constructs a new ModerationStateWidget object.
*
* @param string $plugin_id
* Plugin id.
* @param mixed $plugin_definition
* Plugin definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* Field definition.
* @param array $settings
* Field settings.
* @param array $third_party_settings
* Third party settings.
* @param \Drupal\Core\Session\AccountInterface $current_user
* Current user service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager.
* @param \Drupal\Core\Entity\EntityStorageInterface $moderation_state_storage
* Moderation state storage.
* @param \Drupal\Core\Entity\EntityStorageInterface $moderation_state_transition_storage
* Moderation state transition storage.
* @param \Drupal\Core\Entity\Query\QueryInterface $entity_query
* Moderation transition entity query service.
* @param \Drupal\content_moderation\ModerationInformation $moderation_information
* Moderation information service.
* @param \Drupal\content_moderation\StateTransitionValidation $validator
* Moderation state transition validation service
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, EntityStorageInterface $moderation_state_storage, EntityStorageInterface $moderation_state_transition_storage, QueryInterface $entity_query, ModerationInformation $moderation_information, StateTransitionValidation $validator) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
$this->moderationStateTransitionEntityQuery = $entity_query;
$this->moderationStateTransitionStorage = $moderation_state_transition_storage;
$this->moderationStateStorage = $moderation_state_storage;
$this->entityTypeManager = $entity_type_manager;
$this->currentUser = $current_user;
$this->moderationInformation = $moderation_information;
$this->validator = $validator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['third_party_settings'],
$container->get('current_user'),
$container->get('entity_type.manager'),
$container->get('entity_type.manager')->getStorage('moderation_state'),
$container->get('entity_type.manager')->getStorage('moderation_state_transition'),
$container->get('entity.query')->get('moderation_state_transition', 'AND'),
$container->get('content_moderation.moderation_information'),
$container->get('content_moderation.state_transition_validation')
);
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
/** @var ContentEntityInterface $entity */
$entity = $items->getEntity();
/* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle_entity */
$bundle_entity = $this->entityTypeManager->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle());
if (!$this->moderationInformation->isModeratedEntity($entity)) {
// @todo https://www.drupal.org/node/2779933 write a test for this.
return $element + ['#access' => FALSE];
}
$default = $items->get($delta)->value ?: $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state', FALSE);
/** @var \Drupal\content_moderation\ModerationStateInterface $default_state */
$default_state = $this->entityTypeManager->getStorage('moderation_state')->load($default);
if (!$default || !$default_state) {
throw new \UnexpectedValueException(sprintf('The %s bundle has an invalid moderation state configuration, moderation states are enabled but no default is set.', $bundle_entity->label()));
}
$transitions = $this->validator->getValidTransitions($entity, $this->currentUser);
$target_states = [];
/** @var \Drupal\content_moderation\Entity\ModerationStateTransition $transition */
foreach ($transitions as $transition) {
$target_states[$transition->getToState()] = $transition->label();
}
// @todo https://www.drupal.org/node/2779933 write a test for this.
$element += [
'#access' => FALSE,
'#type' => 'select',
'#options' => $target_states,
'#default_value' => $default,
'#published' => $default ? $default_state->isPublishedState() : FALSE,
'#key_column' => $this->column,
];
$element['#element_validate'][] = array(get_class($this), 'validateElement');
// Use the dropbutton.
$element['#process'][] = [get_called_class(), 'processActions'];
return $element;
}
/**
* Entity builder updating the node moderation state with the submitted value.
*
* @param string $entity_type_id
* The entity type identifier.
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity updated with the submitted values.
* @param array $form
* The complete form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public static function updateStatus($entity_type_id, ContentEntityInterface $entity, array $form, FormStateInterface $form_state) {
$element = $form_state->getTriggeringElement();
if (isset($element['#moderation_state'])) {
$entity->moderation_state->target_id = $element['#moderation_state'];
}
}
/**
* Process callback to alter action buttons.
*/
public static function processActions($element, FormStateInterface $form_state, array &$form) {
// We'll steal most of the button configuration from the default submit
// button. However, NodeForm also hides that button for admins (as it adds
// its own, too), so we have to restore it.
$default_button = $form['actions']['submit'];
$default_button['#access'] = TRUE;
// Add a custom button for each transition we're allowing. The #dropbutton
// property tells FAPI to cluster them all together into a single widget.
$options = $element['#options'];
$entity = $form_state->getFormObject()->getEntity();
$translatable = !$entity->isNew() && $entity->isTranslatable();
foreach ($options as $id => $label) {
$button = [
'#dropbutton' => 'save',
'#moderation_state' => $id,
'#weight' => -10,
];
$button['#value'] = $translatable
? t('Save and @transition (this translation)', ['@transition' => $label])
: t('Save and @transition', ['@transition' => $label]);
$form['actions']['moderation_state_' . $id] = $button + $default_button;
}
// Hide the default buttons, including the specialty ones added by
// NodeForm.
foreach (['publish', 'unpublish', 'submit'] as $key) {
$form['actions'][$key]['#access'] = FALSE;
unset($form['actions'][$key]['#dropbutton']);
}
// Setup a callback to translate the button selection back into field
// widget, so that it will get saved properly.
$form['#entity_builders']['update_moderation_state'] = [get_called_class(), 'updateStatus'];
return $element;
}
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
return parent::isApplicable($field_definition) && $field_definition->getName() === 'moderation_state';
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace Drupal\content_moderation\Plugin\Field;
use Drupal\content_moderation\Entity\ModerationState;
use Drupal\Core\Field\EntityReferenceFieldItemList;
/**
* A computed field that provides a content entity's moderation state.
*
* It links content entities to a moderation state configuration entity via a
* moderation state content entity.
*/
class ModerationStateFieldItemList extends EntityReferenceFieldItemList {
/**
* Gets the moderation state entity linked to a content entity revision.
*
* @return \Drupal\content_moderation\ModerationStateInterface|null
* The moderation state configuration entity linked to a content entity
* revision.
*/
protected function getModerationState() {
$entity = $this->getEntity();
if (!\Drupal::service('content_moderation.moderation_information')->shouldModerateEntitiesOfBundle($entity->getEntityType(), $entity->bundle())) {
return NULL;
}
if ($entity->id() && $entity->getRevisionId()) {
$revisions = \Drupal::service('entity.query')->get('content_moderation_state')
->condition('content_entity_type_id', $entity->getEntityTypeId())
->condition('content_entity_id', $entity->id())
->condition('content_entity_revision_id', $entity->getRevisionId())
->allRevisions()
->sort('revision_id', 'DESC')
->execute();
if ($revision_to_load = key($revisions)) {
/** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */
$content_moderation_state = \Drupal::entityTypeManager()
->getStorage('content_moderation_state')
->loadRevision($revision_to_load);
// Return the correct translation.
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->get('moderation_state')->entity;
}
}
// It is possible that the bundle does not exist at this point. For example,
// the node type form creates a fake Node entity to get default values.
// @see \Drupal\node\NodeTypeForm::form()
$bundle_entity = \Drupal::entityTypeManager()
->getStorage($entity->getEntityType()->getBundleEntityType())
->load($entity->bundle());
if ($bundle_entity && ($default = $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state'))) {
return ModerationState::load($default);
}
}
/**
* {@inheritdoc}
*/
public function get($index) {
if ($index !== 0) {
throw new \InvalidArgumentException('An entity can not have multiple moderation states at the same time.');
}
$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->getModerationState();
// Do not store NULL values in the static cache.
if ($moderation_state) {
$this->list[$index] = $this->createItem($index, ['entity' => $moderation_state]);
}
}
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace Drupal\content_moderation\Plugin\Menu;
use Drupal\Core\Menu\LocalTaskDefault;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\content_moderation\ModerationInformation;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for making the edit tab use 'Edit draft' or 'New draft'.
*/
class EditTab extends LocalTaskDefault implements ContainerFactoryPluginInterface {
use StringTranslationTrait;
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformation
*/
protected $moderationInfo;
/**
* The entity.
*
* @var \Drupal\Core\Entity\ContentEntityInterface
*/
protected $entity;
/**
* Constructs a new EditTab object.
*
* @param array $configuration
* Plugin configuration.
* @param string $plugin_id
* Plugin ID.
* @param mixed $plugin_definition
* Plugin definition.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The translation service.
* @param \Drupal\content_moderation\ModerationInformation $moderation_information
* The moderation information.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, TranslationInterface $string_translation, ModerationInformation $moderation_information) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->stringTranslation = $string_translation;
$this->moderationInfo = $moderation_information;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('string_translation'),
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function getRouteParameters(RouteMatchInterface $route_match) {
// Override the node here with the latest revision.
$this->entity = $route_match->getParameter($this->pluginDefinition['entity_type_id']);
return parent::getRouteParameters($route_match);
}
/**
* {@inheritdoc}
*/
public function getTitle() {
if (!$this->moderationInfo->isModeratedEntity($this->entity)) {
// Moderation isn't enabled.
return parent::getTitle();
}
// @todo https://www.drupal.org/node/2779933 write a test for this.
return $this->moderationInfo->isLiveRevision($this->entity)
? $this->t('New draft')
: $this->t('Edit draft');
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
// @todo https://www.drupal.org/node/2779933 write a test for this.
$tags = parent::getCacheTags();
// Tab changes if node or node-type is modified.
$tags = array_merge($tags, $this->entity->getCacheTags());
$tags[] = $this->entity->getEntityType()->getBundleEntityType() . ':' . $this->entity->bundle();
return $tags;
}
}

View file

@ -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';
}

View file

@ -0,0 +1,134 @@
<?php
namespace Drupal\content_moderation\Plugin\Validation\Constraint;
use Drupal\content_moderation\Entity\ModerationState as ModerationStateEntity;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\content_moderation\StateTransitionValidation;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks if a moderation state transition is valid.
*/
class ModerationStateConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The state transition validation.
*
* @var \Drupal\content_moderation\StateTransitionValidation
*/
protected $validation;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
private $entityTypeManager;
/**
* The moderation info.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* Creates a new ModerationStateConstraintValidator instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_moderation\StateTransitionValidation $validation
* The state transition validation.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, StateTransitionValidation $validation, ModerationInformationInterface $moderation_information) {
$this->validation = $validation;
$this->entityTypeManager = $entity_type_manager;
$this->moderationInformation = $moderation_information;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('content_moderation.state_transition_validation'),
$container->get('content_moderation.moderation_information')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\Core\Entity\EntityInterface $entity */
$entity = $value->getEntity();
// Ignore entities that are not subject to moderation anyway.
if (!$this->moderationInformation->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());
}
if ($entity->moderation_state->target_id) {
$new_state_id = $entity->moderation_state->target_id;
}
else {
$new_state_id = $default = $this->entityTypeManager
->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle())
->getThirdPartySetting('content_moderation', 'default_moderation_state');
}
if ($new_state_id) {
$new_state = ModerationStateEntity::load($new_state_id);
}
// @todo - what if $new_state_id references something that does not exist or
// is null.
if (!$this->validation->isTransitionAllowed($original_entity->moderation_state->entity, $new_state)) {
$this->context->addViolation($constraint->message, ['%from' => $original_entity->moderation_state->entity->label(), '%to' => $new_state->label()]);
}
}
/**
* Determines if this entity is being moderated for the first time.
*
* If the previous version of the entity has no moderation state, we assume
* that means it predates the presence of moderation states.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being moderated.
*
* @return bool
* TRUE if this is the entity's first time being moderated, FALSE otherwise.
*/
protected function isFirstTimeModeration(EntityInterface $entity) {
$original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id());
$original_id = $original_entity->moderation_state->target_id;
return !($entity->moderation_state->target_id && $original_entity && $original_id);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -0,0 +1,122 @@
<?php
namespace Drupal\content_moderation\Routing;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Dynamic route provider for the Content moderation module.
*
* Provides the following routes:
* - The latest version tab, showing the latest revision of an entity, not the
* default one.
*/
class EntityModerationRouteProvider implements EntityRouteProviderInterface, EntityHandlerInterface {
/**
* The entity manager.
*
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected $entityFieldManager;
/**
* Constructs a new DefaultHtmlRouteProvider.
*
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_manager
* The entity manager.
*/
public function __construct(EntityFieldManagerInterface $entity_manager) {
$this->entityFieldManager = $entity_manager;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$container->get('entity_field.manager')
);
}
/**
* {@inheritdoc}
*/
public function getRoutes(EntityTypeInterface $entity_type) {
$collection = new RouteCollection();
if ($moderation_route = $this->getLatestVersionRoute($entity_type)) {
$entity_type_id = $entity_type->id();
$collection->add("entity.{$entity_type_id}.latest_version", $moderation_route);
}
return $collection;
}
/**
* Gets the moderation-form route.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
*
* @return \Symfony\Component\Routing\Route|null
* The generated route, if available.
*/
protected function getLatestVersionRoute(EntityTypeInterface $entity_type) {
if ($entity_type->hasLinkTemplate('latest-version') && $entity_type->hasViewBuilderClass()) {
$entity_type_id = $entity_type->id();
$route = new Route($entity_type->getLinkTemplate('latest-version'));
$route
->addDefaults([
'_entity_view' => "{$entity_type_id}.full",
'_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::title',
])
// If the entity type is a node, unpublished content will be visible
// if the user has the "view all unpublished content" permission.
->setRequirement('_entity_access', "{$entity_type_id}.view")
->setRequirement('_permission', 'view latest version,view any unpublished content')
->setRequirement('_content_moderation_latest_version', 'TRUE')
->setOption('_content_moderation_entity_type', $entity_type_id)
->setOption('parameters', [
$entity_type_id => [
'type' => 'entity:' . $entity_type_id,
'load_forward_revision' => 1,
],
]);
// Entity types with serial IDs can specify this in their route
// requirements, improving the matching process.
if ($this->getEntityTypeIdKeyType($entity_type) === 'integer') {
$route->setRequirement($entity_type_id, '\d+');
}
return $route;
}
}
/**
* Gets the type of the ID key for a given entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* An entity type.
*
* @return string|null
* The type of the ID key for a given entity type, or NULL if the entity
* type does not support fields.
*/
protected function getEntityTypeIdKeyType(EntityTypeInterface $entity_type) {
if (!$entity_type->isSubclassOf(FieldableEntityInterface::class)) {
return NULL;
}
$field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
return $field_storage_definitions[$entity_type->getKey('id')]->getType();
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace Drupal\content_moderation\Routing;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Provides the moderation configuration routes for config entities.
*/
class EntityTypeModerationRouteProvider implements EntityRouteProviderInterface {
/**
* {@inheritdoc}
*/
public function getRoutes(EntityTypeInterface $entity_type) {
$collection = new RouteCollection();
if ($moderation_route = $this->getModerationFormRoute($entity_type)) {
$entity_type_id = $entity_type->id();
$collection->add("entity.{$entity_type_id}.moderation", $moderation_route);
}
return $collection;
}
/**
* Gets the moderation-form route.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
*
* @return \Symfony\Component\Routing\Route|null
* The generated route, if available.
*/
protected function getModerationFormRoute(EntityTypeInterface $entity_type) {
if ($entity_type->hasLinkTemplate('moderation-form') && $entity_type->getFormClass('moderation')) {
$entity_type_id = $entity_type->id();
$route = new Route($entity_type->getLinkTemplate('moderation-form'));
// @todo Come up with a new permission.
$route
->setDefaults([
'_entity_form' => "{$entity_type_id}.moderation",
'_title' => 'Moderation',
])
->setRequirement('_permission', 'administer moderation states')
->setOption('parameters', [
$entity_type_id => ['type' => 'entity:' . $entity_type_id],
]);
return $route;
}
}
}

View file

@ -0,0 +1,247 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Query\QueryFactory;
use Drupal\Core\Session\AccountInterface;
use Drupal\content_moderation\Entity\ModerationStateTransition;
/**
* Validates whether a certain state transition is allowed.
*/
class StateTransitionValidation implements StateTransitionValidationInterface {
/**
* Entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Entity query factory.
*
* @var \Drupal\Core\Entity\Query\QueryFactory
*/
protected $queryFactory;
/**
* Stores the possible state transitions.
*
* @var array
*/
protected $possibleTransitions = [];
/**
* Constructs a new StateTransitionValidation.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
* The entity query factory.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, QueryFactory $query_factory) {
$this->entityTypeManager = $entity_type_manager;
$this->queryFactory = $query_factory;
}
/**
* Computes a mapping of possible transitions.
*
* This method is uncached and will recalculate the list on every request.
* In most cases you want to use getPossibleTransitions() instead.
*
* @see static::getPossibleTransitions()
*
* @return array[]
* An array containing all possible transitions. Each entry is keyed by the
* "from" state, and the value is an array of all legal "to" states based
* on the currently defined transition objects.
*/
protected function calculatePossibleTransitions() {
$transitions = $this->transitionStorage()->loadMultiple();
$possible_transitions = [];
/** @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */
foreach ($transitions as $transition) {
$possible_transitions[$transition->getFromState()][] = $transition->getToState();
}
return $possible_transitions;
}
/**
* Returns a mapping of possible transitions.
*
* @return array[]
* An array containing all possible transitions. Each entry is keyed by the
* "from" state, and the value is an array of all legal "to" states based
* on the currently defined transition objects.
*/
protected function getPossibleTransitions() {
if (empty($this->possibleTransitions)) {
$this->possibleTransitions = $this->calculatePossibleTransitions();
}
return $this->possibleTransitions;
}
/**
* {@inheritdoc}
*/
public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user) {
$bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
$states_for_bundle = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []);
/** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
$current_state = $entity->moderation_state->entity;
$all_transitions = $this->getPossibleTransitions();
$destination_ids = $all_transitions[$current_state->id()];
$destination_ids = array_intersect($states_for_bundle, $destination_ids);
$destinations = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple($destination_ids);
return array_filter($destinations, function(ModerationStateInterface $destination_state) use ($current_state, $user) {
return $this->userMayTransition($current_state, $destination_state, $user);
});
}
/**
* {@inheritdoc}
*/
public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) {
$bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
/** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
$current_state = $entity->moderation_state->entity;
$current_state_id = $current_state ? $current_state->id() : $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state');
// Determine the states that are legal on this bundle.
$legal_bundle_states = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []);
// Legal transitions include those that are possible from the current state,
// filtered by those whose target is legal on this bundle and that the
// user has access to execute.
$transitions = array_filter($this->getTransitionsFrom($current_state_id), function(ModerationStateTransition $transition) use ($legal_bundle_states, $user) {
return in_array($transition->getToState(), $legal_bundle_states, TRUE)
&& $user->hasPermission('use ' . $transition->id() . ' transition');
});
return $transitions;
}
/**
* Returns a list of possible transitions from a given state.
*
* This list is based only on those transitions that exist, not what
* transitions are legal in a given context.
*
* @param string $state_name
* The machine name of the state from which we are transitioning.
*
* @return ModerationStateTransition[]
* A list of possible transitions from a given state.
*/
protected function getTransitionsFrom($state_name) {
$result = $this->transitionStateQuery()
->condition('stateFrom', $state_name)
->sort('weight')
->execute();
return $this->transitionStorage()->loadMultiple($result);
}
/**
* {@inheritdoc}
*/
public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user) {
if ($transition = $this->getTransitionFromStates($from, $to)) {
return $user->hasPermission('use ' . $transition->id() . ' transition');
}
return FALSE;
}
/**
* Returns the transition object that transitions from one state to another.
*
* @param \Drupal\content_moderation\ModerationStateInterface $from
* The origin state.
* @param \Drupal\content_moderation\ModerationStateInterface $to
* The destination state.
*
* @return ModerationStateTransition|null
* A transition object, or NULL if there is no such transition.
*/
protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) {
$from = $this->transitionStateQuery()
->condition('stateFrom', $from->id())
->condition('stateTo', $to->id())
->execute();
$transitions = $this->transitionStorage()->loadMultiple($from);
if ($transitions) {
return current($transitions);
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to) {
$allowed_transitions = $this->calculatePossibleTransitions();
if (isset($allowed_transitions[$from->id()])) {
return in_array($to->id(), $allowed_transitions[$from->id()], TRUE);
}
return FALSE;
}
/**
* Returns a transition state entity query.
*
* @return \Drupal\Core\Entity\Query\QueryInterface
* A transition state entity query.
*/
protected function transitionStateQuery() {
return $this->queryFactory->get('moderation_state_transition', 'AND');
}
/**
* Returns the transition entity storage service.
*
* @return \Drupal\Core\Entity\EntityStorageInterface
* The transition state entity storage.
*/
protected function transitionStorage() {
return $this->entityTypeManager->getStorage('moderation_state_transition');
}
/**
* Returns the state entity storage service.
*
* @return \Drupal\Core\Entity\EntityStorageInterface
* The moderation state entity storage.
*/
protected function stateStorage() {
return $this->entityTypeManager->getStorage('moderation_state');
}
/**
* Loads a specific bundle entity.
*
* @param string $bundle_entity_type_id
* The bundle entity type ID.
* @param string $bundle_id
* The bundle ID.
*
* @return \Drupal\Core\Config\Entity\ConfigEntityInterface|null
* The specific bundle entity.
*/
protected function loadBundleEntity($bundle_entity_type_id, $bundle_id) {
return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Validates whether a certain state transition is allowed.
*/
interface StateTransitionValidationInterface {
/**
* Gets a list of states a user may transition an entity to.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to be transitioned.
* @param \Drupal\Core\Session\AccountInterface $user
* The account that wants to perform a transition.
*
* @return \Drupal\content_moderation\Entity\ModerationState[]
* Returns an array of States to which the specified user may transition the
* entity.
*/
public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user);
/**
* Gets a list of transitions that are legal for this user on this entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to be transitioned.
* @param \Drupal\Core\Session\AccountInterface $user
* The account that wants to perform a transition.
*
* @return \Drupal\content_moderation\Entity\ModerationStateTransition[]
* The list of transitions that are legal for this user on this entity.
*/
public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user);
/**
* Determines if a user is allowed to transition from one state to another.
*
* This method will also return FALSE if there is no transition between the
* specified states at all.
*
* @param \Drupal\content_moderation\ModerationStateInterface $from
* The origin state.
* @param \Drupal\content_moderation\ModerationStateInterface $to
* The destination state.
* @param \Drupal\Core\Session\AccountInterface $user
* The user to validate.
*
* @return bool
* TRUE if the given user may transition between those two states.
*/
public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user);
/**
* Determines a transition allowed.
*
* @param \Drupal\content_moderation\ModerationStateInterface $from
* The origin state.
* @param \Drupal\content_moderation\ModerationStateInterface $to
* The destination state.
*
* @return bool
* Is the transition allowed.
*/
public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to);
}

View file

@ -0,0 +1,109 @@
<?php
namespace Drupal\content_moderation\Tests;
/**
* Tests the moderation form, specifically on nodes.
*
* @group content_moderation
*/
class ModerationFormTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE, [
'draft',
'published',
], 'draft');
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
}
/**
* Tests the moderation form that shows on the latest version page.
*
* The latest version page only shows if there is a forward revision. There
* is only a forward revision if a draft revision is created on a node where
* the default revision is not a published moderation state.
*
* @see \Drupal\content_moderation\EntityOperations
* @see \Drupal\content_moderation\Tests\ModerationStateBlockTest::testCustomBlockModeration
*/
public function testModerationForm() {
// Create new moderated content in draft.
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
], t('Save and Create New Draft'));
$node = $this->drupalGetNodeByTitle('Some moderated content');
$canonical_path = sprintf('node/%d', $node->id());
$edit_path = sprintf('node/%d/edit', $node->id());
$latest_version_path = sprintf('node/%d/latest', $node->id());
$this->assertTrue($this->adminUser->hasPermission('edit any moderated_content content'));
// The latest version page should not show, because there is no forward
// revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Update the draft.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Second version of the content.',
], t('Save and Create New Draft'));
// The latest version page should not show, because there is still no
// forward revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Publish the draft.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Third version of the content.',
], t('Save and Publish'));
// The published view should not have a moderation form, because it is the
// default revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertNoText('Status', 'The node view page has no moderation form.');
// The latest version page should not show, because there is still no
// forward revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Make a forward revision.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Fourth version of the content.',
], t('Save and Create New Draft'));
// The published view should not have a moderation form, because it is the
// default revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertNoText('Status', 'The node view page has no moderation form.');
// The latest version page should show the moderation form and have "Draft"
// status, because the forward revision is in "Draft".
$this->drupalGet($latest_version_path);
$this->assertResponse(200);
$this->assertText('Status', 'Form text found on the latest-version page.');
$this->assertText('Draft', 'Correct status found on the latest-version page.');
// Submit the moderation form to change status to published.
$this->drupalPostForm($latest_version_path, [
'new_state' => 'published',
], t('Apply'));
// The latest version page should not show, because there is no
// forward revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
}
}

View file

@ -0,0 +1,221 @@
<?php
namespace Drupal\content_moderation\Tests;
/**
* Test content_moderation functionality with localization and translation.
*
* @group content_moderation
*/
class ModerationLocaleTest extends ModerationStateTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'node',
'content_moderation',
'locale',
'content_translation',
];
/**
* Tests article translations can be moderated separately.
*/
public function testTranslateModeratedContent() {
$this->drupalLogin($this->rootUser);
// Enable moderation on Article node type.
$this->createContentTypeFromUi(
'Article',
'article',
TRUE,
['draft', 'published', 'archived'],
'draft'
);
// Add French language.
$edit = [
'predefined_langcode' => 'fr',
];
$this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language'));
// Enable content translation on articles.
$this->drupalGet('admin/config/regional/content-language');
$edit = [
'entity_types[node]' => TRUE,
'settings[node][article][translatable]' => TRUE,
'settings[node][article][settings][language][language_alterable]' => TRUE,
];
$this->drupalPostForm(NULL, $edit, t('Save configuration'));
// Adding languages requires a container rebuild in the test running
// environment so that multilingual services are used.
$this->rebuildContainer();
// Create a published article in English.
$edit = [
'title[0][value]' => 'Published English node',
'langcode[0][value]' => 'en',
];
$this->drupalPostForm('node/add/article', $edit, t('Save and Publish'));
$this->assertText(t('Article Published English node has been created.'));
$english_node = $this->drupalGetNodeByTitle('Published English node');
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink(t('Add'));
$edit = [
'title[0][value]' => 'French node Draft',
];
$this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
// Here the error has occurred "The website encountered an unexpected error.
// Please try again later."
// If the translation has got lost.
$this->assertText(t('Article French node Draft has been updated.'));
// Create an article in English.
$edit = [
'title[0][value]' => 'English node',
'langcode[0][value]' => 'en',
];
$this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
$this->assertText(t('Article English node has been created.'));
$english_node = $this->drupalGetNodeByTitle('English node');
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink(t('Add'));
$edit = [
'title[0][value]' => 'French node',
];
$this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
$this->assertText(t('Article French node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('English node', TRUE);
// Publish the English article and check that the translation stays
// unpublished.
$this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
$this->assertText(t('Article English node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('English node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual('French node', $french_node->label());
$this->assertEqual($english_node->moderation_state->target_id, 'published');
$this->assertTrue($english_node->isPublished());
$this->assertEqual($french_node->moderation_state->target_id, 'draft');
$this->assertFalse($french_node->isPublished());
// Create another article with its translation. This time we will publish
// the translation first.
$edit = [
'title[0][value]' => 'Another node',
];
$this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
$this->assertText(t('Article Another node has been created.'));
$english_node = $this->drupalGetNodeByTitle('Another node');
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink(t('Add'));
$edit = [
'title[0][value]' => 'Translated node',
];
$this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
$this->assertText(t('Article Translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
// Publish the translation and check that the source language version stays
// unpublished.
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
$this->assertText(t('Article Translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->target_id, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($english_node->moderation_state->target_id, 'draft');
$this->assertFalse($english_node->isPublished());
// Now check that we can create a new draft of the translation.
$edit = [
'title[0][value]' => 'New draft of translated node',
];
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', $edit, t('Save and Create New Draft (this translation)'));
$this->assertText(t('Article New draft of translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->target_id, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($french_node->getTitle(), 'Translated node', 'The default revision of the published translation remains the same.');
// Publish the draft.
$edit = [
'new_state' => 'published',
];
$this->drupalPostForm('fr/node/' . $english_node->id() . '/latest', $edit, t('Apply'));
$this->assertText(t('The moderation state has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->target_id, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($french_node->getTitle(), 'New draft of translated node', 'The draft has replaced the published revision.');
// Publish the English article before testing the archive transition.
$this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
$this->assertText(t('Article Another node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$this->assertEqual($english_node->moderation_state->target_id, 'published');
// Archive the node and its translation.
$this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)'));
$this->assertText(t('Article Another node has been updated.'));
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)'));
$this->assertText(t('Article New draft of translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($english_node->moderation_state->target_id, 'archived');
$this->assertFalse($english_node->isPublished());
$this->assertEqual($french_node->moderation_state->target_id, 'archived');
$this->assertFalse($french_node->isPublished());
// Create another article with its translation. This time publishing english
// after creating a forward french revision.
$edit = [
'title[0][value]' => 'An english node',
];
$this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
$this->assertText(t('Article An english node has been created.'));
$english_node = $this->drupalGetNodeByTitle('An english node');
$this->assertFalse($english_node->isPublished());
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink(t('Add'));
$edit = [
'title[0][value]' => 'A french node',
];
$this->drupalPostForm(NULL, $edit, t('Save and Publish (this translation)'));
$english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$this->assertFalse($english_node->isPublished());
// Create a forward revision
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Create New Draft (this translation)'));
$english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$this->assertFalse($english_node->isPublished());
// Publish the english node and the default french node not the latest
// french node should be used.
$this->drupalPostForm('/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
$english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$this->assertTrue($english_node->isPublished());
}
}

View file

@ -0,0 +1,138 @@
<?php
namespace Drupal\content_moderation\Tests;
use Drupal\block_content\Entity\BlockContent;
use Drupal\block_content\Entity\BlockContentType;
/**
* Tests general content moderation workflow for blocks.
*
* @group content_moderation
*/
class ModerationStateBlockTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Create the "basic" block type.
$bundle = BlockContentType::create([
'id' => 'basic',
'label' => 'basic',
'revision' => FALSE,
]);
$bundle->save();
// Add the body field to it.
block_content_add_body_field($bundle->id());
}
/**
* Tests moderating custom blocks.
*
* Blocks and any non-node-type-entities do not have a concept of
* "published". As such, we must use the "default revision" to know what is
* going to be "published", i.e. visible to the user.
*
* The one exception is a block that has never been "published". When a block
* is first created, it becomes the "default revision". For each edit of the
* block after that, Content Moderation checks the "default revision" to
* see if it is set to a published moderation state. If it is not, the entity
* being saved will become the "default revision".
*
* The test below is intended, in part, to make this behavior clear.
*
* @see \Drupal\content_moderation\EntityOperations::entityPresave
* @see \Drupal\content_moderation\Tests\ModerationFormTest::testModerationForm
*/
public function testCustomBlockModeration() {
$this->drupalLogin($this->rootUser);
$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 = [
'enable_moderation_state' => TRUE,
'allowed_moderation_states_unpublished[draft]' => TRUE,
'allowed_moderation_states_published[published]' => TRUE,
'default_moderation_state' => 'draft',
];
$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 = array(
'id' => 'moderated_block',
'settings[label]' => $edit['info[0][value]'],
'region' => 'sidebar_first',
);
$block = BlockContent::load(1);
$url = 'admin/structure/block/add/block_content:' . $block->uuid() . '/' . $this->config('system.theme')->get('default');
$this->drupalPostForm($url, $instance, t('Save block'));
// Navigate to home page and check that the block is visible. It should be
// visible because it is the default revision.
$this->drupalGet('');
$this->assertText($body);
// Update the block.
$updated_body = 'This is the new body value';
$edit = [
'body[0][value]' => $updated_body,
];
$this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft'));
$this->assertText(t('basic Moderated block has been updated.'));
// Navigate to the home page and check that the block shows the updated
// content. It should show the updated content because the block's default
// revision is not a published moderation state.
$this->drupalGet('');
$this->assertText($updated_body);
// Publish the block so we can create a forward revision.
$this->drupalPostForm('block/' . $block->id(), [], t('Save and Publish'));
// Create a forward revision.
$forward_revision_body = 'This is the forward revision body value';
$edit = [
'body[0][value]' => $forward_revision_body,
];
$this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft'));
$this->assertText(t('basic Moderated block has been updated.'));
// Navigate to home page and check that the forward revision doesn't show,
// since it should not be set as the default revision.
$this->drupalGet('');
$this->assertText($updated_body);
// Open the latest tab and publish the new draft.
$edit = [
'new_state' => 'published',
];
$this->drupalPostForm('block/' . $block->id() . '/latest', $edit, t('Apply'));
$this->assertText(t('The moderation state has been updated.'));
// Navigate to home page and check that the forward revision is now the
// default revision and therefore visible.
$this->drupalGet('');
$this->assertText($forward_revision_body);
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace Drupal\content_moderation\Tests;
use Drupal\Core\Url;
use Drupal\node\Entity\Node;
/**
* Tests general content moderation workflow for nodes.
*
* @group content_moderation
*/
class ModerationStateNodeTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi(
'Moderated content',
'moderated_content',
TRUE,
['draft', 'needs_review', 'published'],
'draft'
);
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
}
/**
* Tests creating and deleting content.
*/
public function testCreatingContent() {
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'moderated content',
], t('Save and Create New Draft'));
$nodes = \Drupal::entityTypeManager()
->getStorage('node')
->loadByProperties([
'title' => 'moderated content',
]);
if (!$nodes) {
$this->fail('Test node was not saved correctly.');
return;
}
$node = reset($nodes);
$this->assertEqual('draft', $node->moderation_state->target_id);
$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->target_id);
// Verify that the state field is not shown.
$this->assertNoText('Published');
// Delete the node.
$this->drupalPostForm('node/' . $node->id() . '/delete', array(), t('Delete'));
$this->assertText(t('The Moderated content moderated content has been deleted.'));
$this->drupalGet('admin/structure/types/manage/moderated_content/moderation');
$this->assertFieldByName('enable_moderation_state');
$this->assertFieldChecked('edit-enable-moderation-state');
$this->drupalPostForm(NULL, ['enable_moderation_state' => FALSE], t('Save'));
$this->drupalGet('admin/structure/types/manage/moderated_content/moderation');
$this->assertFieldByName('enable_moderation_state');
$this->assertNoFieldChecked('edit-enable-moderation-state');
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'non-moderated content',
], t('Save and publish'));
$nodes = \Drupal::entityTypeManager()
->getStorage('node')
->loadByProperties([
'title' => 'non-moderated content',
]);
if (!$nodes) {
$this->fail('Non-moderated test node was not saved correctly.');
return;
}
$node = reset($nodes);
$this->assertEqual(NULL, $node->moderation_state->target_id);
}
/**
* Tests edit form destinations.
*/
public function testFormSaveDestination() {
// Create new moderated content in draft.
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
], t('Save and Create New Draft'));
$node = $this->drupalGetNodeByTitle('Some moderated content');
$edit_path = sprintf('node/%d/edit', $node->id());
// After saving, we should be at the canonical URL and viewing the first
// revision.
$this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertText('First version of the content.');
// Create a new draft; after saving, we should still be on the canonical
// URL, but viewing the second revision.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Second version of the content.',
], t('Save and Create New Draft'));
$this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertText('Second version of the content.');
// Make a new published revision; after saving, we should be at the
// canonical URL.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Third version of the content.',
], t('Save and Publish'));
$this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
$this->assertText('Third version of the content.');
// Make a new forward revision; after saving, we should be on the "Latest
// version" tab.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Fourth version of the content.',
], t('Save and Create New Draft'));
$this->assertUrl(Url::fromRoute('entity.node.latest_version', ['node' => $node->id()]));
$this->assertText('Fourth version of the content.');
}
/**
* Tests pagers aren't broken by content_moderation.
*/
public function testPagers() {
// Create 51 nodes to force the pager.
foreach (range(1, 51) as $delta) {
Node::create([
'type' => 'moderated_content',
'uid' => $this->adminUser->id(),
'title' => 'Node ' . $delta,
'status' => 1,
'moderation_state' => 'published',
])->save();
}
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/content');
$element = $this->cssSelect('nav.pager li.is-active a');
$url = (string) $element[0]['href'];
$query = [];
parse_str(parse_url($url, PHP_URL_QUERY), $query);
$this->assertEqual(0, $query['page']);
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Drupal\content_moderation\Tests;
/**
* Tests moderation state node type integration.
*
* @group content_moderation
*/
class ModerationStateNodeTypeTest extends ModerationStateTestBase {
/**
* A node type without moderation state disabled.
*/
public function testNotModerated() {
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Not moderated', 'not_moderated');
$this->assertText('The content type Not moderated has been added.');
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated');
$this->drupalGet('node/add/not_moderated');
$this->assertRaw('Save as unpublished');
$this->drupalPostForm(NULL, [
'title[0][value]' => 'Test',
], t('Save and publish'));
$this->assertText('Not moderated Test has been created.');
}
/**
* Tests enabling moderation on an existing node-type, with content.
*/
public function testEnablingOnExistingContent() {
// Create a node type that is not moderated.
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Not moderated', 'not_moderated');
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated');
// Create content.
$this->drupalGet('node/add/not_moderated');
$this->drupalPostForm(NULL, [
'title[0][value]' => 'Test',
], t('Save and publish'));
$this->assertText('Not moderated Test has been created.');
// Now enable moderation state.
$this->enableModerationThroughUi(
'not_moderated',
['draft', 'needs_review', 'published'],
'draft'
);
// And make sure it works.
$nodes = \Drupal::entityTypeManager()->getStorage('node')
->loadByProperties(['title' => 'Test']);
if (empty($nodes)) {
$this->fail('Could not load node with title Test');
return;
}
$node = reset($nodes);
$this->drupalGet('node/' . $node->id());
$this->assertResponse(200);
$this->assertLinkByHref('node/' . $node->id() . '/edit');
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertResponse(200);
$this->assertRaw('Save and Create New Draft');
$this->assertNoRaw('Save and publish');
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace Drupal\content_moderation\Tests;
/**
* Tests moderation state config entity.
*
* @group content_moderation
*/
class ModerationStateStatesTest extends ModerationStateTestBase {
/**
* Tests route access/permissions.
*/
public function testAccess() {
$paths = [
'admin/config/workflow/moderation',
'admin/config/workflow/moderation/states',
'admin/config/workflow/moderation/states/add',
'admin/config/workflow/moderation/states/draft',
'admin/config/workflow/moderation/states/draft/delete',
];
foreach ($paths as $path) {
$this->drupalGet($path);
// No access.
$this->assertResponse(403);
}
$this->drupalLogin($this->adminUser);
foreach ($paths as $path) {
$this->drupalGet($path);
// User has access.
$this->assertResponse(200);
}
}
/**
* Tests administration of moderation state entity.
*/
public function testStateAdministration() {
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/config/workflow/moderation');
$this->assertLink('Moderation states');
$this->assertLink('Moderation state transitions');
$this->clickLink('Moderation states');
$this->assertLink('Add moderation state');
$this->assertText('Draft');
// Edit the draft.
$this->clickLink('Edit', 0);
$this->assertFieldByName('label', 'Draft');
$this->assertNoFieldChecked('edit-published');
$this->drupalPostForm(NULL, [
'label' => 'Drafty',
], t('Save'));
$this->assertText('Saved the Drafty Moderation state.');
$this->drupalGet('admin/config/workflow/moderation/states/draft');
$this->assertFieldByName('label', 'Drafty');
$this->drupalPostForm(NULL, [
'label' => 'Draft',
], t('Save'));
$this->assertText('Saved the Draft Moderation state.');
$this->clickLink(t('Add moderation state'));
$this->drupalPostForm(NULL, [
'label' => 'Expired',
'id' => 'expired',
], t('Save'));
$this->assertText('Created the Expired Moderation state.');
$this->drupalGet('admin/config/workflow/moderation/states/expired');
$this->clickLink('Delete');
$this->assertText('Are you sure you want to delete Expired?');
$this->drupalPostForm(NULL, [], t('Delete'));
$this->assertText('Moderation state Expired deleted');
}
}

View file

@ -0,0 +1,149 @@
<?php
namespace Drupal\content_moderation\Tests;
use Drupal\Core\Session\AccountInterface;
use Drupal\simpletest\WebTestBase;
use Drupal\user\Entity\Role;
use Drupal\content_moderation\Entity\ModerationState;
/**
* Defines a base class for moderation state tests.
*/
abstract class ModerationStateTestBase extends WebTestBase {
/**
* Profile to use.
*/
protected $profile = 'testing';
/**
* Admin user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* Permissions to grant admin user.
*
* @var array
*/
protected $permissions = [
'administer moderation states',
'administer moderation state transitions',
'use draft_draft transition',
'use draft_published transition',
'use published_draft transition',
'use published_archived transition',
'access administration pages',
'administer content types',
'administer nodes',
'view latest version',
'view any unpublished content',
'access content overview',
];
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'content_moderation',
'block',
'block_content',
'node',
];
/**
* Sets the test up.
*/
protected function setUp() {
parent::setUp();
$this->adminUser = $this->drupalCreateUser($this->permissions);
$this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']);
$this->drupalPlaceBlock('page_title_block');
$this->drupalPlaceBlock('local_actions_block', ['id' => 'actions_block']);
}
/**
* Creates a content-type from the UI.
*
* @param string $content_type_name
* Content type human name.
* @param string $content_type_id
* Machine name.
* @param bool $moderated
* TRUE if should be moderated.
* @param string[] $allowed_states
* Array of allowed state IDs.
* @param string $default_state
* Default state.
*/
protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, array $allowed_states = [], $default_state = NULL) {
$this->drupalGet('admin/structure/types');
$this->clickLink('Add content type');
$edit = [
'name' => $content_type_name,
'type' => $content_type_id,
];
$this->drupalPostForm(NULL, $edit, t('Save content type'));
if ($moderated) {
$this->enableModerationThroughUi($content_type_id, $allowed_states, $default_state);
}
}
/**
* Enable moderation for a specified content type, using the UI.
*
* @param string $content_type_id
* Machine name.
* @param string[] $allowed_states
* Array of allowed state IDs.
* @param string $default_state
* Default state.
*/
protected function enableModerationThroughUi($content_type_id, array $allowed_states, $default_state) {
$this->drupalGet('admin/structure/types');
$this->assertLinkByHref('admin/structure/types/manage/' . $content_type_id . '/moderation');
$this->drupalGet('admin/structure/types/manage/' . $content_type_id);
$this->assertLinkByHref('admin/structure/types/manage/' . $content_type_id . '/moderation');
$this->drupalGet('admin/structure/types/manage/' . $content_type_id . '/moderation');
$this->assertFieldByName('enable_moderation_state');
$this->assertNoFieldChecked('edit-enable-moderation-state');
$edit['enable_moderation_state'] = 1;
/** @var ModerationState $state */
foreach (ModerationState::loadMultiple() as $state) {
$key = $state->isPublishedState() ? 'allowed_moderation_states_published[' . $state->id() . ']' : 'allowed_moderation_states_unpublished[' . $state->id() . ']';
$edit[$key] = in_array($state->id(), $allowed_states, TRUE) ? $state->id() : FALSE;
}
$edit['default_moderation_state'] = $default_state;
$this->drupalPostForm(NULL, $edit, t('Save'));
}
/**
* Grants given user permission to create content of given type.
*
* @param \Drupal\Core\Session\AccountInterface $account
* User to grant permission to.
* @param string $content_type_id
* Content type ID.
*/
protected function grantUserPermissionToCreateContentOfType(AccountInterface $account, $content_type_id) {
$role_ids = $account->getRoles(TRUE);
/* @var \Drupal\user\RoleInterface $role */
$role_id = reset($role_ids);
$role = Role::load($role_id);
$role->grantPermission(sprintf('create %s content', $content_type_id));
$role->grantPermission(sprintf('edit any %s content', $content_type_id));
$role->grantPermission(sprintf('delete any %s content', $content_type_id));
$role->save();
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace Drupal\content_moderation\Tests;
/**
* Tests moderation state transition config entity.
*
* @group content_moderation
*/
class ModerationStateTransitionsTest extends ModerationStateTestBase {
/**
* Tests route access/permissions.
*/
public function testAccess() {
$paths = [
'admin/config/workflow/moderation/transitions',
'admin/config/workflow/moderation/transitions/add',
'admin/config/workflow/moderation/transitions/draft_published',
'admin/config/workflow/moderation/transitions/draft_published/delete',
];
foreach ($paths as $path) {
$this->drupalGet($path);
// No access.
$this->assertResponse(403);
}
$this->drupalLogin($this->adminUser);
foreach ($paths as $path) {
$this->drupalGet($path);
// User has access.
$this->assertResponse(200);
}
}
/**
* Tests administration of moderation state transition entity.
*/
public function testTransitionAdministration() {
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/config/workflow/moderation');
$this->clickLink('Moderation state transitions');
$this->assertLink('Add moderation state transition');
$this->assertText('Create New Draft');
// Edit the Draft » Draft review.
$this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft');
$this->assertFieldByName('label', 'Create New Draft');
$this->assertFieldByName('stateFrom', 'draft');
$this->assertFieldByName('stateTo', 'draft');
$this->drupalPostForm(NULL, [
'label' => 'Create Draft',
], t('Save'));
$this->assertText('Saved the Create Draft Moderation state transition.');
$this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft');
$this->assertFieldByName('label', 'Create Draft');
// Now set it back.
$this->drupalPostForm(NULL, [
'label' => 'Create New Draft',
], t('Save'));
$this->assertText('Saved the Create New Draft Moderation state transition.');
// Add a new state.
$this->drupalGet('admin/config/workflow/moderation/states/add');
$this->drupalPostForm(NULL, [
'label' => 'Expired',
'id' => 'expired',
], t('Save'));
$this->assertText('Created the Expired Moderation state.');
// Add a new transition.
$this->drupalGet('admin/config/workflow/moderation/transitions');
$this->clickLink(t('Add moderation state transition'));
$this->drupalPostForm(NULL, [
'label' => 'Published » Expired',
'id' => 'published_expired',
'stateFrom' => 'published',
'stateTo' => 'expired',
], t('Save'));
$this->assertText('Created the Published » Expired Moderation state transition.');
// Delete the new transition.
$this->drupalGet('admin/config/workflow/moderation/transitions/published_expired');
$this->clickLink('Delete');
$this->assertText('Are you sure you want to delete Published » Expired?');
$this->drupalPostForm(NULL, [], t('Delete'));
$this->assertText('Moderation transition Published » Expired deleted');
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace Drupal\content_moderation\Tests;
/**
* Tests permission access control around nodes.
*
* @group content_moderation
*/
class NodeAccessTest extends ModerationStateTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi(
'Moderated content',
'moderated_content',
TRUE,
['draft', 'published'],
'draft'
);
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
}
/**
* Verifies that a non-admin user can still access the appropriate pages.
*/
public function testPageAccess() {
$this->drupalLogin($this->adminUser);
// Create a node to test with.
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'moderated content',
], t('Save and Create New Draft'));
$nodes = \Drupal::entityTypeManager()
->getStorage('node')
->loadByProperties([
'title' => 'moderated content',
]);
if (!$nodes) {
$this->fail('Test node was not saved correctly.');
return;
}
/** @var \Drupal\node\NodeInterface $node */
$node = reset($nodes);
$view_path = 'node/' . $node->id();
$edit_path = 'node/' . $node->id() . '/edit';
$latest_path = 'node/' . $node->id() . '/latest';
// Publish the node.
$this->drupalPostForm($edit_path, [], t('Save and Publish'));
// Ensure access works correctly for anonymous users.
$this->drupalLogout();
$this->drupalGet($edit_path);
$this->assertResponse(403);
$this->drupalGet($latest_path);
$this->assertResponse(403);
$this->drupalGet($view_path);
$this->assertResponse(200);
// Create a forward revision for the 'Latest revision' tab.
$this->drupalLogin($this->adminUser);
$this->drupalPostForm($edit_path, [
'title[0][value]' => 'moderated content revised',
], t('Save and Create New Draft'));
// Now make a new user and verify that the new user's access is correct.
$user = $this->createUser([
'use draft_draft transition',
'use published_draft transition',
'view latest version',
'view any unpublished content',
]);
$this->drupalLogin($user);
$this->drupalGet($edit_path);
$this->assertResponse(403);
$this->drupalGet($latest_path);
$this->assertResponse(200);
$this->drupalGet($view_path);
$this->assertResponse(200);
// Now make another user, who should not be able to see forward revisions.
$user = $this->createUser([
'use published_draft transition',
]);
$this->drupalLogin($user);
$this->drupalGet($edit_path);
$this->assertResponse(403);
$this->drupalGet($latest_path);
$this->assertResponse(403);
$this->drupalGet($view_path);
$this->assertResponse(200);
}
}

View file

@ -0,0 +1,266 @@
<?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'),
'join_extra' => [
[
'field' => 'content_entity_type_id',
'value' => $entity_type_id,
],
[
'field' => 'content_entity_revision_id',
'left_field' => $entity_type->getKey('revision'),
],
],
],
];
$revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
$data[$revision_table]['moderation_state'] = [
'title' => t('Moderation state'),
'relationship' => [
'id' => 'standard',
'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
'base' => $content_moderation_state_entity_revision_base_table,
'base field' => 'content_entity_revision_id',
'relationship field' => $entity_type->getKey('revision'),
'join_extra' => [
[
'field' => 'content_entity_type_id',
'value' => $entity_type_id,
],
],
],
];
}
return $data;
}
/**
* Alters the table and field information from hook_views_data().
*
* @param array $data
* An array of all information about Views tables and fields, collected from
* hook_views_data(), passed by reference.
*
* @see hook_views_data()
*/
public function alterViewsData(array &$data) {
$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();
}
}