Commit 0f139055 authored by catch's avatar catch

Issue #2779647 by alexpott, Sam152, catch, scookie, yoroy, pericxc,...

Issue #2779647 by alexpott, Sam152, catch, scookie, yoroy, pericxc, timmillwood, tacituseu, jhedstrom, xjm, bojanz, tstoeckler: Add a workflow component, ui module, and implement it in content moderation
parent e223ebe5
......@@ -142,7 +142,8 @@
"drupal/update": "self.version",
"drupal/user": "self.version",
"drupal/views": "self.version",
"drupal/views_ui": "self.version"
"drupal/views_ui": "self.version",
"drupal/workflows": "self.version"
},
"minimum-stability": "dev",
"prefer-stable": true,
......
langcode: en
status: true
dependencies: { }
id: archived
label: Archived
published: false
default_revision: true
weight: -8
langcode: en
status: true
dependencies: { }
id: draft
label: Draft
published: false
default_revision: false
weight: -10
langcode: en
status: true
dependencies: { }
id: published
label: Published
published: true
default_revision: true
weight: -9
langcode: en
status: true
dependencies:
config:
- content_moderation.state.archived
- content_moderation.state.draft
id: archived_draft
label: 'Un-archive to Draft'
stateFrom: archived
stateTo: draft
weight: -5
langcode: en
status: true
dependencies:
config:
- content_moderation.state.archived
- content_moderation.state.published
id: archived_published
label: 'Un-archive'
stateFrom: archived
stateTo: published
weight: -4
langcode: en
status: true
dependencies:
config:
- content_moderation.state.draft
id: draft_draft
label: 'Create New Draft'
stateFrom: draft
stateTo: draft
weight: -10
langcode: en
status: true
dependencies:
config:
- content_moderation.state.draft
- content_moderation.state.published
id: draft_published
label: 'Publish'
stateFrom: draft
stateTo: published
weight: -9
langcode: en
status: true
dependencies:
config:
- content_moderation.state.archived
- content_moderation.state.published
id: published_archived
label: 'Archive'
stateFrom: published
stateTo: archived
weight: -6
langcode: en
status: true
dependencies:
config:
- content_moderation.state.draft
- content_moderation.state.published
id: published_draft
label: 'Create New Draft'
stateFrom: published
stateTo: draft
weight: -8
langcode: en
status: true
dependencies:
config:
- content_moderation.state.published
id: published_published
label: 'Publish'
stateFrom: published
stateTo: published
weight: -7
langcode: en
status: true
dependencies:
module:
- content_moderation
id: editorial
label: 'Editorial workflow'
states:
archived:
label: Archived
weight: 5
draft:
label: Draft
weight: -5
published:
label: Published
weight: 0
transitions:
archive:
label: Archive
from:
- published
to: archived
weight: 2
archived_draft:
label: 'Un-archive to Draft'
from:
- archived
to: draft
weight: 3
archived_published:
label: Un-archive
from:
- archived
to: published
weight: 4
create_new_draft:
label: 'Create New Draft'
from:
- draft
- published
to: draft
weight: 0
publish:
label: Publish
from:
- draft
- published
to: published
weight: 1
type: content_moderation
type_settings:
states:
archived:
published: false
default_revision: true
draft:
published: false
default_revision: false
published:
published: true
default_revision: true
entity_types: { }
content_moderation.state.*:
type: config_entity
label: 'Moderation state config'
mapping:
id:
type: string
label: 'ID'
label:
type: label
label: 'Label'
published:
type: boolean
label: 'Is published'
default_revision:
type: boolean
label: 'Is default revision'
weight:
type: integer
label: 'Weight'
content_moderation.state_transition.*:
type: config_entity
label: 'Moderation state transition config'
views.filter.latest_revision:
type: views_filter
label: 'Latest revision'
mapping:
id:
type: string
label: 'ID'
label:
type: label
label: 'Label'
stateFrom:
type: string
label: 'From state'
stateTo:
value:
type: string
label: 'To state'
weight:
type: integer
label: 'Weight'
label: 'Value'
node.type.*.third_party.content_moderation:
workflow.type_settings.content_moderation:
type: mapping
label: 'Enable moderation states for this node type'
mapping:
enabled:
type: boolean
label: 'Moderation states enabled'
allowed_moderation_states:
states:
type: sequence
label: 'Additional state configuration for content moderation'
sequence:
type: string
label: 'Moderation state'
default_moderation_state:
type: string
label: 'Moderation state for new content'
block_content.type.*.third_party.content_moderation:
type: mapping
label: 'Enable moderation states for this block content type'
mapping:
enabled:
type: boolean
label: 'Moderation states enabled'
allowed_moderation_states:
type: mapping
label: 'States'
mapping:
published:
type: boolean
label: 'Is published'
default_revision:
type: boolean
label: 'Is default revision'
entity_types:
type: sequence
label: 'Entity types'
sequence:
type: string
label: 'Moderation state'
default_moderation_state:
type: string
label: 'Moderation state for new block content'
views.filter.latest_revision:
type: views_filter
label: 'Latest revision'
mapping:
value:
type: string
label: 'Value'
type: sequence
label: 'Bundles'
sequence:
type: string
label: 'Bundle ID'
......@@ -5,3 +5,5 @@ version: VERSION
core: 8.x
package: Core (Experimental)
configure: content_moderation.overview
dependencies:
- workflows
entity.moderation_state.add_form:
route_name: 'entity.moderation_state.add_form'
title: 'Add moderation state'
appears_on:
- entity.moderation_state.collection
entity.moderation_state_transition.add_form:
route_name: 'entity.moderation_state_transition.add_form'
title: 'Add moderation state transition'
appears_on:
- entity.moderation_state_transition.collection
# Moderation state menu items definition
content_moderation.overview:
title: 'Content moderation'
route_name: content_moderation.overview
description: 'Configure states and transitions for entities.'
parent: system.admin_config_workflow
entity.moderation_state.collection:
title: 'Moderation states'
route_name: entity.moderation_state.collection
description: 'Administer moderation states.'
parent: content_moderation.overview
weight: 10
# Moderation state transition menu items definition
entity.moderation_state_transition.collection:
title: 'Moderation state transitions'
route_name: entity.moderation_state_transition.collection
description: 'Administer moderation states transitions.'
parent: content_moderation.overview
weight: 20
moderation_state.entities:
content_moderation.workflows:
deriver: 'Drupal\content_moderation\Plugin\Derivative\DynamicLocalTasks'
weight: 100
......@@ -18,9 +18,11 @@
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\workflows\WorkflowInterface;
use Drupal\node\NodeInterface;
use Drupal\node\Plugin\Action\PublishNode;
use Drupal\node\Plugin\Action\UnpublishNode;
use Drupal\workflows\Entity\Workflow;
/**
* Implements hook_help().
......@@ -31,15 +33,13 @@ function content_moderation_help($route_name, RouteMatchInterface $route_match)
case 'help.page.content_moderation':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('The Content Moderation module provides basic moderation for content. This lets site admins define states for content, and then define transitions between those states. For more information, see the <a href=":content_moderation">online documentation for the Content Moderation module</a>.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation']) . '</p>';
$output .= '<p>' . t('The Content Moderation module provides moderation for content by applying workflows to content. For more information, see the <a href=":content_moderation">online documentation for the Content Moderation module</a>.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation']) . '</p>';
$output .= '<h3>' . t('Uses') . '</h3>';
$output .= '<dl>';
$output .= '<dt>' . t('Moderation states') . '</dt>';
$output .= '<dd>' . t('Moderation states provide the <em>Draft</em> and <em>Archived</em> states as additions to the basic <em>Published</em> option. You can click the blue <em>Add Moderation state</em> button and create new states.') . '</dd>';
$output .= '<dt>' . t('Moderation state transitions') . '</dt>';
$output .= '<dd>' . t('Using the "Moderation state transitions" screen, you can create the actual workflow. You decide the direction in which content moves from state to state, and which user roles are allowed to make that move.') . '</dd>';
$output .= '<dt>' . t('Configuring workflows') . '</dt>';
$output .= '<dd>' . t('Enable the Workflow UI module to create, edit and delete content moderation workflows.') . '</p>';
$output .= '<dt>' . t('Configure Content Moderation permissions') . '</dt>';
$output .= '<dd>' . t('Each state is exposed as a permission. If a user has the permission for a transition, then they can move that node from the start state to the end state') . '</p>';
$output .= '<dd>' . t('Each transition is exposed as a permission. If a user has the permission for a transition, then they can move that node from the start state to the end state') . '</p>';
$output .= '</dl>';
return $output;
}
......@@ -182,15 +182,17 @@ function content_moderation_node_access(NodeInterface $node, $operation, Account
$access_result->addCacheableDependency($node);
}
elseif ($operation === 'update' && $moderation_info->isModeratedEntity($node) && $node->moderation_state && $node->moderation_state->target_id) {
elseif ($operation === 'update' && $moderation_info->isModeratedEntity($node) && $node->moderation_state) {
/** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */
$transition_validation = \Drupal::service('content_moderation.state_transition_validation');
$valid_transition_targets = $transition_validation->getValidTransitionTargets($node, $account);
$valid_transition_targets = $transition_validation->getValidTransitions($node, $account);
$access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden();
$access_result->addCacheableDependency($node);
$access_result->addCacheableDependency($account);
$workflow = \Drupal::service('content_moderation.moderation_information')->getWorkflowForEntity($node);
$access_result->addCacheableDependency($workflow);
foreach ($valid_transition_targets as $valid_transition_target) {
$access_result->addCacheableDependency($valid_transition_target);
}
......@@ -222,3 +224,39 @@ function content_moderation_action_info_alter(&$definitions) {
$definitions['node_unpublish_action']['class'] = ModerationOptOutUnpublishNode::class;
}
}
/**
* Implements hook_entity_bundle_info_alter().
*/
function content_moderation_entity_bundle_info_alter(&$bundles) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */
$plugin = $workflow->getTypePlugin();
foreach ($plugin->getEntityTypes() as $entity_type_id) {
foreach ($plugin->getBundlesForEntityType($entity_type_id) as $bundle_id) {
if (isset($bundles[$entity_type_id][$bundle_id])) {
$bundles[$entity_type_id][$bundle_id]['workflow'] = $workflow->id();
}
}
}
}
}
/**
* Implements hook_ENTITY_TYPE_insert().
*/
function content_moderation_workflow_insert(WorkflowInterface $entity) {
// Clear bundle cache so workflow gets added or removed from the bundle
// information.
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
// Clear field cache so extra field is added or removed.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
}
/**
* Implements hook_ENTITY_TYPE_update().
*/
function content_moderation_workflow_update(WorkflowInterface $entity) {
content_moderation_workflow_insert($entity);
}
......@@ -2,18 +2,13 @@ view any unpublished content:
title: 'View any unpublished content'
description: 'This permission is necessary for any users that may moderate content.'
'view moderation states':
title: 'View moderation states'
description: 'View moderation states.'
'view content moderation':
title: 'View content moderation'
description: 'View content moderation.'
'administer moderation states':
title: 'Administer moderation states'
description: 'Create and edit moderation states.'
'restrict access': TRUE
'administer moderation state transitions':
title: 'Administer content moderation state transitions'
description: 'Create and edit content moderation state transitions.'
'administer content moderation':
title: 'Administer content moderation'
description: 'Administer workflows on content entities.'
'restrict access': TRUE
view latest version:
......
content_moderation.overview:
path: '/admin/config/workflow/moderation'
defaults:
_controller: '\Drupal\system\Controller\SystemController::systemAdminMenuBlockPage'
_title: 'Content moderation'
requirements:
_permission: 'access administration pages'
......@@ -6,10 +6,10 @@ services:
- { name: paramconverter, priority: 5 }
content_moderation.state_transition_validation:
class: \Drupal\content_moderation\StateTransitionValidation
arguments: ['@entity_type.manager', '@entity.query']
arguments: ['@content_moderation.moderation_information']
content_moderation.moderation_information:
class: Drupal\content_moderation\ModerationInformation
arguments: ['@entity_type.manager']
arguments: ['@entity_type.manager', '@entity_type.bundle.info']
access_check.latest_revision:
class: Drupal\content_moderation\Access\LatestRevisionCheck
arguments: ['@content_moderation.moderation_information']
......
<?php
namespace Drupal\content_moderation;
use Drupal\workflows\StateInterface;
/**
* A value object representing a workflow state for content moderation.
*/
class ContentModerationState implements StateInterface {
/**
* The vanilla state object from the Workflow module.
*
* @var \Drupal\workflows\StateInterface
*/
protected $state;
/**
* If entities should be published if in this state.
*
* @var bool
*/
protected $published;
/**
* If entities should be the default revision if in this state.
*
* @var bool
*/
protected $defaultRevision;
/**
* ContentModerationState constructor.
*
* Decorates state objects to add methods to determine if an entity should be
* published or made the default revision.
*
* @param \Drupal\workflows\StateInterface $state
* The vanilla state object from the Workflow module.
* @param bool $published
* (optional) TRUE if entities should be published if in this state, FALSE
* if not. Defaults to FALSE.
* @param bool $default_revision
* (optional) TRUE if entities should be the default revision if in this
* state, FALSE if not. Defaults to FALSE.
*/
public function __construct(StateInterface $state, $published = FALSE, $default_revision = FALSE) {
$this->state = $state;
$this->published = $published;
$this->defaultRevision = $default_revision;
}
/**
* Determines if entities should be published if in this state.
*
* @return bool
*/
public function isPublishedState() {
return $this->published;
}
/**
* Determines if entities should be the default revision if in this state.
*
* @return bool
*/
public function isDefaultRevisionState() {
return $this->defaultRevision;
}
/**
* {@inheritdoc}
*/
public function id() {
return $this->state->id();
}
/**
* {@inheritdoc}
*/
public function label() {
return $this->state->label();
}
/**
* {@inheritdoc}
*/
public function weight() {
return $this->state->weight();
}
/**
* {@inheritdoc}
*/
public function canTransitionTo($to_state_id) {
return $this->state->canTransitionTo($to_state_id);
}
/**
* {@inheritdoc}
*/
public function getTransitionTo($to_state_id) {
return $this->state->getTransitionTo($to_state_id);
}
/**
* {@inheritdoc}
*/
public function getTransitions() {
return $this->state->getTransitions();
}
}
......@@ -55,10 +55,17 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
->setTranslatable(TRUE)
->setRevisionable(TRUE);
$fields['moderation_state'] = BaseFieldDefinition::create('entity_reference')
$fields['workflow'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Workflow'))
->setDescription(t('The workflow the moderation state is in.'))
->setSetting('target_type', 'workflow')
->setRequired(TRUE)
->setTranslatable(TRUE)
->setRevisionable(TRUE);
$fields['moderation_state'] = BaseFieldDefinition::create('string')
->setLabel(t('Moderation state'))
->setDescription(t('The moderation state of the referenced content.'))
->setSetting('target_type', 'moderation_state')
->setRequired(TRUE)
->setTranslatable(TRUE)
->setRevisionable(TRUE)
......@@ -155,7 +162,7 @@ public function save() {
if ($related_entity instanceof TranslatableInterface) {
$related_entity = $related_entity->getTranslation($this->activeLangcode);
}
$related_entity->moderation_state->target_id = $this->moderation_state->target_id;
$related_entity->moderation_state = $this->moderation_state;
return $related_entity->save();
}
......
......@@ -2,14 +2,43 @@
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Customizations for node entities.
*/
class NodeModerationHandler extends ModerationHandler {
/**
* The moderation information service.